Skip to Content
Deneva MCPComponentsMcp Server

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:

  1. Implement your-tool.ts under src/mcp/tools/ exporting a ToolDef.
  2. Append it to the TOOLS array.

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).