Engineering Guide

Building a production MCP server: a working implementation guide

16 min read · September 2026

Most enterprise teams meet the Model Context Protocol the same way: someone builds a demo server in an afternoon, it works beautifully against a toy dataset, and then it stalls for four months in security review. The protocol is not the hard part. The hard part is that an MCP server is a new, model-driven access path into your systems of record, and it has to satisfy the same bar as any other production integration — authentication, authorization, auditability, rate limiting, and a blast radius someone is willing to sign off on.

This is the guide we wish we had before our first enterprise MCP deployment. It is opinionated, it assumes TypeScript on the server side and Claude on the model side, and it is written from engagements where the server had to pass a real security review before it was allowed near production data.

Start with the smallest possible tool surface. The most common design error is exposing your API. Teams wrap thirty existing REST endpoints as thirty MCP tools and hand the result to a model. The model then has to reason about pagination, partial failures, and cross-endpoint consistency — things your frontend team spent two years getting right. Instead, design tools around the tasks the model actually needs to accomplish. Four well-shaped tools beat thirty thin proxies, every time.

A good tool has a single clear purpose, an input schema tight enough that most invalid calls are rejected before they reach your code, a description written for a model rather than a developer, and an output that is small, structured, and self-describing. If a tool can return ten thousand rows, it is the wrong tool. Add a filter, add a limit, and make the limit part of the contract.

import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { z } from "zod";

const server = new McpServer({ name: "claims-tools", version: "1.0.0" });

server.tool(
  "search_claims",
  "Find claims for a policy. Returns at most 25 summaries, newest first. " +
    "Use get_claim for the full record of a single claim.",
  {
    policyNumber: z.string().regex(/^[A-Z]{2}-\d{8}$/),
    status: z.enum(["open", "closed", "reopened"]).optional(),
    limit: z.number().int().min(1).max(25).default(10),
  },
  async ({ policyNumber, status, limit }, { authInfo }) => {
    const rows = await claims.search({
      policyNumber,
      status,
      limit,
      actor: authInfo.subject,   // never a service account
      scopes: authInfo.scopes,   // enforced in the data layer
    });
    return {
      content: [{ type: "text", text: JSON.stringify(rows) }],
      structuredContent: { claims: rows },
    };
  },
);

Notice what is happening in that handler. The identity being used for the query is the human on the other end of the conversation, not the MCP server's own credential. This is the single most important decision in the whole design, and it is the one most demos get wrong. If your MCP server holds a broad service credential and queries on behalf of anyone who can reach it, you have built a confused deputy: the model becomes a way to read data the user was never entitled to see.

The pattern that survives security review is token pass-through with downstream enforcement. The client authenticates the user, obtains a token scoped to that user, and presents it to the MCP server. The server validates the token — signature, issuer, audience, expiry — and then passes the resulting identity into the data layer, where your existing row-level rules apply exactly as they do for the web application. The MCP server enforces nothing on its own that the data layer does not also enforce. It is a transport, not a policy engine.

server.use(async (req, next) => {
  const raw = req.headers["authorization"]?.replace(/^Bearer /, "");
  if (!raw) throw new McpError(-32001, "Unauthenticated");

  const claims = await verifyJwt(raw, {
    issuer: process.env["OIDC_ISSUER"]!,
    audience: "mcp://claims-tools",
  });

  req.authInfo = {
    subject: claims.sub,
    scopes: claims.scope?.split(" ") ?? [],
    expiresAt: claims.exp,
  };
  return next();
});

Separate reads from writes and treat them as different products. Read tools can be permissive within the user's entitlements: worst case, the model retrieves something unhelpful and wastes tokens. Write tools change the world, and a model that has misunderstood the conversation will call them with complete confidence. Every write tool in our production servers carries three properties: it is idempotent under a caller-supplied key, it is reversible or soft, and it is either low-consequence or gated behind explicit human confirmation in the client.

Idempotency is the one teams skip and regret. Models retry. Clients retry. Networks retry. Without an idempotency key derived from the business intent — not from a random UUID the model invents on each attempt — a retried tool call becomes a duplicate payment, a duplicate ticket, or a duplicate email to a customer. We require the key in the schema and reject calls without one.

server.tool(
  "add_claim_note",
  "Append an internal note to a claim. Idempotent on idempotencyKey.",
  {
    claimId: z.string().uuid(),
    note: z.string().min(1).max(2000),
    idempotencyKey: z.string().uuid(),
  },
  async ({ claimId, note, idempotencyKey }, { authInfo }) => {
    const result = await notes.appendOnce({
      claimId,
      note,
      key: idempotencyKey,
      actor: authInfo.subject,
    });
    return {
      content: [
        {
          type: "text",
          text: result.created
            ? `Note added to ${claimId}.`
            : `Note already present for this key; no change made.`,
        },
      ],
    };
  },
);

Write error messages for the model, not for a log file. A tool that fails with "500 Internal Server Error" teaches the model nothing, so it retries the identical call until the conversation degrades. A tool that fails with "Policy number not found. Policy numbers look like AB-12345678; you may have used the claim number instead." gets a corrected call on the next turn. Treat your error strings as prompt engineering, because that is exactly what they are.

Distinguish the three failure classes explicitly. Input errors are the model's fault and should describe the fix. Permission errors are the user's situation and should say so plainly without leaking what exists behind the wall. System errors are yours and should tell the model to stop retrying and surface the problem to the human. Collapsing all three into one generic failure is how you get retry storms against a database that is already struggling.

Budget for context, not just for correctness. A tool returning a 40 KB JSON blob will work in testing and quietly ruin the assistant in production, because five such calls exhaust the working context and the model starts forgetting the user's actual question. We enforce a hard byte cap per tool response — typically 8 KB — implemented in the serialization layer rather than left to the handler's discretion. When the result exceeds the cap, we truncate deterministically and tell the model what was dropped and how to fetch the rest.

Log every call as if a regulator will read it, because in financial services and insurance one eventually will. Our standard record carries: timestamp, user subject, tool name, tool version, full input arguments after PII redaction, output size, latency, outcome, and the model and prompt version that produced the call. That record is what lets you answer the question that comes up in every model risk review — "why did the system do this?" — six months after the fact.

Test the server the way you test any other API, plus one more layer. Unit tests on each handler cover the input schema boundaries, the permission denial path, and the idempotency replay path. Contract tests assert that tool descriptions and schemas have not drifted, because a description change is a behavior change even when no code changed. Then add adversarial tests: a fixed set of transcripts where a user, or content retrieved by a tool, attempts to induce the model into calling a write tool it should not. Run them on every prompt or schema change and gate the release on the result.

That last category catches the failure mode unique to this architecture. If a read tool returns text from a document, and that document contains instructions, the model may follow them. The mitigations are unglamorous: never grant a write tool broader scope than the human's own entitlements, require confirmation for anything irreversible, and treat all tool output as untrusted data in your system prompt. No amount of prompt wording substitutes for the scope boundary.

Operationally, run the MCP server like any other internal service. It sits inside the VPC. It has an SLO, a dashboard, and an on-call owner. It is rate-limited per user, not per client. It has a kill switch that disables individual tools without a deploy, because the first time a tool misbehaves in production you will want to turn off exactly one thing at two in the morning. And its version is pinned in the assistant configuration, so a server deploy cannot silently change model behavior.

A realistic timeline, from the engagements we have run: two weeks to a working server against real data in a development account, two to four weeks for the security and identity work that makes it deployable, and two more weeks of evaluation and adversarial testing before it is exposed to users. Call it six to eight weeks for the first server. The second one in the same organization takes two, because the identity plumbing, the audit schema, and the test harness are already there.

The advice that generalizes beyond MCP: the protocol gave the industry a common way to connect models to systems, and it removed a genuine integration tax. It did not remove the need to decide what a model is allowed to do, on whose authority, with what evidence trail. That decision was always the actual work, and it still is.

— Related services

Builders Newsletter

Get our field notes in your inbox.

One thoughtful read a month on what's shipping in commerce, AI, cloud, and security — from the engineers building it.

No spam. Unsubscribe anytime.