Case Study — APIDelta
Building a production MCP server in ~530 lines of TypeScript
How I put an AI-agent interface on a multi-tenant SaaS — Streamable HTTP, raw JSON-RPC 2.0, and tools designed for a model to read. No SDK, no sessions, no state.
2026 · TypeScript · NestJS · Prisma · Solo design & build
01 / Context
APIDelta is a SaaS I designed, built, and operated end to end: it crawls third-party API changelogs (Stripe, Twilio, SendGrid, and a 39-entry curated catalog), classifies each change with Claude — breaking change, deprecation, addition, with a severity level — and alerts engineering teams over Slack, email, and HMAC-signed webhooks before a silent upstream change breaks production.
For the V2 release I added a Model Context Protocol server, so the same intelligence became queryable from wherever engineers already work. Instead of opening a dashboard, a developer can ask Claude or Cursor “did Stripe change anything about webhooks recently?”and the assistant answers from their team’s own monitored data, live over MCP.
A note on scope: I’ve built several MCP and multi-agent systems, but most of them ship inside a proprietary Web3 risk-intelligence platform and can’t be shown. APIDelta is entirely my own — which is why this is the one with code on the page.
02 / The transport decision: no SDK
MCP over Streamable HTTP is, at its core, JSON-RPC 2.0 against a single POST endpoint. A tool-only server — no resources, no prompts, no sampling — needs exactly three methods: initialize, tools/list, and tools/call, plus tolerating ping and the initialized notification.
The official SDK is built around long-lived transports and session managers. That machinery earns its complexity for stateful servers, but inside an existing NestJS API it fights the framework: my tools are thin, dependency-injected wrappers over Prisma queries, and every request already carries its own auth. So I hand-rolled the protocol layer: a ~150-line controller that speaks JSON-RPC directly, and a tools class that is plain NestJS.
switch (req.method) {
case 'initialize':
return jsonRpcResult(id, {
protocolVersion: MCP_PROTOCOL_VERSION, // '2024-11-05'
capabilities: { tools: {} },
serverInfo: SERVER_INFO,
});
case 'notifications/initialized':
case 'initialized':
case 'ping':
return jsonRpcResult(id, {});
case 'tools/list':
return jsonRpcResult(id, { tools: this.tools.list() });
case 'tools/call': {
const { name, arguments: args } = req.params ?? {};
if (typeof name !== 'string') {
return jsonRpcError(id, -32602, 'tools/call requires a `name` parameter');
}
// ctx.teamId came from the API key — never from the request body.
const result = await this.tools.call(name, args ?? {}, ctx);
return jsonRpcResult(id, result);
}
default:
return jsonRpcError(id, -32601, `Method not found: ${req.method}`);
}The trade-offs are explicit, not accidental: no SSE streaming, no server notifications, protocol version pinned to 2024-11-05. If APIDelta ever needed subscriptions pushed through MCP, this layer would be revisited. Engineering a spec is deciding which half of it your product actually needs — and writing down which half you left out.
03 / Architecture
04 / Multi-tenancy when the client is a model
The interesting security property of an MCP server is that the caller is an LLM. A prompt-injected client can be convinced to ask for anything— other teams’ data, unbounded result sets, internal errors. The design assumes this from the first line:
- →Team identity derives from the API key, server-side, on every request. No tool argument, header, or JSON-RPC parameter can widen the scope — the blast radius of any hostile prompt is capped at the caller's own team.
- →Every Prisma query carries the teamId filter at the query-builder level, not in post-processing. There is no code path that fetches cross-tenant rows.
- →Result limits are clamped to 1–100 regardless of what the model asks for, and long descriptions are truncated — a misbehaving agent can't pull the whole database through a tool call.
- →Tool failures return an isError content block with a one-line message. Stack traces stay in the server logs.
None of this is exotic — it’s standard multi-tenant hygiene applied to a new kind of untrusted client. That framing (“the model is an untrusted caller”) comes directly from my security-audit background, and I think it’s the single most transferable habit for anyone shipping MCP servers today.
05 / Designing tools for a model, not a UI
A tool description is a prompt. The model chooses tools by reading them, so each one is written for the model’s decision, down to including the kind of question it should route here: “Useful for ‘did Stripe change anything about X recently?’ queries.”
{
name: 'recent_changes',
description:
"Recent classified change entries detected by APIDelta's crawler " +
'for this team. Filter by severity and limit. Returns the most ' +
'recent matches first.',
inputSchema: {
type: 'object',
properties: {
severity: {
type: 'string',
enum: ['CRITICAL', 'HIGH', 'MEDIUM', 'LOW'],
description:
'Minimum severity threshold (inclusive). ' +
'E.g. "HIGH" returns CRITICAL + HIGH.',
},
sourceName: {
type: 'string',
description:
'Optional case-insensitive substring match against the source name.',
},
limit: {
type: 'number',
description: 'Max rows to return (default 20, max 100).',
},
},
},
}Three deliberate choices in that schema and its siblings:
- →Enums over free text. severity is a closed set, so the model cannot invent values the query layer would silently mismatch.
- →Empty states that steer. An empty result returns 'No sources configured. Add one at …/dashboard/sources or browse the catalog at …/catalog' — the model can relay a next action instead of a dead end.
- →Compact markdown output. Results render as tight markdown lists with 240-character description caps. The client's context window is the scarcest resource in the whole system; every tool respects it.
06 / What I’d do differently
Cursor-based pagination instead of clamped limits — clamping is safe but lossy; a cursor lets an agent walk a long history without a 100-row ceiling.
Tool-selection evals.Once descriptions are the API contract, they deserve tests: a small harness that asks realistic questions through a real client and scores whether the model picked the right tool with the right arguments. I’d build this before adding a sixth tool.
Streaming, only when earned. Server-push (“tell me when Stripe ships a breaking change”) stays on the existing webhook/Slack path for now. If subscriptions ever move into MCP proper, that is the moment the hand-rolled transport gets replaced — not before.
I build MCP servers, multi-agent systems, and the full-stack products around them — and I use those agent systems to ship faster than teams twice the size.