/** * MCP (Model Context Protocol) transport over the shared registry. * * Builds a low-level `@modelcontextprotocol/sdk` `Server` per authenticated * request, bound to the caller's `AuthContext`, exposing exactly two handlers: * * - `tools/list` returns ONLY the tools whose `requiredScope` is in the * caller's scopes โ€” server-side TOOL-HIDING (design.md ยง2.5). A finance-less * caller never sees finance tools. * - `tools/call` routes through {@link executeTool}, so scope enforcement, * input validation, rate limiting, finance redaction, and audit are * identical to the OpenAPI path. Hiding is convenience; the 403 from * `executeTool` is the real boundary (a forced call to a hidden tool still * fails). * * The low-level `Server` (not the Zod-based `McpServer`) is used deliberately: * our tools carry JSON-Schema input schemas and we need per-caller dynamic tool * lists, which the high-level helper does not support cleanly. */ import { Server } from '@modelcontextprotocol/sdk/server/index.js'; import { CallToolRequestSchema, ListToolsRequestSchema, ErrorCode, McpError, } from '@modelcontextprotocol/sdk/types.js'; import { executeTool, InputValidationError, UnknownToolError } from './dispatch.js'; import { ScopeError } from './auth.js'; import { RateLimitError } from './rate-limit.js'; import { visibleTools } from './visibility.js'; import type { DispatchDeps } from './dispatch.js'; import type { ToolRegistry } from './registry.js'; import type { AuthContext } from './types.js'; /** Server identity advertised in the MCP handshake. */ export interface McpServerInfo { name: string; version: string; } /** * Create a low-level MCP `Server` scoped to one authenticated caller. * * @param registry the server's tool registry * @param ctx the authenticated caller (drives tool-hiding) * @param deps audit + rate-limit dependencies for the dispatch path * @param info MCP server identity for the handshake */ export function createMcpServer( registry: ToolRegistry, ctx: AuthContext, deps: DispatchDeps, info: McpServerInfo, ): Server { const server = new Server(info, { capabilities: { tools: {} } }); // tools/list โ€” scope-filtered (tool-hiding). server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: visibleTools(registry, ctx).map((tool) => ({ name: tool.name, description: tool.description, inputSchema: tool.inputSchema as { type: 'object' }, })), })); // tools/call โ€” single authoritative dispatch path. server.setRequestHandler(CallToolRequestSchema, async (request) => { const { name, arguments: args } = request.params; try { const output = await executeTool(registry, ctx, name, args ?? {}, deps); return { content: [{ type: 'text', text: JSON.stringify(output) }], structuredContent: output as Record, }; } catch (err) { throw toMcpError(err); } }); return server; } /** Map dispatch errors to MCP protocol errors (no secrets / stack traces). */ function toMcpError(err: unknown): McpError { if (err instanceof UnknownToolError) { return new McpError(ErrorCode.MethodNotFound, err.message); } if (err instanceof ScopeError) { return new McpError(ErrorCode.InvalidRequest, err.message); } if (err instanceof InputValidationError) { return new McpError(ErrorCode.InvalidParams, err.message); } if (err instanceof RateLimitError) { return new McpError(ErrorCode.InvalidRequest, err.message); } if (err instanceof McpError) return err; // Generic handler failure: do not leak the underlying message verbatim. return new McpError(ErrorCode.InternalError, 'Tool execution failed.'); }