Capstone 13 — MCP Server with Registry and Governance
The Model Context Protocol became the default tool-use spec in 2026. Anthropic, OpenAI, Google, and every major IDE ship MCP clients. Pinterest published its internal ecosystem of MCP servers, the AAIF Registry formalized capability metadata at `.well-known`, AWS ECS published the reference stateless deployment, and Block's goose-agent put the same protocol inside a hosted assistant. The production shape is StreamableHTTP transport, OAuth 2.1 scopes, OPA policy gating, and a registry where platform teams discover, validate, and enable servers. Build all of it.
Learning Objectives
- Design a StreamableHTTP MCP server that stays stateless behind a load balancer, and prove it with a multi-replica load test that needs no session stickiness.
- Enforce OAuth 2.1 per-tool scopes and OPA policy on every tool call, covering scope checks, PII redaction, and payload-size caps.
- Separate destructive tools onto a second MCP server gated by an approved:by:human scope elevated through a Slack card within 15 minutes.
- Publish a .well-known/mcp-capabilities manifest, index it with a registry service, and write a redacted, append-only per-tenant audit log for every call.
Problem
A production MCP server is a network service that exposes internal tools to any MCP client over StreamableHTTP, paired with a registry, a separate service that lets a platform team discover, validate, and enable those servers. MCP is now the lingua franca of tool use: Claude Code, Cursor 3, Amp, OpenCode, Gemini CLI, and every managed agent consume MCP servers.
Writing the server is the easy part, since FastMCP makes it quick. The hard part is deploying it at scale under enterprise requirements: per-tenant OAuth scopes, OPA policy on destructive tools, StreamableHTTP stateless scaling, a registry for discovery, and an audit log per tool call. Pinterest's internal MCP ecosystem and the AAIF Registry spec set the 2026 bar.
You will build an MCP server exposing 10 internal tools (Postgres read-only, S3 listing, Jira, Linear, Datadog, etc.), a registry UI for platform discovery, and a human-approval gate for destructive tools. The load test shows StreamableHTTP scaling horizontally, and the audit trail is what gets you through an enterprise security review.
Concept
StreamableHTTP is the MCP transport that serves JSON-RPC over a single HTTP endpoint: it accepts requests, streams responses, and supports long-lived connections for notifications. The 2026 revision of MCP mandates it as the default, replacing the earlier stdio-and-SSE shape for networked servers. It is stateless by default, which means you can scale it horizontally behind a load balancer.
Authorization is OAuth 2.1 with per-tool scopes. A token carries scopes like jira:read, s3:list, and postgres:query:readonly, and the server checks them at tool-call time, not just at session start. For high-risk tools the server rejects any call whose scope has not been elevated to approved:by:human within the last N minutes. That elevation comes from a Slack review card.
The registry is a separate service. Every MCP server exposes a .well-known/mcp-capabilities document listing its tool manifest, transport URL, and auth requirements. The registry polls each one, validates it, and indexes it. Platform teams use the registry UI to see which tools exist, which scopes they need, and which teams own them.
Architecture
MCP client (Claude Code, Cursor 3, ...)
|
v
StreamableHTTP over HTTPS (JSON-RPC + streaming)
|
v
MCP server (FastMCP) behind load balancer
|
+------+------+---------+----------+------------+
v v v v v
Postgres S3 listing Jira Linear Datadog
(read-only) (paged) (read) (read) (query)
|
+------+-------------+
v v
OPA policy gate destructive tool MCP (separate server)
|
v
human approval via Slack
|
v
audit log (append-only, per-tenant)
registry service
|
v GET /.well-known/mcp-capabilities from each server
v
UI: search / validate / enable-disable / ownership
Stack
- Server framework: FastMCP (Python) or
@modelcontextprotocol/sdk(TypeScript) - Transport: StreamableHTTP over HTTPS (stateless)
- Auth: OAuth 2.1 with workload identity via SPIFFE / SPIRE
- Policy: OPA / Rego rules per tool; policy decision service per request
- Registry: self-hosted, consumes
.well-known/mcp-capabilitiesmanifests - Human approval: Slack interactive message for destructive tools
- Deployment: AWS ECS Fargate or Fly.io, one server per tenant or shared with tenant scoping
- Audit: structured JSONL per-tenant bucket with per-call lineage
Build It
-
Tool surface. Expose 10 internal tools: Postgres read-only query, S3 list objects, Jira search/fetch, Linear search/fetch, Datadog metric query, PagerDuty on-call lookup, GitHub read-only, Notion search, Slack search, Salesforce read. Each tool gets a typed schema and a scope label.
-
FastMCP server. Mount the tools and configure the StreamableHTTP transport. Add middleware for OAuth token introspection and scope enforcement.
-
OPA policy. Write a Rego policy per tool covering which scopes permit invocation, what PII redaction applies, and what payload-size caps apply. The decision service is called on every tool call.
-
Registry service. Build a separate Go or TS service that polls
.well-known/mcp-capabilitiesfrom registered servers and validates each manifest with JSON Schema. It exposes a list / search / validate / enable-disable UI. -
Capability manifest. Each server exposes
.well-known/mcp-capabilitieswith the tool list, auth requirements, transport URL, owner team, and SLO. -
Destructive tool separation. Tools that mutate state (Jira create, Linear create, Postgres write) live on a second MCP server with a stricter auth flow. Tokens need the
approved:by:humanscope, elevated via Slack card within 15 minutes. -
Audit log. Write append-only JSONL per tenant:
{timestamp, user, tool, args_redacted, response_redacted, outcome}. Presidio redacts PII before anything is written. -
Load test. Run 100 concurrent clients on StreamableHTTP. Add a second replica and show the load balancer redistributing traffic without session stickiness.
-
Conformance tests. Run the official MCP conformance suite against both servers and pass all mandatory sections.
Use It
$ curl -H "Authorization: Bearer eyJhbGc..." \
-X POST https://mcp.internal.example.com/ \
-d '{"jsonrpc":"2.0","method":"tools/call",
"params":{"name":"postgres.readonly","arguments":{"sql":"SELECT 1"}}}'
[registry] capability validated: postgres.readonly v1.2
[policy] scope postgres:query:readonly present; allowed
[audit] logged: user=u42 tool=postgres.readonly outcome=ok
response: { "result": { "rows": [[1]] } }
Ship It
outputs/skill-mcp-server.md describes the deliverable: a production-grade MCP server, registry, and audit layer for internal tools, with OAuth 2.1 scopes and OPA gating.
| Weight | Criterion | How it is measured |
|---|---|---|
| 25 | Spec conformance | StreamableHTTP + capability manifest passes MCP conformance tests |
| 20 | Security | Scope enforcement, OPA coverage across every tool, secret hygiene |
| 20 | Observability | Per-tool-call audit log with PII redaction |
| 20 | Scale | 100-client load test horizontal scale demonstration |
| 15 | Registry UX | Discover / validate / enable-disable workflow |
| 100 |
I'd build the destructive-tool server from step 6 before I polish the registry UI. The approval flow is where the Slack elevation, the 15-minute window, and the scope check all have to agree, and a gap there lets a mutating call through. A registry bug costs you a bad listing; this one costs you a bad write. The rubric gives Security 20 points and Registry UX 15, which points the same way.
I'd also run the step 8 load test early, with the second replica added and no session stickiness. The point of a stateless StreamableHTTP server is that any replica can serve any request, and a server that keeps per-session state in memory would force stickiness and break that. My bet is that it's cheaper to find that out before the registry and audit layers are built on top.
Exercises
-
Add a new tool (Confluence search). Ship it through the registry validation flow without touching the core server.
-
Write an OPA policy that redacts Postgres query results containing columns named
email,ssn, orphone. Exercise with a probe query. -
Benchmark StreamableHTTP vs stdio on local latency. Report per-call p50/p95.
-
Implement per-tenant quota: maximum N calls per minute per tool per tenant. Enforce via a second OPA rule.
-
Run the MCP conformance suite from mcp-conformance-tests and fix every failure.
Key Terms
| Term | What people say | What it actually means |
|---|---|---|
| StreamableHTTP | "2026 MCP transport" | Stateless HTTP + streaming; replaces SSE + stdio for networked servers |
| Capability manifest | "Well-known doc" | .well-known/mcp-capabilities with tool list, auth, transport URL |
| OPA / Rego | "Policy engine" | Open Policy Agent for authorizing tool calls against external rules |
| Scope elevation | "Approved-by-human" | Short-lived scope granted via Slack approval, required for destructive tools |
| Registry | "Tool discovery" | Service that indexes MCP servers from their capability manifests |
| Workload identity | "SPIFFE / SPIRE" | Cryptographic service identity for OAuth token issuance |
| Conformance suite | "Spec tests" | Official MCP test battery for StreamableHTTP + tool manifest correctness |
Further Reading
- Model Context Protocol 2026 Roadmap: StreamableHTTP, capability metadata, registry
- AAIF MCP Registry spec: the 2026 registry spec
- AWS ECS reference deployment: reference production deployment
- Pinterest internal MCP ecosystem: the reference internal deployment
- Block
gooseMCP usage: reference agent consumption pattern - FastMCP: Python server framework
- Open Policy Agent: policy engine reference
- SPIFFE / SPIRE: workload identity reference
Code
- main.py (python, 238 lines)
- ts/package.json (json, 22 lines)
- ts/README.md (markdown, 30 lines)
- ts/src/index.ts (typescript, 83 lines)
- ts/src/protocol.ts (typescript, 100 lines)
- ts/src/tools.ts (typescript, 73 lines)
- ts/src/transport.ts (typescript, 46 lines)
- ts/src/types.ts (typescript, 54 lines)
- ts/tests/protocol.test.ts (typescript, 124 lines)
- ts/tsconfig.json (json, 18 lines)