server.go

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