An MCP server is the component that exposes a system's data and actions to AI applications through the Model Context Protocol. A host application, such as a chat client, an IDE or an agent runtime, runs an MCP client, and that client talks JSON-RPC 2.0 to your server. The server's job is translation and control: it turns protocol requests into calls on your databases and APIs, and it decides, on every call, whether this caller may do this thing.
The architecture of a well-built server changed materially with the 2026-07-28 revision of the specification. Earlier revisions opened a session with an initialize handshake, and servers could push requests to the client over a long-lived stream. The current revision is stateless. Every request carries its own protocol version, client capabilities and credentials, and anything that spans requests travels as an explicit handle. This article builds a server around that model: the request path, a framework-free implementation of the protocol checks, multi round-trip requests, authorization, a worked refund tool, scaling, and failure modes. Field and header names below are taken from the published specification. SDK support for the new revision varies, so check your SDK's release notes before relying on it.
What changed: from sessions to self-contained requests
If you have built an MCP server before, this table is the fastest way to update your mental model. The specification calls handshake-based revisions (2025-11-25 and earlier) legacy and per-request revisions modern.
| Concern | Legacy (2025-11-25 and earlier) | Modern (2026-07-28) |
|---|---|---|
| Version agreement | initialize handshake once per connection | Every request declares it in _meta; server/discover is optional |
| Client capabilities | Sent once at initialize | Sent on every request |
| HTTP sessions | Mcp-Session-Id header | None; servers ignore the header |
| Server needs user input | Server sends its own JSON-RPC request | Returns an input_required result; client retries |
| Change notifications | Standalone GET stream | subscriptions/listen request with a long-lived response stream |
| Resumable streams | Last-Event-ID | Not supported |
The practical consequence is large. A modern server holds no per-connection state, so any replica behind an ordinary load balancer can answer any request. A stdio process is no longer a conversation, so clients may interleave unrelated requests on it. The deprecated 2024-11-05 HTTP+SSE transport, which the older version of this page described as a peer of HTTP, should not be used for new work. See MCP transports for the transport history.
The request path through a server
Follow one tools/call over Streamable HTTP. The client sends a single POST to the server's one MCP endpoint. The Accept header lists both application/json and text/event-stream. The protocol version, method and tool name are mirrored into headers so gateways can route without parsing the body:
POST /mcp HTTP/1.1
Authorization: Bearer eyJhbGciOi...
Content-Type: application/json
Accept: application/json, text/event-stream
MCP-Protocol-Version: 2026-07-28
Mcp-Method: tools/call
Mcp-Name: lookup_order
{"jsonrpc": "2.0", "id": 7, "method": "tools/call",
"params": {"name": "lookup_order", "arguments": {"order_id": "A-1042"},
"_meta": {"io.modelcontextprotocol/protocolVersion": "2026-07-28",
"io.modelcontextprotocol/clientCapabilities": {"elicitation": {}},
"io.modelcontextprotocol/clientInfo": {"name": "acme-desk", "version": "3.1.0"}}}}Inside the server, the request passes four gates in order. The edge checks TLS and the Origin header; the specification requires rejecting an invalid Origin with 403 to block DNS rebinding, and local servers should bind to 127.0.0.1 only. Token validation checks the bearer token and its audience. Protocol checks enforce the required _meta fields, a supported version, and that headers match the body. The dispatcher routes the method to a handler, which applies per-tool policy before touching a backend. The server answers with either one JSON object or an SSE stream scoped to this request that carries progress notifications and then the final response. Closing that stream is the client's cancellation signal.
Implementing the protocol checks
The envelope checks are short enough to write by hand, and writing them once clarifies what an SDK does for you. The error codes are the ones the specification defines: -32602 for missing required _meta, -32022 for an unsupported version, -32020 for a header mismatch, and -32601 (with HTTP 404) for an unknown method.
SUPPORTED = ["2026-07-28"]
META = "io.modelcontextprotocol/"
def handle_post(req):
if not origin_allowed(req.headers.get("Origin")):
return http(403)
principal = validate_token(req.headers.get("Authorization"),
audience=CANONICAL_URI) # e.g. https://mcp.acme.com/mcp
if principal is None:
return http(401, {"WWW-Authenticate":
f'Bearer resource_metadata="{PRM_URL}"'})
msg = json.loads(req.body)
params = msg.get("params", {})
meta = params.get("_meta", {})
version = meta.get(META + "protocolVersion")
caps = meta.get(META + "clientCapabilities")
if version is None or caps is None:
return rpc_error(400, msg, -32602, "Invalid params: missing _meta fields")
if version not in SUPPORTED:
return rpc_error(400, msg, -32022, "Unsupported protocol version",
{"supported": SUPPORTED, "requested": version})
expected = {"mcp-protocol-version": version, "mcp-method": msg["method"]}
if msg["method"] in ("tools/call", "prompts/get"):
expected["mcp-name"] = params.get("name")
elif msg["method"] == "resources/read":
expected["mcp-name"] = params.get("uri")
for h, v in expected.items():
if decode_header(req.headers.get(h)) != v: # handles =?base64?...?=
return rpc_error(400, msg, -32020, f"Header mismatch: {h}")
handler = METHODS.get(msg["method"])
if handler is None:
return rpc_error(404, msg, -32601, "Method not found")
return handler(principal, params, caps)Note what is absent: no session table, no lookup of what this client said earlier. Capabilities come from this request. A handler must never assume a capability the client did not declare. If the operation cannot proceed without it, return -32021 (MissingRequiredClientCapability). If a degraded path exists, as in the refund example below, a tool error that explains the limitation is often the better experience.
Tools, resources and prompts
A server exposes three kinds of feature, and choosing the right one is most of the design work. Tools are functions the model decides to call: each has a name, a description and a JSON Schema inputSchema, and may declare an outputSchema so results can carry typed structuredContent alongside text. Resources are addressable data, identified by URI, that the host or user chooses to bring into context. Prompts are parameterised templates a user picks explicitly, such as a slash command.
Design tools around user intent, not around your REST endpoints. refund_order(order_id, amount_cents, reason) is better than exposing POST /payments/{id}/adjustments and hoping the model composes it correctly. Keep tool lists short, because every definition costs context tokens on every turn. Report business failures as a tool result with isError: true and a message the model can act on, and reserve JSON-RPC errors for protocol faults. The model can recover from "order A-1042 is already refunded"; it cannot recover from an opaque -32603. Treat tool annotations from servers you do not control as untrusted. The specification says so explicitly. MCP tools covers schema design in more detail.
Multi round-trip requests
Sometimes a tool needs something only the client can supply: a user confirmation, a missing field, or an LLM completion. In the modern protocol the server does not send its own request for it. Instead it returns a result with resultType: "input_required", an inputRequests map (each value an elicitation/create, sampling/createMessage or roots/list request), and an opaque requestState string. The client gathers the input and retries the original request with a new JSON-RPC id, the matching inputResponses and the exact requestState. This is called a multi round-trip request (MRTR), and it is allowed only on tools/call, resources/read and prompts/get.
Because requestState passes through the client, the specification requires treating it as attacker-controlled. If it influences authorization or business logic you must integrity-protect it with an HMAC or AEAD, and you should bind it to the principal, a short expiry and a digest of the original request. Signing alone does not make it single-use, so anything that must happen at most once needs a server-side check such as an idempotency key.
Authorization: the server as a resource server
For HTTP transports the server is an OAuth 2.1 resource server. It must publish OAuth 2.0 Protected Resource Metadata (RFC 9728) naming its authorization server. A request without a valid token gets a 401 whose WWW-Authenticate header points at that metadata. Clients must send a resource parameter (RFC 8707) carrying your canonical URI, and you must reject any token whose audience is not you. Never pass the client's token through to a downstream API. That creates a confused deputy. Obtain a separate credential for the backend instead. Stdio servers skip all of this and read credentials from their environment.
Map scopes to tools. When a token lacks the scope for a specific call, return 403 with error="insufficient_scope" and every scope the operation needs in one challenge, so the client can step up authorization once instead of discovering missing scopes one at a time. MCP authorization walks through the full OAuth flow.
Worked example: a refund tool that asks first
Here is a support-desk server whose refund_order tool requires user confirmation above 100 USD. The first call arrives with amount_cents: 25000. The handler checks the refunds:write scope, sees no confirmation, confirms the client declared elicitation, and returns:
def refund_order(principal, params, caps):
args = params["arguments"]
require_scope(principal, "refunds:write") # 403 insufficient_scope if missing
if args["amount_cents"] > 10_000:
answer = params.get("inputResponses", {}).get("confirm")
if answer is None:
if "elicitation" not in caps:
return tool_error("Refunds over 100 USD need user confirmation; "
"this client cannot ask. Use the support console.")
return {"resultType": "input_required",
"inputRequests": {"confirm": {"method": "elicitation/create", "params": {
"mode": "form",
"message": f"Refund {args['amount_cents']/100:.2f} USD on {args['order_id']}?",
"requestedSchema": {"type": "object",
"properties": {"ok": {"type": "boolean"}},
"required": ["ok"]}}}},
"requestState": seal(principal.id, "refund_order", args, ttl_s=300)}
unseal_or_reject(params.get("requestState"), principal.id, "refund_order", args)
if answer.get("action") != "accept" or not answer["content"]["ok"]:
return tool_result("Refund cancelled by the user.")
r = payments.refund(args["order_id"], args["amount_cents"],
idempotency_key=f"{principal.id}:{args['order_id']}:{args['amount_cents']}")
audit.write(principal.id, "refund_order", args, r.id)
return tool_result(f"Refunded {r.amount/100:.2f} USD, ref {r.id}", structured={"refund_id": r.id})The host shows the user the form, the user accepts, and the client retries with id 8, the same arguments, inputResponses: {"confirm": {"action": "accept", "content": {"ok": true}}} and the sealed state. The retry may land on a different replica. That is fine, because everything needed travels in the request. The seal stops a tampered client from swapping the amount after confirmation, and the idempotency key stops a replayed retry from refunding twice.
Scaling and operating the server
Statelessness makes horizontal scaling ordinary: run replicas behind any load balancer, with no sticky routing and no shared session store. Plan for two kinds of long-lived connection. Request-scoped SSE streams last as long as a slow tool call. subscriptions/listen streams deliver list-change and resource-update notifications for as long as the client keeps them open. Send X-Accel-Buffering: no on SSE responses so proxies such as nginx do not buffer events, and emit periodic SSE comment lines as keep-alives on quiet listen streams.
The mirrored headers let a gateway rate-limit or route by Mcp-Method and Mcp-Name without parsing JSON. Only trust them, though, when the protocol version requires header-body validation, and validate again at the server. Propagate W3C trace context through the reserved traceparent key in _meta so a tool call shows up as one trace from host to backend. If you must serve older clients, run a dual-era server that answers initialize with legacy semantics and per-request _meta with modern ones on the same endpoint. MCP gateways covers fronting many servers with one policy point.
Failure modes and trade-offs
The failure modes we see most:
- Hidden session state. Caching something keyed on the connection or process breaks as soon as a second replica or an interleaving client appears. Pass explicit handles instead.
- Unsigned requestState. A client edits the amount or the target between confirmation and retry. Seal it and bind it to the principal and arguments.
- Token passthrough. Forwarding the user's MCP token to a backend lets anything holding that token act as your server. Exchange it for a backend-scoped credential.
- Tool sprawl. Fifty thin endpoint wrappers burn context and confuse tool selection. Fewer, intent-shaped tools perform better.
- Prompt injection through results. Text returned from a backend, such as a ticket body, can carry instructions. Keep side-effecting tools behind confirmation and scopes, and never let returned text widen permissions. MCP security lists the defences.
- Blocking proxies. A buffering proxy turns streamed progress into one late burst and trips client timeouts.
The main trade-off of the stateless model is payload size against operational simplicity. Each request repeats metadata and multi-step interactions cost an extra round trip, but in exchange you get replica-agnostic routing, no session store, and failures that are easy to reason about.
What to do next
- Check which protocol revisions your SDK and your main clients support, and decide whether you need a dual-era server.
- Remove any per-connection state; replace it with explicit, signed handles.
- Implement the envelope checks: Origin, token audience, required
_meta, version, header match. - Publish Protected Resource Metadata, map scopes to tools, and return complete insufficient_scope challenges.
- Redesign tools around user intent, add output schemas, and return business errors with isError.
- Put confirmations on side-effecting tools using MRTR with sealed requestState and idempotency keys.
- Disable proxy buffering for SSE, add traceparent propagation, and load-test with requests spread across replicas.