MCP server
Source: src/mcp/server.ts
Wraps the @modelcontextprotocol/sdk server in our tool registry pattern. Mounted at POST /mcp via mountMcp(fastify).
Tool registry
Every MCP tool the server exposes is a ToolDef<I, O>:
interface ToolDef<I, O> {
name: string;
description: string;
inputSchema: ZodType<I>;
handler: (input: I, ctx: { tenantId: string; requestId: string }) => Promise<O>;
}The registry is a single array TOOLS in server.ts. Adding a tool means:
- Implement
your-tool.tsundersrc/mcp/tools/exporting aToolDef. - Append it to the
TOOLSarray.
That’s it — no other wiring. Phase 2 / 3 add adapter calls inside the handlers; the registry shape doesn’t change.
Single failure-audit producer
Every tool handler is wrapped:
try { return await tool.handler(parsed, ctx); }
catch (err) {
await writeAuditEvent('mcp.tool_failed', 'failure', { ... });
throw err;
}This is the only place mcp.tool_failed is written. Tool implementations that throw don’t need to handle audit themselves; tool implementations that succeed write mcp.tool_called from inside their own handler (so they can include tool-specific success metadata).
Per-request transport
Each POST /mcp request creates a fresh StreamableHTTPServerTransport and a fresh McpServer instance. The transport is request-scoped (not connection-scoped) — this matches the way @fastify/... handles each HTTP request as its own scope and avoids any cross-request state leaks via SDK internals.
Context propagation: closure, not SDK plumbing
The MCP SDK’s transport.handleRequest(req, res, parsedBody?) has no application-context slot — the third argument is the JSON-RPC payload (used to bypass the consumed raw stream when a Fastify body parser ran first). Earlier drafts of this code passed { tenantId, requestId } there; the SDK then validated that as a JSON-RPC message and returned Parse error: Invalid JSON-RPC message (-32700) for every request. See docs/phase-1-foundation.md §14 #8.
The current shape:
const server = buildMcpServer({ tenantId, requestId: req.id });
await server.connect(transport);
await transport.handleRequest(req.raw, reply.raw, req.body);buildMcpServer(ctx) builds a fresh server per request with the context closed over by every tool handler at registration time. No extra.context reading, no SDK-version-specific plumbing — context is captured at construction. The cost (one tiny object allocation + a registry walk per request) is negligible against the per-request transport allocation we already do.
req.body is what Fastify already parsed from the request — passing it as parsedBody tells the SDK not to try to re-read the consumed raw stream.
SDK version drift
Comment from the source:
The @modelcontextprotocol/sdk surface evolves between minor versions. We import from the documented entry points; if a future version changes the McpServer API, the registry pattern below is the contract to preserve and only the inner adapter needs adjusting.
Our package.json pins to ^1.0.0. If you bump the SDK, run the §G5 acceptance tests before merging.
Wire format
Phase 1 returns each tool result as one JSON-encoded text block:
{ "content": [{ "type": "text", "text": "<JSON.stringify of tool output>" }] }This is a stable contract that tests can assert on. Phase 2 may switch to richer content types (e.g. structured content blocks) as the SDK supports them.
See also
- MCP tools — the actual tool implementations.
- Entry point — bootstrap order around
mountMcp(app).