Capstone 13: Stateless MCP Server with Registry and Governance
Production MCP is not one server process. It is a chain of contracts: publishable metadata, live discovery, a stateless request envelope, authorization, policy, audit, and deployment evidence.
Lesson 13 of 85Capstone~25 hoursPython and TypeScript reference models; any production language
Before this: Phase 11, Phase 13, Phase 14, Phase 17, and Phase 18
On this page
Learning Objectives
- Implement the stateless MCP request and result envelope.
- Keep Registry metadata separate from live protocol discovery.
- Build deterministic, cache-aware tool discovery.
- Enforce issuer, audience, scope, and approval policy for every tool call.
- Deploy Streamable HTTP without session affinity.
- Prove behavior at the wire, authorization, policy, registry, and audit boundaries.
Required MCP Prerequisite Path
Complete the four linked Phase 13 lessons in order before treating this capstone as production-ready:
- Lesson 28 defines the tool, schema, content, pagination, completion, routing, and error contracts this server must expose.
- Lesson 29 defines cancellation races, deadlines, idempotency, backpressure, retry, and reconnect behavior.
- Lesson 30 defines namespace, provenance, admission pin, Registry status, drift, ledger, and rollback evidence.
- Lesson 31 defines golden and negative transcripts, strict version eras, SDK differential checks, proxy proof, redaction, health, and release gating.
The capstone integrates those artifacts. It does not replace them with one happy-path SDK test.
The Problem
An internal platform needs read-only data tools and a small set of state-changing tools. Developers must be able to discover the server, understand how to connect, inspect its live capabilities, and call only the operations they are authorized to use.
The difficult part is not registering a function. The difficult part is keeping six different truths aligned:
server.jsonsays where the server can be installed or reached.server/discoversays what the live process supports now.- Every request says which protocol revision and client capabilities it uses.
- Authorization binds a caller to the correct issuer, resource, and scopes.
- Policy decides whether this specific action may run.
- Audit evidence records what crossed the boundary without leaking secrets or sensitive payloads.
If any one of these drifts, the platform may list a server that cannot be reached, route an incompatible client, accept a token minted for another resource, or expose a destructive action without the expected review.
The Two Discovery Layers
The Registry and the live MCP server answer different questions.
| Layer | Contract | Question it answers |
|---|---|---|
| Publication | server.json and Registry API |
What is this server, where is its package or remote endpoint, and how is it configured? |
| Runtime | server/discover |
Which protocol versions, capabilities, extensions, and server identity does this process support? |
The official Registry uses a versioned server.json schema. A remote entry can name a Streamable HTTP URL:
{
"$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json",
"name": "com.example/internal-readonly",
"title": "Internal Read-Only Tools",
"description": "Read-only incident and data lookup tools.",
"version": "1.0.0",
"remotes": [
{
"type": "streamable-http",
"url": "https://mcp.internal.example.com/readonly"
}
]
}
The Registry schema version and the MCP protocol revision are independent. Do not rewrite one date to match the other. Validate each document against its own contract.
Schema validity does not prove namespace ownership. A publisher verified for example.com uses the reverse-DNS namespace com.example/* or one of its child namespaces. The Registry authentication flow proves that ownership. Keeping the domain labels in their ordinary order names a different namespace.
The stdlib model's validate_registry_document function is intentionally a partial remote-profile validator. It checks the official required name, description, and version fields; the optional title; the published name and length constraints; concrete-version shape; and each streamable-http or sse remote's HTTP(S) URL shape. It additionally requires a non-empty remotes list because this capstone always live-probes a remote. validate_publisher_namespace separately checks the name against the verified publisher domain, while validate_runtime_alignment compares the publication name and version with live serverInfo. The official schema also supports package-only records and more remote fields. Before publication, validate the entire document with the pinned official JSON Schema or mcp-publisher; do not present this dependency-free subset as full schema validation.
The server must implement server/discover; a client may call it before other methods. This capstone client does so after resolving the endpoint, and receives the current protocol revision and live capabilities:
{
"resultType": "complete",
"supportedVersions": ["2026-07-28"],
"capabilities": {
"tools": {
"listChanged": false
}
},
"_meta": {
"io.modelcontextprotocol/serverInfo": {
"name": "com.example/internal-readonly",
"version": "1.0.0"
}
},
"ttlMs": 3600000,
"cacheScope": "public"
}
A private catalog may index extra ownership, review, or lifecycle data, but it must not invent that data as MCP wire fields or root server.json fields. Store organizational policy beside the published record. When public custom metadata is necessary, use the Registry's _meta.io.modelcontextprotocol.registry/publisher-provided extension and stay within its 4 KB limit.
Stateless MCP Core
MCP revision 2026-07-28 removes protocol sessions and the initialize / notifications/initialized handshake. It also removes Mcp-Session-Id.
Every request carries protocol context in params._meta:
{
"io.modelcontextprotocol/protocolVersion": "2026-07-28",
"io.modelcontextprotocol/clientCapabilities": {},
"io.modelcontextprotocol/clientInfo": {
"name": "internal-platform-client",
"version": "1.0.0"
}
}
The version and capabilities are request facts, not connection facts. A load balancer may send consecutive requests to different healthy replicas because either replica can validate the request from the message itself.
Ordinary results include resultType: "complete". Servers should place their identity in _meta.io.modelcontextprotocol/serverInfo on each result. A missing or non-string protocol version is invalid params -32602. Error -32022 is only for a supplied string that is unsupported, with exactly {"supported": ["2026-07-28"], "requested": "..."} as its data.
Cacheable discovery
tools/list must be deterministic for the same effective tool set. The result includes:
ttlMs, a freshness hint for the client;cacheScope, eitherpublicorprivate;- a stable tool order so identical lists can reuse prompt caches;
resultType: "complete"and server identity metadata.
Per-user authorization should normally produce cacheScope: "private". Do not put user-specific tool visibility behind a shared public cache.
Streamable HTTP
A network server exposes one MCP endpoint that accepts POST. Each JSON-RPC request or notification gets its own POST.
For a request, the server returns either one JSON object or an SSE stream scoped to that request. A long-lived subscriptions/listen request carries opted-in change notifications. There is no standalone GET stream, session DELETE, session header, or Last-Event-ID replay in the current transport.
Each request includes:
MCP-Protocol-Version, matching the body metadata;Mcp-Method, matching the JSON-RPC method;Mcp-Namefortools/call,resources/read, andprompts/get;Accept: application/json, text/event-stream.
Reject mismatched mirrored headers with the specified -32020 error. Validate Origin, bind local development servers to loopback, authenticate remote clients, and treat a closed request-scoped SSE response as cancellation.
cf-mcp-gate
Authorization and Policy
Transport metadata is not authorization. Validate authorization on every call.
For remote servers:
- Discover protected-resource metadata.
- Select the authorization server for that resource.
- Prefer Client ID Metadata Documents for client registration. Treat Dynamic Client Registration as compatibility support.
- Send the resource indicator during authorization.
- Validate a returned
issvalue against the authorization server recorded for the flow. - Key client credentials by issuer. Never reuse registration data across issuers.
- Validate token issuer, audience or resource, expiry, and scopes at the MCP server.
- Apply a second policy decision to the concrete tool and arguments.
Tool annotations such as readOnlyHint and destructiveHint help clients present risk. They are not trusted authorization controls.
Approval is a record, not a magic scope
A state-changing call needs an approval record bound to the actor, tool, normalized arguments or digest, target environment, expiry, and one-time or repeat-use policy. A chat message alone is not proof of approval.
The Python model hashes canonical JSON with sorted keys, then binds that digest with the token subject, tool name, server URL, and expiry. Replaying the record after changing even one argument fails before the handler runs. Approval is separate evidence, not a scope added to the access token.
Keep high-risk tools on a separately reviewable surface when that materially reduces blast radius. Separation is useful only if credentials, policy, deployment identity, and audit controls are also separate.
Build It
1. Model publication metadata
Create and schema-validate server.json. Include a stable name inside the namespace authenticated for the publisher, plus version, description, official repository or packages metadata when applicable, and a remote or stdio transport. Keep secrets as declared environment-variable inputs, never literal values.
2. Implement live discovery
Implement server/discover before any feature RPC. Advertise supported protocol versions, capabilities, extensions, and server identity. Add a version rejection case using -32022.
3. Implement the stateless envelope
Require protocol version and client capabilities in every request. Return resultType and server identity in every result. Remove initialization state, connection-scoped capability caches, and session identifiers.
4. Build the tool surface
Start with two read-only tools and one state-changing tool. Give each a bounded JSON Schema, precise description, deterministic result shape, and honest annotations. Add output schemas when clients rely on structured results.
5. Add cache-aware listing
Return tools in stable order with ttlMs and cacheScope. Exercise cache expiry and list-change notification behavior separately.
6. Add authorization and policy
Validate issuer, audience, expiry, and scope. Run a policy decision for every tool call. Bind approvals to exact high-risk actions. Deny missing or stale approvals before executing a handler.
7. Separate registry and runtime validation
Validate the static server.json record, then probe the remote endpoint with server/discover. Report drift when the published remote, identity, version, or required capabilities disagree with the live process.
8. Add audit evidence
Record actor, issuer, resource, tool, policy decision, request identifier, trace context, latency, and outcome. Redact or digest sensitive arguments and results before persistence. Keep the audit sink outside model-visible context.
9. Exercise horizontal scaling
Place two stateless replicas behind a load balancer. Send at least 100 concurrent requests. Demonstrate that correctness does not depend on affinity. If a tool needs cross-call state, mint an explicit opaque handle and store it in a shared durable system.
10. Cross the real wire
Run conformance checks against the actual server binary. Capture request headers and JSON bodies, not only SDK objects. Exercise wrong version, header mismatch, missing scope, wrong audience, malformed arguments, handler failure, cancellation, and cache expiry.
Required Evidence Pack
A submission is incomplete until it contains all five evidence classes:
| Evidence | Minimum proof | Source lesson |
|---|---|---|
| Wire | Redacted raw headers and JSON-RPC bodies for golden and negative cases, including metadata type failure, header mismatch, unsupported version, missing or unknown resultType, notification no-response, and response ID matching |
Lesson 31 |
| Proxy | The same stable case run directly and through the deployed intermediary, with ingress, origin, and egress status and body digests; prove protocol errors are not collapsed into generic 500 responses and streaming is not buffered | Lessons 29 and 31 |
| Admission | Verified publisher namespace, immutable Registry record digest, artifact or remote provenance, live server/discover identity and capability observation, descriptor pin, current Registry status, and admission-ledger event |
Lesson 30 |
| Retry | A cancellation-versus-completion race, explicit timeout, safe read retry, mutation idempotency key, reconnect refetch, and proof that request cancellation cannot silently become durable task cancellation | Lesson 29 |
| Rollback | Exact previous version, admission and artifact digests, descriptor pin, active Registry status, current health window, route restoration result, and redacted decision evidence | Lessons 30 and 31 |
Store a digest of the redacted pack with the release. If any class is missing, hold the release. Do not infer proxy behavior from an in-process dispatcher, admission from Registry presence, retry safety from a new JSON-RPC id, or rollback readiness from “the previous deployment.”
Local Reference Models
The Python model demonstrates registry metadata, reverse-DNS publisher namespace validation, publication-to-runtime identity checks, live discovery, deterministic tool listing, per-request metadata, trusted-issuer, audience, expiry, and scope checks, action-bound approvals, a documented partial Registry validator, policy, and audit without opening a network socket:
cd phases/19-capstone-projects/13-mcp-server-with-registry
python3 code/main.py
python3 -m unittest discover -s code/tests -v
The TypeScript project exposes the stateless JSON-RPC shape over stdio without an MCP SDK. Its tools/call path enforces the same bounded input schemas advertised by tools/list; invalid arguments for a known tool return a complete result with isError: true without invoking the executor:
cd phases/19-capstone-projects/13-mcp-server-with-registry/code/ts
npm install
npm run typecheck
npm test
npm run demo
These models prove local contract logic. They do not prove HTTP headers, OAuth exchange, Registry publication, OPA integration, load balancing, or collector receipt.
Wire Example
POST /mcp HTTP/1.1
Host: mcp.internal.example.com
Content-Type: application/json
Accept: application/json, text/event-stream
MCP-Protocol-Version: 2026-07-28
Mcp-Method: tools/call
Mcp-Name: postgres.readonly
Authorization: Bearer REDACTED
{
"jsonrpc": "2.0",
"id": 42,
"method": "tools/call",
"params": {
"name": "postgres.readonly",
"arguments": {"sql": "SELECT 1"},
"_meta": {
"io.modelcontextprotocol/protocolVersion": "2026-07-28",
"io.modelcontextprotocol/clientCapabilities": {},
"io.modelcontextprotocol/clientInfo": {
"name": "internal-platform-client",
"version": "1.0.0"
}
}
}
}
Ship It
Ship a repository containing:
- a schema-valid
server.json; - read-only and state-changing server surfaces;
server/discover, deterministictools/list, and policy-gatedtools/call;- a Streamable HTTP deployment with two interchangeable replicas;
- authorization and approval integration;
- a Registry publisher or private Registry API adapter;
- policy definitions and action-bound approval records;
- redacted audit output and trace propagation;
- wire and proxy failure evidence;
- admission, retry, health, and rollback evidence with a digest of the redacted pack.
| Weight | Criterion | Evidence |
|---|---|---|
| 25 | Protocol correctness | Stateless request metadata, discovery, results, headers, and negative cases |
| 20 | Authorization | Issuer, audience, expiry, scope, and action-bound approval cases |
| 15 | Registry integrity | Valid server.json, publication record, live discovery probe, and drift report |
| 15 | Policy and safety | Allow, deny, malformed, stale approval, and sensitive-data cases |
| 15 | Scale and reliability | Two replicas, no affinity dependency, cancellation, timeout, and recovery |
| 10 | Auditability | Redacted receiver-side audit and trace evidence |
Exercises
- Change the published remote URL while leaving the live server unchanged. Make the registry validation report the exact drift.
- Send
tools/listtwice with identical inputs and prove byte-stable tool order. Then expirettlMsand refresh. - Send a valid body with a different
MCP-Protocol-Versionheader. Return-32020and do not invoke policy or the tool. - Mint a token for the read-only server and present it to the state-changing server. Prove audience validation fails before the handler runs.
- Bind an approval to one normalized argument digest. Change one field and prove the approval cannot be replayed.
- Route consecutive calls to alternating replicas. Replace hidden process memory with an explicit shared handle wherever the workflow needs persistence.
- Break a request-scoped SSE connection and retry with a new JSON-RPC request ID. Verify that no
Last-Event-IDrecovery path is used.
Key Terms
| Term | What people say | What it actually means |
|---|---|---|
| Stateless MCP | "No state anywhere" | No protocol session; cross-call state is explicit and server-managed |
server.json |
"The tool manifest" | Registry metadata for naming, packaging, configuration, and transports |
server/discover |
"The handshake" | A normal mandatory RPC for live versions and capabilities, not a session initializer |
| Cache scope | "Can I cache it?" | Whether a cacheable result is safe for shared or private reuse |
| Policy decision | "The token allows it" | A separate decision over actor, tool, target, arguments, and context |
| Approval record | "A human clicked yes" | Evidence bound to one actor and consequential action under an expiry policy |
| Explicit handle | "A session ID" | Ordinary application data for named server-managed state, not protocol connection state |
Further Reading
Check yourself
Quick quiz
Why does this capstone use both server.json and server/discover?
- server.json stores sessions, while server/discover deletes them
- The Registry requires both files to contain identical version dates
- They are two names for the same tool manifest
- server.json publishes install and endpoint metadata, while server/discover reports live protocol capabilities
Show the answer
D. server.json publishes install and endpoint metadata, while server/discover reports live protocol capabilities The Registry record helps clients locate and configure a server. The live RPC reports what the running process supports. Their schemas and version domains are independent, but the published identity must use the authenticated namespace and match live serverInfo.
What replaces the initialize handshake in MCP revision 2026-07-28?
- Mcp-Session-Id plus notifications/initialized
- Every request carries protocol version and client capabilities, and clients can call mandatory server/discover
- A GET request that opens a permanent server stream
- A session cookie sent once per TCP connection
Show the answer
B. Every request carries protocol version and client capabilities, and clients can call mandatory server/discover The protocol is stateless. Version and capabilities are request metadata, while server/discover provides live version and capability discovery without creating a session.
Which tools/list behavior supports safe and efficient discovery?
- A response cached forever without a scope
- A random tool order on each replica
- A connection-local list created during initialize
- Deterministic order plus ttlMs and a correct public or private cacheScope
Show the answer
D. Deterministic order plus ttlMs and a correct public or private cacheScope Stable ordering improves reproducibility and prompt caching. ttlMs communicates freshness, and cacheScope prevents user-specific results from leaking through shared caches.
Which Streamable HTTP design matches the current stateless transport?
- All clients require sticky load-balancer routing
- POST creates a session, GET resumes it, and DELETE closes it
- A standalone GET stream uses Last-Event-ID replay
- Each JSON-RPC message uses its own POST, with JSON or request-scoped SSE for a request
Show the answer
D. Each JSON-RPC message uses its own POST, with JSON or request-scoped SSE for a request Current Streamable HTTP is POST-only for MCP messages and has no protocol session, standalone GET stream, session DELETE, or Last-Event-ID resumability.
What must a production server validate before a policy-approved tool handler runs?
- Only that server.json lists the remote URL
- Only the tool's destructiveHint annotation
- Only that a human posted an approval message
- Protocol metadata plus token issuer, audience or resource, expiry, scope, and any exact-action approval
Show the answer
D. Protocol metadata plus token issuer, audience or resource, expiry, scope, and any exact-action approval Transport metadata, token validation, and policy are separate gates. Annotations and publication metadata do not grant authority.
Which evidence best supports the claim that the server scales without protocol sessions?
- A server.json remote entry
- A wire-level concurrent run across two interchangeable replicas with no affinity dependency
- A unit test that constructs one response dictionary
- A diagram showing a load balancer
Show the answer
B. A wire-level concurrent run across two interchangeable replicas with no affinity dependency The real transport and deployment boundary must be exercised. Alternating requests across replicas proves correctness is not hidden in one process or sticky session.
Questions people ask
What is an MCP registry?
It is a catalog where MCP servers are published with metadata such as name, version, package or remote address and how to configure them. Clients use it to find servers. The live server then reports what it supports right now, which may differ from the listing.
How do you control what an AI agent can do through MCP?
Check identity and permissions on every request, not just at connect time. Tokens should be issued for that specific server and carry scopes. A policy layer decides if a given action may run, risky actions can require review, and an audit log records each call without storing private data.
Lessons from AI Engineering from Scratch by Rohit Ghumare and contributors, MIT license. Notes for owners by Lozo. License