1// Package server wires up the MCP server with tools and transport selection.
2package server
3
4import (
5 "context"
6 "encoding/json"
7 "fmt"
8 "log"
9 "net/http"
10 "os"
11
12 "github.com/modelcontextprotocol/go-sdk/mcp"
13
14 "git.secluded.site/sb-mcp/internal/config"
15 "git.secluded.site/sb-mcp/internal/silverbullet"
16)
17
18// Version is set at build time via ldflags.
19var Version = "dev"
20
21// New creates a fully-wired MCP server with all tools registered.
22func New(cfg *config.Config) *mcp.Server {
23 sbClient := silverbullet.New(cfg.SBURL, silverbullet.Auth{
24 User: cfg.SBUser,
25 Pass: cfg.SBPass,
26 Token: cfg.SBToken,
27 })
28 defaultTimeout := cfg.DefaultTimeout
29 if defaultTimeout == 0 {
30 defaultTimeout = 120
31 }
32
33 server := mcp.NewServer(&mcp.Implementation{
34 Name: "sb-mcp",
35 Version: Version,
36 }, &mcp.ServerOptions{
37 Instructions: Instructions,
38 })
39
40 // Register tools with the SB client closed over
41 mcp.AddTool(server, &mcp.Tool{
42 Name: "execute_lua",
43 Description: "Execute a Space Lua script on the SilverBullet instance. Use 'return' to send results back. 'print()' output is not captured.",
44 Annotations: &mcp.ToolAnnotations{
45 Title: "Execute Lua",
46 ReadOnlyHint: false,
47 DestructiveHint: ptrBool(true),
48 IdempotentHint: false,
49 OpenWorldHint: ptrBool(false),
50 },
51 }, makeExecuteLuaHandler(sbClient, defaultTimeout))
52
53 mcp.AddTool(server, &mcp.Tool{
54 Name: "screenshot",
55 Description: "Capture the current SilverBullet viewport as a PNG image.",
56 Annotations: &mcp.ToolAnnotations{
57 Title: "Screenshot",
58 ReadOnlyHint: true,
59 IdempotentHint: false,
60 OpenWorldHint: ptrBool(false),
61 },
62 }, makeScreenshotHandler(sbClient))
63
64 mcp.AddTool(server, &mcp.Tool{
65 Name: "console_logs",
66 Description: "Retrieve recent console log entries from SilverBullet for debugging.",
67 Annotations: &mcp.ToolAnnotations{
68 Title: "Console Logs",
69 ReadOnlyHint: true,
70 IdempotentHint: false,
71 OpenWorldHint: ptrBool(false),
72 },
73 }, makeConsoleLogsHandler(sbClient))
74
75 return server
76}
77
78// Run starts the MCP server with the appropriate transport.
79// If cfg.HTTPAddr is set, uses streamable HTTP; otherwise uses stdio.
80func Run(ctx context.Context, cfg *config.Config) error {
81 server := New(cfg)
82
83 if cfg.HTTPAddr != "" {
84 handler := mcp.NewStreamableHTTPHandler(func(r *http.Request) *mcp.Server {
85 return server
86 }, &mcp.StreamableHTTPOptions{
87 DisableLocalhostProtection: true,
88 })
89 log.Printf("sb-mcp: MCP server listening on %s", cfg.HTTPAddr)
90 return http.ListenAndServe(cfg.HTTPAddr, handler)
91 }
92
93 // Stdio transport with logging to stderr
94 t := &mcp.LoggingTransport{
95 Transport: &mcp.StdioTransport{},
96 Writer: os.Stderr,
97 }
98 log.Printf("sb-mcp: starting stdio transport")
99 return server.Run(ctx, t)
100}
101
102// ptrBool returns a pointer to the given bool value.
103func ptrBool(b bool) *bool {
104 return &b
105}
106
107// makeExecuteLuaHandler returns a tool handler that closes over the SB client.
108func makeExecuteLuaHandler(client *silverbullet.Client, defaultTimeout int) func(context.Context, *mcp.CallToolRequest, ExecuteLuaParams) (*mcp.CallToolResult, any, error) {
109 return func(ctx context.Context, req *mcp.CallToolRequest, params ExecuteLuaParams) (*mcp.CallToolResult, any, error) {
110 timeout := params.Timeout
111 if timeout == 0 {
112 timeout = defaultTimeout
113 }
114
115 result, err := client.ExecuteLua(ctx, params.Script, timeout)
116 if err != nil {
117 return nil, nil, err
118 }
119
120 if result.Error != "" {
121 return &mcp.CallToolResult{
122 Content: []mcp.Content{
123 &mcp.TextContent{Text: fmt.Sprintf("Lua error: %s", result.Error)},
124 },
125 IsError: true,
126 }, nil, nil
127 }
128
129 formatted := formatLuaResult(result.Result)
130
131 return &mcp.CallToolResult{
132 Content: []mcp.Content{
133 &mcp.TextContent{Text: formatted},
134 },
135 }, nil, nil
136 }
137}
138
139// makeScreenshotHandler returns a tool handler that closes over the SB client.
140func makeScreenshotHandler(client *silverbullet.Client) func(context.Context, *mcp.CallToolRequest, ScreenshotParams) (*mcp.CallToolResult, any, error) {
141 return func(ctx context.Context, req *mcp.CallToolRequest, params ScreenshotParams) (*mcp.CallToolResult, any, error) {
142 data, err := client.Screenshot(ctx)
143 if err != nil {
144 return nil, nil, err
145 }
146
147 return &mcp.CallToolResult{
148 Content: []mcp.Content{
149 &mcp.ImageContent{
150 Data: data,
151 MIMEType: "image/png",
152 },
153 },
154 }, nil, nil
155 }
156}
157
158// makeConsoleLogsHandler returns a tool handler that closes over the SB client.
159func makeConsoleLogsHandler(client *silverbullet.Client) func(context.Context, *mcp.CallToolRequest, ConsoleLogsParams) (*mcp.CallToolResult, any, error) {
160 return func(ctx context.Context, req *mcp.CallToolRequest, params ConsoleLogsParams) (*mcp.CallToolResult, any, error) {
161 limit := params.Limit
162 if limit == 0 {
163 limit = 100
164 }
165
166 result, err := client.ConsoleLogs(ctx, limit, params.Since)
167 if err != nil {
168 return nil, nil, err
169 }
170
171 logsJSON, err := json.MarshalIndent(result.Logs, "", " ")
172 if err != nil {
173 return nil, nil, fmt.Errorf("formatting logs: %w", err)
174 }
175
176 return &mcp.CallToolResult{
177 Content: []mcp.Content{
178 &mcp.TextContent{Text: string(logsJSON)},
179 },
180 }, nil, nil
181 }
182}
183
184// formatLuaResult formats a JSON result from Lua execution for display.
185// JSON strings are unescaped to plain text (so markdown content reads cleanly).
186// All other values (numbers, bools, null, objects, arrays) pass through as raw JSON.
187func formatLuaResult(raw json.RawMessage) string {
188 if raw == nil {
189 return "null"
190 }
191
192 var v any
193 if json.Unmarshal(raw, &v) == nil {
194 if s, ok := v.(string); ok {
195 return s
196 }
197 }
198
199 return string(raw)
200}