Skip to content
ignusmart.com / work

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

5
tools exposed
~530
lines of code
0
SDK dependencies
1
stateless endpoint

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.

mcp.controller.ts — the entire protocol dispatcher
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.”

mcp.tools.ts — one of the five tool descriptors
{
  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.