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