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: "usage",
45 Description: "Load the SilverBullet usage guide, gotchas, and best practices. Call this BEFORE execute_lua because you WILL mess up otherwise!",
46 Annotations: &mcp.ToolAnnotations{
47 Title: "Usage Guide",
48 ReadOnlyHint: true,
49 IdempotentHint: true,
50 OpenWorldHint: new(false),
51 },
52 }, makeUsageHandler())
53
54 mcp.AddTool(server, &mcp.Tool{
55 Name: "execute_lua",
56 Description: "Execute a Space Lua script on the SilverBullet instance. " +
57 "DO NOT call this without FIRST calling usage to understand how to interact with SilverBullet in the first place.",
58 Annotations: &mcp.ToolAnnotations{
59 Title: "Execute Lua",
60 ReadOnlyHint: false,
61 DestructiveHint: new(true),
62 IdempotentHint: false,
63 OpenWorldHint: new(true),
64 },
65 }, makeExecuteLuaHandler(sbClient, defaultTimeout))
66
67 mcp.AddTool(server, &mcp.Tool{
68 Name: "screenshot",
69 Description: "Capture the current SilverBullet viewport as a PNG image.",
70 Annotations: &mcp.ToolAnnotations{
71 Title: "Screenshot",
72 ReadOnlyHint: true,
73 IdempotentHint: false,
74 OpenWorldHint: new(false),
75 },
76 }, makeScreenshotHandler(sbClient))
77
78 mcp.AddTool(server, &mcp.Tool{
79 Name: "console_logs",
80 Description: "Retrieve recent console log entries from SilverBullet for debugging.",
81 Annotations: &mcp.ToolAnnotations{
82 Title: "Console Logs",
83 ReadOnlyHint: true,
84 IdempotentHint: false,
85 OpenWorldHint: new(false),
86 },
87 }, makeConsoleLogsHandler(sbClient))
88
89 return server
90}
91
92// Run starts the MCP server with the appropriate transport.
93// If cfg.HTTPAddr is set, uses streamable HTTP; otherwise uses stdio.
94func Run(ctx context.Context, cfg *config.Config) error {
95 server := New(cfg)
96
97 if cfg.HTTPAddr != "" {
98 handler := mcp.NewStreamableHTTPHandler(func(r *http.Request) *mcp.Server {
99 return server
100 }, &mcp.StreamableHTTPOptions{
101 DisableLocalhostProtection: true,
102 })
103 log.Printf("sb-mcp: MCP server listening on %s", cfg.HTTPAddr)
104 return http.ListenAndServe(cfg.HTTPAddr, handler)
105 }
106
107 // Stdio transport with logging to stderr
108 t := &mcp.LoggingTransport{
109 Transport: &mcp.StdioTransport{},
110 Writer: os.Stderr,
111 }
112 log.Printf("sb-mcp: starting stdio transport")
113 return server.Run(ctx, t)
114}
115
116// makeExecuteLuaHandler returns a tool handler that closes over the SB client.
117func makeExecuteLuaHandler(client *silverbullet.Client, defaultTimeout int) func(context.Context, *mcp.CallToolRequest, ExecuteLuaParams) (*mcp.CallToolResult, any, error) {
118 return func(ctx context.Context, req *mcp.CallToolRequest, params ExecuteLuaParams) (*mcp.CallToolResult, any, error) {
119 timeout := params.Timeout
120 if timeout == 0 {
121 timeout = defaultTimeout
122 }
123
124 result, err := client.ExecuteLua(ctx, params.Script, timeout)
125 if err != nil {
126 return nil, nil, err
127 }
128
129 if result.Error != "" {
130 return &mcp.CallToolResult{
131 Content: []mcp.Content{
132 &mcp.TextContent{Text: fmt.Sprintf("Lua error: %s", result.Error)},
133 },
134 IsError: true,
135 }, nil, nil
136 }
137
138 formatted := formatLuaResult(result.Result)
139
140 return &mcp.CallToolResult{
141 Content: []mcp.Content{
142 &mcp.TextContent{Text: formatted},
143 },
144 }, nil, nil
145 }
146}
147
148// makeScreenshotHandler returns a tool handler that closes over the SB client.
149func makeScreenshotHandler(client *silverbullet.Client) func(context.Context, *mcp.CallToolRequest, ScreenshotParams) (*mcp.CallToolResult, any, error) {
150 return func(ctx context.Context, req *mcp.CallToolRequest, params ScreenshotParams) (*mcp.CallToolResult, any, error) {
151 data, err := client.Screenshot(ctx)
152 if err != nil {
153 return nil, nil, err
154 }
155
156 return &mcp.CallToolResult{
157 Content: []mcp.Content{
158 &mcp.ImageContent{
159 Data: data,
160 MIMEType: "image/png",
161 },
162 },
163 }, nil, nil
164 }
165}
166
167// makeConsoleLogsHandler returns a tool handler that closes over the SB client.
168func makeConsoleLogsHandler(client *silverbullet.Client) func(context.Context, *mcp.CallToolRequest, ConsoleLogsParams) (*mcp.CallToolResult, any, error) {
169 return func(ctx context.Context, req *mcp.CallToolRequest, params ConsoleLogsParams) (*mcp.CallToolResult, any, error) {
170 limit := params.Limit
171 if limit == 0 {
172 limit = 100
173 }
174
175 result, err := client.ConsoleLogs(ctx, limit, params.Since)
176 if err != nil {
177 return nil, nil, err
178 }
179
180 logsJSON, err := json.MarshalIndent(result.Logs, "", " ")
181 if err != nil {
182 return nil, nil, fmt.Errorf("formatting logs: %w", err)
183 }
184
185 return &mcp.CallToolResult{
186 Content: []mcp.Content{
187 &mcp.TextContent{Text: string(logsJSON)},
188 },
189 }, nil, nil
190 }
191}
192
193// makeUsageHandler returns a tool handler that returns the embedded SilverBullet usage guide.
194func makeUsageHandler() func(context.Context, *mcp.CallToolRequest, UsageParams) (*mcp.CallToolResult, any, error) {
195 return func(_ context.Context, _ *mcp.CallToolRequest, _ UsageParams) (*mcp.CallToolResult, any, error) {
196 return &mcp.CallToolResult{
197 Content: []mcp.Content{
198 &mcp.TextContent{Text: Instructions},
199 },
200 }, nil, nil
201 }
202}
203
204// formatLuaResult formats a JSON result from Lua execution for display.
205// JSON strings are unescaped to plain text (so markdown content reads cleanly).
206// All other values (numbers, bools, null, objects, arrays) pass through as raw JSON.
207func formatLuaResult(raw json.RawMessage) string {
208 if raw == nil {
209 return "null"
210 }
211
212 var v any
213 if json.Unmarshal(raw, &v) == nil {
214 if s, ok := v.(string); ok {
215 return s
216 }
217 }
218
219 return string(raw)
220}