server.go

  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}