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.
97 lines
3.0 KiB
Go
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 }
|