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