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		}, nil)
 87		log.Printf("sb-mcp: MCP server listening on %s", cfg.HTTPAddr)
 88		return http.ListenAndServe(cfg.HTTPAddr, handler)
 89	}
 90
 91	// Stdio transport with logging to stderr
 92	t := &mcp.LoggingTransport{
 93		Transport: &mcp.StdioTransport{},
 94		Writer:    os.Stderr,
 95	}
 96	log.Printf("sb-mcp: starting stdio transport")
 97	return server.Run(ctx, t)
 98}
 99
100// ptrBool returns a pointer to the given bool value.
101func ptrBool(b bool) *bool {
102	return &b
103}
104
105// makeExecuteLuaHandler returns a tool handler that closes over the SB client.
106func makeExecuteLuaHandler(client *silverbullet.Client, defaultTimeout int) func(context.Context, *mcp.CallToolRequest, ExecuteLuaParams) (*mcp.CallToolResult, any, error) {
107	return func(ctx context.Context, req *mcp.CallToolRequest, params ExecuteLuaParams) (*mcp.CallToolResult, any, error) {
108		timeout := params.Timeout
109		if timeout == 0 {
110			timeout = defaultTimeout
111		}
112
113		result, err := client.ExecuteLua(ctx, params.Script, timeout)
114		if err != nil {
115			return nil, nil, fmt.Errorf("executing lua: %w", err)
116		}
117
118		if result.Error != "" {
119			return &mcp.CallToolResult{
120				Content: []mcp.Content{
121					&mcp.TextContent{Text: fmt.Sprintf("Lua error: %s", result.Error)},
122				},
123				IsError: true,
124			}, nil, nil
125		}
126
127		formatted := formatLuaResult(result.Result)
128
129		return &mcp.CallToolResult{
130			Content: []mcp.Content{
131				&mcp.TextContent{Text: formatted},
132			},
133		}, nil, nil
134	}
135}
136
137// makeScreenshotHandler returns a tool handler that closes over the SB client.
138func makeScreenshotHandler(client *silverbullet.Client) func(context.Context, *mcp.CallToolRequest, ScreenshotParams) (*mcp.CallToolResult, any, error) {
139	return func(ctx context.Context, req *mcp.CallToolRequest, params ScreenshotParams) (*mcp.CallToolResult, any, error) {
140		data, err := client.Screenshot(ctx)
141		if err != nil {
142			return nil, nil, fmt.Errorf("fetching screenshot: %w", err)
143		}
144
145		return &mcp.CallToolResult{
146			Content: []mcp.Content{
147				&mcp.ImageContent{
148					Data:     data,
149					MIMEType: "image/png",
150				},
151			},
152		}, nil, nil
153	}
154}
155
156// makeConsoleLogsHandler returns a tool handler that closes over the SB client.
157func makeConsoleLogsHandler(client *silverbullet.Client) func(context.Context, *mcp.CallToolRequest, ConsoleLogsParams) (*mcp.CallToolResult, any, error) {
158	return func(ctx context.Context, req *mcp.CallToolRequest, params ConsoleLogsParams) (*mcp.CallToolResult, any, error) {
159		limit := params.Limit
160		if limit == 0 {
161			limit = 100
162		}
163
164		result, err := client.ConsoleLogs(ctx, limit, params.Since)
165		if err != nil {
166			return nil, nil, fmt.Errorf("fetching logs: %w", err)
167		}
168
169		logsJSON, err := json.MarshalIndent(result.Logs, "", "  ")
170		if err != nil {
171			return nil, nil, fmt.Errorf("formatting logs: %w", err)
172		}
173
174		return &mcp.CallToolResult{
175			Content: []mcp.Content{
176				&mcp.TextContent{Text: string(logsJSON)},
177			},
178		}, nil, nil
179	}
180}
181
182// formatLuaResult formats a JSON result from Lua execution for display.
183// JSON strings are unescaped to plain text (so markdown content reads cleanly).
184// All other values (numbers, bools, null, objects, arrays) pass through as raw JSON.
185func formatLuaResult(raw json.RawMessage) string {
186	if raw == nil {
187		return "null"
188	}
189
190	var v any
191	if json.Unmarshal(raw, &v) == nil {
192		if s, ok := v.(string); ok {
193			return s
194		}
195	}
196
197	return string(raw)
198}