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