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 }, nil)
87 log.Printf("sb-mcp: MCP server listening on %s", cfg.HTTPAddr)
88 return http.ListenAndServe(cfg.HTTPAddr, handler)
89 }
90
91 // Stdio transport with logging to stderr
92 t := &mcp.LoggingTransport{
93 Transport: &mcp.StdioTransport{},
94 Writer: os.Stderr,
95 }
96 log.Printf("sb-mcp: starting stdio transport")
97 return server.Run(ctx, t)
98}
99
100// ptrBool returns a pointer to the given bool value.
101func ptrBool(b bool) *bool {
102 return &b
103}
104
105// makeExecuteLuaHandler returns a tool handler that closes over the SB client.
106func makeExecuteLuaHandler(client *silverbullet.Client, defaultTimeout int) func(context.Context, *mcp.CallToolRequest, ExecuteLuaParams) (*mcp.CallToolResult, any, error) {
107 return func(ctx context.Context, req *mcp.CallToolRequest, params ExecuteLuaParams) (*mcp.CallToolResult, any, error) {
108 timeout := params.Timeout
109 if timeout == 0 {
110 timeout = defaultTimeout
111 }
112
113 result, err := client.ExecuteLua(ctx, params.Script, timeout)
114 if err != nil {
115 return nil, nil, fmt.Errorf("executing lua: %w", err)
116 }
117
118 if result.Error != "" {
119 return &mcp.CallToolResult{
120 Content: []mcp.Content{
121 &mcp.TextContent{Text: fmt.Sprintf("Lua error: %s", result.Error)},
122 },
123 IsError: true,
124 }, nil, nil
125 }
126
127 formatted := formatLuaResult(result.Result)
128
129 return &mcp.CallToolResult{
130 Content: []mcp.Content{
131 &mcp.TextContent{Text: formatted},
132 },
133 }, nil, nil
134 }
135}
136
137// makeScreenshotHandler returns a tool handler that closes over the SB client.
138func makeScreenshotHandler(client *silverbullet.Client) func(context.Context, *mcp.CallToolRequest, ScreenshotParams) (*mcp.CallToolResult, any, error) {
139 return func(ctx context.Context, req *mcp.CallToolRequest, params ScreenshotParams) (*mcp.CallToolResult, any, error) {
140 data, err := client.Screenshot(ctx)
141 if err != nil {
142 return nil, nil, fmt.Errorf("fetching screenshot: %w", err)
143 }
144
145 return &mcp.CallToolResult{
146 Content: []mcp.Content{
147 &mcp.ImageContent{
148 Data: data,
149 MIMEType: "image/png",
150 },
151 },
152 }, nil, nil
153 }
154}
155
156// makeConsoleLogsHandler returns a tool handler that closes over the SB client.
157func makeConsoleLogsHandler(client *silverbullet.Client) func(context.Context, *mcp.CallToolRequest, ConsoleLogsParams) (*mcp.CallToolResult, any, error) {
158 return func(ctx context.Context, req *mcp.CallToolRequest, params ConsoleLogsParams) (*mcp.CallToolResult, any, error) {
159 limit := params.Limit
160 if limit == 0 {
161 limit = 100
162 }
163
164 result, err := client.ConsoleLogs(ctx, limit, params.Since)
165 if err != nil {
166 return nil, nil, fmt.Errorf("fetching logs: %w", err)
167 }
168
169 logsJSON, err := json.MarshalIndent(result.Logs, "", " ")
170 if err != nil {
171 return nil, nil, fmt.Errorf("formatting logs: %w", err)
172 }
173
174 return &mcp.CallToolResult{
175 Content: []mcp.Content{
176 &mcp.TextContent{Text: string(logsJSON)},
177 },
178 }, nil, nil
179 }
180}
181
182// formatLuaResult formats a JSON result from Lua execution for display.
183// JSON strings are unescaped to plain text (so markdown content reads cleanly).
184// All other values (numbers, bools, null, objects, arrays) pass through as raw JSON.
185func formatLuaResult(raw json.RawMessage) string {
186 if raw == nil {
187 return "null"
188 }
189
190 var v any
191 if json.Unmarshal(raw, &v) == nil {
192 if s, ok := v.(string); ok {
193 return s
194 }
195 }
196
197 return string(raw)
198}