Files
gronod eab16991ac Implement the runnable MCP server: 31 tools, auth, projections (Phase 07)
Lands the executable half of the Phase 01-06 contracts: stdio MCP
wiring with bundle policy enforced at call time, JWT/api_key upstream
auth with single-flight renewal and 401 retry, strict argument
decoding, allowlisted result projections with enum label twins, TV
season expansion, settings read-modify-write under a revision lock,
and a sanitized ToolError envelope that never forwards raw upstream
bodies.
2026-09-18 19:14:10 +01:00

97 lines
3.0 KiB
Go

// Package mcpserver wires the tool registry onto the MCP protocol
// transport: tools/list advertises the bundle-filtered catalogue and
// tools/call dispatches to handler closures.
package mcpserver
import (
"context"
"encoding/json"
"fmt"
"github.com/modelcontextprotocol/go-sdk/mcp"
"ombi-mcp/internal/tools"
)
// Version is stamped into the server implementation identity.
const Version = "0.1.0"
// New builds an MCP server advertising every tool whose bundle is
// enabled. Bundle policy is enforced again at call time so a stale
// tools/list snapshot cannot reach a disabled handler.
func New(env *tools.Env, bundles map[string]bool) (*mcp.Server, error) {
srv := mcp.NewServer(&mcp.Implementation{
Name: "ombi-mcp",
Version: Version,
}, &mcp.ServerOptions{
// The tool list is static for a process lifetime, so no
// listChanged notification is ever sent; declaring the bare
// capability also drops the SDK's default logging capability,
// which this server does not implement.
Capabilities: &mcp.ServerCapabilities{Tools: &mcp.ToolCapabilities{}},
})
registered := map[string]bool{}
for _, def := range tools.Registry() {
if !def.Enabled(bundles) {
continue
}
if registered[def.Name] {
return nil, fmt.Errorf("duplicate tool registration %q", def.Name)
}
registered[def.Name] = true
inSchema, err := def.InputSchema()
if err != nil {
return nil, fmt.Errorf("tool %q: %w", def.Name, err)
}
outSchema, err := def.OutputSchema()
if err != nil {
return nil, fmt.Errorf("tool %q: %w", def.Name, err)
}
t := &mcp.Tool{
Name: def.Name,
Description: def.Description,
InputSchema: inSchema,
OutputSchema: outSchema,
Annotations: &mcp.ToolAnnotations{
ReadOnlyHint: def.ReadOnly,
DestructiveHint: boolPtr(def.Destructive),
IdempotentHint: def.Idempotent,
OpenWorldHint: boolPtr(def.OpenWorld),
},
}
srv.AddTool(t, callHandler(env, bundles, def))
}
return srv, nil
}
// callHandler adapts a tools.Handler to the SDK's ToolHandler. The
// ToolResult envelope maps to structuredContent plus a serialized
// text copy; isError mirrors ok.
func callHandler(env *tools.Env, bundles map[string]bool, def tools.ToolDef) mcp.ToolHandler {
return func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
if !def.Enabled(bundles) {
return nil, fmt.Errorf("tool %q is not enabled", def.Name)
}
res := def.Handler(ctx, env, req.Params.Arguments)
if res == nil {
return nil, fmt.Errorf("tool %q returned no result", def.Name)
}
// Serialize the same bounded object into a text block for
// clients that do not consume structured content.
text, err := json.Marshal(res)
if err != nil {
return nil, fmt.Errorf("marshal result: %w", err)
}
return &mcp.CallToolResult{
Content: []mcp.Content{&mcp.TextContent{Text: string(text)}},
StructuredContent: res,
IsError: !res.OK,
}, nil
}
}
func boolPtr(b bool) *bool { return &b }