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 remoteThe 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
| Flow | Sequence | When it applies | What breaks it |
|---|---|---|---|
| Local stdio | Host spawns process → initialize → tools/list → tools/call → logs on stderr → shutdown via stdin close | User-machine tools: files, git, local DB | Non-protocol stdout; secrets in tool args; untrusted launch commands |
| Remote OAuth bootstrap | Unauthenticated request → 401 + resource_metadata → RFC 9728 metadata → AS discovery → CIMD/DCR → auth code + PKCE + resource= → bearer on every call | Multi-user / SaaS / shared HTTP | Session ID as auth; skipping audience; omnibus scopes |
| Authenticated call | Validate JWT (sig, exp, audience, scopes) → handler → content / structuredContent / isError | Steady state | Token passthrough; huge JSON dumps; protocol error for fixable validation failures |
| Scope step-up | Needs tickets:write, has tickets:read → 403 scope= → re-consent → retry | Least-privilege remote tools | All scopes at first login; no scope→tool map |
| Upstream as separate client | Inbound token for this server → own credentials to upstream API | Proxy-style servers | Forwarding inbound bearer; static third-party client ID without per-client consent |
| Injection-aware results | Minimal structured data; untrusted fields labeled; static descriptions | Web, email, UGC tools | Raw 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.
| Field | Value |
|---|---|
| name | search_tickets |
| description | ~80–120 tokens: when to use, severity enum, site ID format (LON-WH) |
| inputSchema | query string, limit int default 10 max 100, optional severity enum |
| Return | list of {id, title, severity, site} — compact, not full bodies |
2. Happy path (illustrative sizes).
| Stage | What happens | Scale |
|---|---|---|
| Model selects tool | Description + 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 |
| Success | 10 rows in content | ~300–600 tokens into next model turn |
| Validation fail | limit: 500 → isError: true, "limit must be ≤ 100" | Model retries with limit: 100 |
3. Remote auth (first connection).
- POST without
Authorization→ 401 withWWW-Authenticate: Bearer resource_metadata="https://tickets.example/.well-known/oauth-protected-resource", scope="tickets:read". - GET metadata →
authorization_servers: ["https://idp.example"]. - OAuth 2.1 auth-code + PKCE S256 with
resource=https://tickets.example. - Later calls send
Authorization: Bearer <access_token>. - 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
- MCP Authorization spec (2025-11-25) — normative OAuth 2.1 flow: discovery, CIMD/DCR/pre-registration, resource indicators, token validation
- MCP Security Best Practices — confused deputy, token passthrough, session hijacking, SSRF, local-server compromise
- Let's fix OAuth in MCP — Aaron Parecki — shaped the resource-server redesign; best AS/RS mental model
- MCP is Not the Problem, It's your Server — Philipp Schmid — outcomes-not-operations tool design; why REST mirrors fail as agent UIs
- MCP Python SDK — official SDK (
mcp.server.fastmcp); stdio + Streamable HTTP - FastMCP documentation — standalone FastMCP: auth providers, composition, OpenAPI generation
- Build an MCP Server in 20 Minutes (with Python) — pixegami — clean end-to-end build into a real client
- Build ANYTHING With an Advanced MCP Server — Tech With Tim — remote server with OAuth and per-user data
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.