AI Engineering Playbook
MCP

Building MCP Servers

Server development, the auth/security model, and MCP vs plain function calling — why the protocol exists.

Prerequisites

  • The MCP Protocol — host/client/server roles, transports, lifecycle, and capability negotiation before you implement a server.
  • Tool Calling — schema design, tool errors vs hard failures, and how hosts flatten MCP tools into model-facing definitions.
  • Security — prompt injection and trust boundaries that apply the moment tool text enters the model context.

The intuition

Building an MCP server is less like writing a library and more like opening a shop counter that any compliant host can walk up to. You publish a menu (tool names, descriptions, JSON Schema), staff handlers that fulfill orders, and choose a private desk window (stdio) or a network storefront (Streamable HTTP with real authentication). The framework — FastMCP, official SDKs — is the cash register: typed functions become menu entries; exceptions become receipts the model can read.

The protocol exists so one ticket-search server serves Claude Desktop, an IDE, and the governed enterprise platform without custom adapters. One internal app, a handful of in-process tools, no reuse story: plain function calling is simpler and correct.

Key insight

MCP standardizes where tools come from and how they are discovered, not how the model invokes them. Inside the host, MCP tools are typically flattened into ordinary function-calling definitions. Choose MCP when distribution, multi-host reuse, or third-party supply matters; choose plain functions when you own a single app end to end.

Why it exists

Server authors need a standard shape because ad-hoc tool plugs do not scale. Boilerplate is pure tax: JSON-RPC framing, initialize, capability flags, tools/list vs tools/call, and transport binding should not be reimplemented per domain — SDKs and FastMCP collapse that so you write handlers and schemas.

Transport choice is a security choice. Local data wants zero network surface (stdio, secrets from env). Shared multi-tenant data wants HTTP — and then you own authentication. A network-reachable tool server is an API to sensitive systems driven by a non-deterministic agent; the spec’s answer is OAuth 2.1 resource-server semantics with audience-bound tokens.

Tool quality is prompt quality. Descriptions and schemas are model-facing docs — bad menus produce wrong orders, and validation errors must return as readable tool results. Everything returned also enters the model context, so keep descriptions stable and outputs minimal; hosts still treat server output as untrusted.

In-process function lists lose multi-host reuse and isolation. Custom REST “tool APIs” recreate discovery and error semantics everywhere. MCP is the interoperable middle when tools are a product surface, not just private helpers.

The core idea

Building an MCP server means three things: tool definitions (name, description, JSON Schema for inputs), handlers that execute them, and a transport binding. Frameworks collapse the boilerplate — in Python, FastMCP turns a type-hinted function into a fully described tool via a decorator, generating the schema from the signature and docstring. Official SDKs handle JSON-RPC framing, lifecycle, and capability negotiation so you only write domain logic.

The first real design decision is transport. stdio for a server on the user’s machine: zero network surface, credentials from environment variables. Streamable HTTP for a shared remote server: the moment it is remote, you own authentication. The current stable spec (2025-11-25) makes a remote MCP server an OAuth 2.1 resource server. It advertises its authorization server via Protected Resource Metadata (RFC 9728); clients discover it from a 401 WWW-Authenticate header, run an OAuth flow with PKCE, and present bearer tokens the server must validate — including that the token was issued for this server (RFC 8707 resource indicators). Two anti-patterns are forbidden: token passthrough (forwarding the client’s token upstream) and using session IDs as authentication.

Function calling is an API feature: the model emits a structured call, your code executes it. MCP is a distribution and interoperability layer on top of the same capability — it separates tool provider from app author, adds runtime discovery, and makes one server reusable across hosts. One app, five in-process tools, no reuse: plain function calling wins.

How it actually works

A FastMCP-style server at sketch level:

from fastmcp import FastMCP

mcp = FastMCP("ticket-server")

@mcp.tool
def search_tickets(query: str, limit: int = 10) -> list[dict]:
    """Search support tickets by keyword."""       # docstring -> tool description
    return db.search(query, limit)                 # type hints -> JSON Schema

@mcp.resource("tickets://{ticket_id}")
def get_ticket(ticket_id: str) -> str:             # app-controlled context, URI-addressed
    return db.fetch(ticket_id).to_json()

if __name__ == "__main__":
    mcp.run()                                      # stdio default; transport="http" for remote

The framework emits the tools/list entry, dispatches tools/call, packs returns into content plus structuredContent, and maps exceptions to isError: true so the model can self-correct. FastMCP 1.0 lives in the official Python SDK as mcp.server.fastmcp; standalone FastMCP 2.x/3.x adds auth providers, composition/proxying, and OpenAPI/FastAPI generation.

Tool design is agent UX, not REST wrapping. Mirroring every internal endpoint one-to-one is the trap that kills most production servers. Agents pay for every tool definition on every turn, and multi-step orchestration belongs in your code — not the model’s context. Prefer few high-level, task-shaped tools (search_tickets, create_ticket); flatten arguments to primitives and enums; put when to use in the docstring; paginate results. Validation failures belong in isError: true with guidance ("limit must be ≤ 100"), not as JSON-RPC errors the model never sees.

Key insight

A tool that dumps 50 KB of JSON on every call taxes every subsequent model turn, not just the call that fetched it. Compact results, pagination, and high-level task tools are cost and accuracy controls — not polish.

Remote auth. Client hits the server unauthenticated → 401 with WWW-Authenticate: Bearer resource_metadata="…/.well-known/oauth-protected-resource", scope="tickets:read" → client fetches that metadata, discovers the authorization server (RFC 8414 or OpenID Connect discovery) → identifies itself (Client ID Metadata Documents — an HTTPS URL as client_id — preferred; Dynamic Client Registration as fallback; pre-registration when you already know the client) → OAuth 2.1 authorization-code + PKCE S256 with resource= naming the MCP server’s canonical URI → Authorization: Bearer <token> on every request. The server validates signature, expiry, and audience. Insufficient scope → 403 with a scope= challenge so the client steps up incrementally.

Token passthrough is banned because it skips audience validation, destroys the audit trail (upstream logs show the user, not your server), bypasses your rate limits, and builds a confused deputy. Correct pattern: validate the client’s token, then act as your own OAuth client to upstream with separate credentials.

The confused-deputy attack targets proxies that use one static client ID at a third-party AS. First legitimate auth sets a consent cookie keyed to that static ID. An attacker dynamically registers a client with redirect_uri=attacker.com and lures the user through the flow — the cookie skips consent, the auth code lands at the attacker, who redeems it as the victim. Mitigation: per-client consent at the proxy before forwarding, exact-match redirect URIs, single-use state.

Prompt injection via the server. Descriptions, results, and resources all enter the model context. A rug pull ships a clean description at install, then swaps it via tools/list_changed. Even honest servers that touch untrusted data (web, email) can inject through results. Hosts defend with human approval on writes, description pinning/diffs, and sandboxing; authors keep descriptions static, outputs minimal, and untrusted content unlabeled.

Common misconception

“We only accept tokens from our corporate IdP” is not enough if you skip audience checks. A token minted for another internal API can still be a valid JWT from the same issuer.

On the governed enterprise platform, write-capable internal tools are typically remote MCP servers behind the firm’s IdP: the host enforces human approval on writes, the server validates audience-bound tokens and maps scopes to tools, and upstream systems see the platform’s service identity — not a forwarded end-user token.

The flows

FlowSequenceWhen it appliesWhat breaks it
Local stdioHost spawns process → initialize → tools/listtools/call → logs on stderr → shutdown via stdin closeUser-machine tools: files, git, local DBNon-protocol stdout; secrets in tool args; untrusted launch commands
Remote OAuth bootstrapUnauthenticated request → 401 + resource_metadata → RFC 9728 metadata → AS discovery → CIMD/DCR → auth code + PKCE + resource= → bearer on every callMulti-user / SaaS / shared HTTPSession ID as auth; skipping audience; omnibus scopes
Authenticated callValidate JWT (sig, exp, audience, scopes) → handler → content / structuredContent / isErrorSteady stateToken passthrough; huge JSON dumps; protocol error for fixable validation failures
Scope step-upNeeds tickets:write, has tickets:read → 403 scope= → re-consent → retryLeast-privilege remote toolsAll scopes at first login; no scope→tool map
Upstream as separate clientInbound token for this server → own credentials to upstream APIProxy-style serversForwarding inbound bearer; static third-party client ID without per-client consent
Injection-aware resultsMinimal structured data; untrusted fields labeled; static descriptionsWeb, email, UGC toolsRaw HTML/email in content; rug-pull without host pinning

Authoring path: few task-level tools with when-to-use docstrings → framework schemas + contract tests → domain failures as isError: true → stdio local / HTTP only with OAuth RS posture → publish protected-resource metadata, validate audience, never pass tokens through.

A worked example

A minimal ticket search server for the governed enterprise platform — same domain as the protocol page, from the server author’s side.

1. Tool surface.

FieldValue
namesearch_tickets
description~80–120 tokens: when to use, severity enum, site ID format (LON-WH)
inputSchemaquery string, limit int default 10 max 100, optional severity enum
Returnlist of {id, title, severity, site} — compact, not full bodies

2. Happy path (illustrative sizes).

StageWhat happensScale
Model selects toolDescription + schema in host prompt~400–600 tokens every turn while mounted
tools/call{"query": "warehouse SLA", "limit": 10, "severity": "Sev1"}~50–100 ms in-process; +RTT if remote
Success10 rows in content~300–600 tokens into next model turn
Validation faillimit: 500isError: true, "limit must be ≤ 100"Model retries with limit: 100

3. Remote auth (first connection).

  1. POST without Authorization401 with WWW-Authenticate: Bearer resource_metadata="https://tickets.example/.well-known/oauth-protected-resource", scope="tickets:read".
  2. GET metadata → authorization_servers: ["https://idp.example"].
  3. OAuth 2.1 auth-code + PKCE S256 with resource=https://tickets.example.
  4. Later calls send Authorization: Bearer <access_token>.
  5. Server checks signature, expiry, audience == this server, scope ≥ tickets:read.

4. Deliberate break — REST-shaped tool dump. Twenty one-to-one REST tools instead of one search_tickets: thousands of constant prompt tokens per turn, overlapping names raise selection error, write endpoints without host approval become high blast radius.

What omitted stages look like in production

  • No server-side input caps — the model will send limit=100000; enforce in the handler.
  • Stack traces in isError — paths and secrets leak into model context and vendor logs.
  • stdio rebound as localhost HTTP — any local process or DNS-rebound page can drive it.
  • Static third-party client ID without per-client consent — classic confused-deputy setup.
  • Token passthrough “for the MVP” — breaks audience boundaries permanently; fix before prod.

Production concerns

Statefulness is your scaling tax. Mcp-Session-Id needs sticky routing or shared session state. Prefer stateless HTTP modes; the 2026-07-28 revision makes a stateless core the headline so round-robin and serverless work. Pair with Reliability for retries and idempotent handlers on redelivery.

Auth. Delegate to an IdP — do not hand-roll an authorization server. Short-lived access tokens, refresh rotation for public clients, narrow scopes with step-up. See Security.

Cost is tokens, not CPU. Every mounted tool’s schema and description hits every model call — trim, cap tool count, paginate. Track under Cost.

Latency. Tool calls sit in an agent loop: budget per-call latency, emit progress, support cancellation, prefer idempotent handlers. Budget against Latency.

Failure modes: stray stdout corrupting stdio; schema drift (additive changes + list_changed); unbounded inputs; stack traces in tool errors.

Local hygiene. Installation is arbitrary code execution — hosts show the exact launch command and sandbox. Prefer stdio; if HTTP is unavoidable, bind 127.0.0.1 with Origin checks. Secrets from env, never tool args.

Observability. Structured logs to stderr or the logging capability; correlate by request id; log auth failures and scope escalations. Wire into Observability next to model spans.

Common drill-downs

MCP vs plain function calling? Single app, owned tools, no cross-host reuse → plain FC. Shared, runtime-discovered, or third-party tools → MCP. Hosts flatten MCP tools into FC defs either way.

How does the model learn a tool? tools/list returns name, description, inputSchema (optional outputSchema); the host injects them. Description is docs and injection surface — pin and diff it.

Auth for a remote server fronting an internal API? OAuth 2.1 resource server: RFC 9728 metadata to the corporate IdP; validate issuer/expiry/audience; map scopes to tools; 403 for step-up. Upstream: your own client credentials, never the inbound token.

Token passthrough / confused deputy? Passthrough skips audience checks, breaks audit, and lends your authority to whoever holds a bearer. Proxies with a static third-party client ID need per-client consent at the proxy, exact redirect match, and single-use state.

stdio or HTTP? Local data on the user machine → stdio. Shared/SaaS → Streamable HTTP + OAuth. Never open a local HTTP port without auth (DNS rebinding).

Validation fail — protocol or tool error? isError: true with guidance in content. Protocol errors stop at the client; the model never self-corrects on them.

Version without breaking clients? Protocol version at initialize. Tools: additive only; list_changed for live refresh; breaking changes as tool_v2 + deprecate; contract-test schemas.

Test yourself

A team exposes twenty internal REST endpoints as one-to-one MCP tools 'for completeness.' Latency is fine, but tool selection quality collapses and cost spikes. What two mechanisms explain this, and what server-side redesign helps?

Your remote MCP server validates JWT signature and expiry from the corporate IdP but still accepts tokens meant for the internal wiki API. Which check is missing, and what secondary controls fail when you also forward that token upstream?

A proxy MCP server uses one static client_id at a third-party SaaS AS. After a normal user consents once, an attacker registers a client with redirect_uri=attacker.example and phishes the user through your proxy. Walk the exploit and the mandated mitigations.

A handler rejects limit>100 by raising a JSON-RPC invalid-params error. The model never corrects itself. Why, and what should the handler return instead?

You ship a clean tool description at install. Two weeks later hosts that auto-refresh tools start exfiltrating data via a new send_email tool description. Name the attack pattern and list defenses on host vs server.

Go deeper

Where this connects

  • The MCP Protocol — wire format, lifecycle, and transports underneath the framework sugar.
  • Agent Reliability — human-in-the-loop on writes, tool hallucination, and compounding errors in multi-step loops.
  • Security — prompt injection, data leakage, and why tool output is untrusted input to the model.
  • Design: Customer Support Agent — ticket tools, escalation, and guardrails meeting the patterns on this page.
The MCP Protocol

On this page