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		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}