Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Chapter 22: Deployment & Discovery

The security chapters were about making a server safe enough to trust. Deployment is about making that same server real enough to operate.

A local prototype can survive with hand-configured credentials, loose process ownership, and one developer watching it carefully. A production MCP service has to survive dependency outages, secret rotation, clients that cache what they saw last week, and an on-call engineer who did not design the first version.

Deployment is where architecture becomes operations. Discovery is where operations becomes usability. You need both.

The operator rule for this chapter: the URL a client was given, the resource in your protected resource metadata, and the audience of every token your server accepts are the same string. When those three drift apart, no amount of healthy infrastructure will get a client through its first call.

Discovery Is Resource Identity

Once a server is remote and shared, every client team needs a stable answer to four questions:

  • what is the canonical MCP endpoint?
  • which environment does it represent?
  • how is authorization discovered?
  • where do humans go for onboarding, scope expectations, and troubleshooting?

For an OAuth-protected server, the third answer is protected resource metadata (RFC 9728), and Chapter 19 shows how clients walk from a 401 to that document to the authorization server. What Chapter 19 assumes, and deployment breaks, is that the server has exactly one name.

A Vanity Domain Turns Into a 401 Loop

The tickets server runs at https://mcp.example.com/mcp. Its token validator is configured with that URL as the only accepted audience. A platform team adds a friendlier name, tickets.example.com, as a second hostname on the same ingress, and the onboarding page starts handing out https://tickets.example.com/mcp.

Nothing is down. Every health check is green. And every client configured with the new name fails like this:

  1. The client posts to https://tickets.example.com/mcp with no token and gets a 401.
  2. It fetches protected resource metadata from the same host. The metadata handler builds resource from the request’s Host header, so it reports the vanity name.
  3. The client asks auth.example.com for a token with resource=https://tickets.example.com/mcp (RFC 8707). The authorization server has that string registered as an alias, so it issues a token with that audience.
  4. The client retries. The validator expects https://mcp.example.com/mcp, sees the vanity audience, and returns 401 with error="invalid_token".
  5. The client concludes its token is bad, discards it, and runs the flow again. It gets the same token. The user sees a sign-in window that keeps coming back.

Here is the metadata each host returned. The canonical host was correct:

{
  "resource": "https://mcp.example.com/mcp",
  "authorization_servers": ["https://auth.example.com"],
  "scopes_supported": ["tickets:read", "tickets:write"],
  "bearer_methods_supported": ["header"]
}

The vanity host returned a document that looked just as healthy but named a resource the validator would never accept:

{
  "resource": "https://tickets.example.com/mcp",
  "authorization_servers": ["https://auth.example.com"],
  "scopes_supported": ["tickets:read", "tickets:write"],
  "bearer_methods_supported": ["header"]
}

If the handler had hard-coded the canonical value instead, a conforming client would have stopped at step 2: RFC 9728 §3.3 requires the client to check that resource matches the URL it is using, and it would refuse to continue. That failure is louder and easier to diagnose, but still a failure.

One loop checks every name you serve:

for host in mcp.example.com tickets.example.com; do
  printf '%-22s ' "$host"
  curl -s "https://$host/.well-known/oauth-protected-resource/mcp" | jq -r .resource
done
mcp.example.com        https://mcp.example.com/mcp
tickets.example.com    https://tickets.example.com/mcp

Two different answers means two resource identities, and your validator only knows one. Your logs say the same thing if every 401 records the audience of the rejected token; Chapter 23 builds that event, and Chapter 24 queries it.

The fix is a decision, not a patch: serve MCP on one hostname. Aliases answer the MCP path and the well-known path with a 404 whose body names the canonical URL; they never proxy. If a business reason demands two names, make them two resources, each with its own metadata, its own registration at the authorization server, and a validator that accepts only the audience of the host that served the request. Never build resource from the Host header.

Publish the Canonical URL as Data

A registry entry is where the canonical URL stops being tribal knowledge. The official registry’s server.json for tickets needs very little:

{
  "$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json",
  "name": "com.example/tickets",
  "title": "Tickets",
  "description": "Search, create, and update support tickets and deployments",
  "version": "4.2.0",
  "websiteUrl": "https://docs.example.com/mcp/tickets",
  "remotes": [
    { "type": "streamable-http", "url": "https://mcp.example.com/mcp" }
  ]
}

Notice what is absent: auth. The entry carries the canonical URL and a human help page; the server’s own metadata carries the authorization server and scopes. Keep it that way, because a second copy of auth configuration is a second place to drift. Give staging its own entry (com.example/tickets-staging) or none at all, never a URL variable that lets a client pick the environment.

Moving the Canonical URL Is a Migration

Sooner or later the canonical URL has to change: a new domain, a split into regional endpoints, a move behind a gateway. Treat it as an interface migration, because to a client it is one. Every stored configuration, every cached token, and every registry entry points at the old string, and tokens issued for the old audience are useless at the new one.

The sequence that avoids a flag day:

  1. Stand up the new URL as a complete, separate resource: its own metadata with its own resource, registered at the authorization server, and a validator that accepts only its own audience.
  2. Publish the new URL in the registry and onboarding docs, and announce a dated cutover.
  3. Keep the old URL serving its own old identity, unchanged, while you watch its traffic by client_name in the logs.
  4. When old-URL traffic is down to clients you can contact directly, answer the old MCP path with a 404 whose JSON-RPC error message names the new URL. Humans read it in client logs; models read it in the failed call.

The step to resist is a 301 or 308 from the old MCP path to the new one. A client that follows the redirect sends a token minted for the old audience to a server that will reject it, and you are back in the loop from the start of this chapter, now with a redirect hiding the cause. Redirects are fine for the human help page. They are wrong for the MCP endpoint and its well-known documents.

The help page behind websiteUrl is one of three documents a shared server needs: client onboarding, operator runbooks, and release notes for interface or auth changes. If a client team cannot tell which server to connect to, which auth flow to expect, and where to go when it fails, the deployment is incomplete even when the endpoint is healthy.

HTTP Behavior That Actually Matters

Remote MCP over HTTP brings back the ordinary web-service details that local prototypes let you ignore:

  • timeout policy that matches real client expectations,
  • streaming behavior that degrades cleanly when a client cannot use it fully,
  • MCP-Protocol-Version handling that is explicit rather than accidental,
  • CORS and origin validation when browser-mediated clients are involved,
  • compression for large but legitimate responses,
  • and request-size limits that stop abuse without breaking normal use.

“Explicit” has a test. Send a version you do not support and confirm the server says so instead of guessing:

curl -s -X POST https://mcp.example.com/mcp \
  -H 'Content-Type: application/json' \
  -H 'Accept: application/json, text/event-stream' \
  -H 'MCP-Protocol-Version: 2019-01-01' \
  -H 'Mcp-Method: server/discover' \
  -d '{"jsonrpc":"2.0","id":1,"method":"server/discover","params":{"_meta":{"io.modelcontextprotocol/protocolVersion":"2019-01-01","io.modelcontextprotocol/clientCapabilities":{}}}}' \
  -w '\nHTTP %{http_code}\n'

A 2026-07-28 server answers 400 with an UnsupportedProtocolVersionError that lists the versions it does support. A header that disagrees with _meta must get 400 with -32020 (HeaderMismatch). A 200, or a 400 with an empty body, means version handling is accidental. Run the probe through the public hostname, not just against the pod, because a proxy that rewrites or drops headers fails it too.

None of these make the architecture diagram more interesting. All of them affect whether the service is actually usable.

Choosing a Deployment Topology

TargetBest fitMain tradeoffs
Containerized serviceDefault choice for most remote MCP serversPredictable runtime and rollout, but you own normal service operations
Kubernetes deploymentShared platform environments, multi-service estates, policy-heavy environmentsStrong controls, but easy to over-engineer early
Serverless function or serviceSpiky or low-volume read-heavy workloadsCheap idle time, but cold starts and connection setup can dominate latency
VM or long-lived processSmaller internal deployments with simple ownershipFewer moving parts, but weaker standardization and release discipline
Edge-style deploymentVery latency-sensitive, geographically distributed read pathsUseful in narrow cases, but state, auth, and downstream connectivity get harder fast

Pick the simplest platform that can satisfy your auth, observability, and rollback needs. “Modern” is not a deployment requirement.

Readiness Waits for Warm-Up

Chapter 7 covered /healthz and /readyz. The deployment-specific addition is warm-up: /readyz stays 503 until connection pools are open, caches are primed, and the tool catalog is built, so the first real call after a rollout does not pay the cold start that Chapter 13 measured. During a rolling deploy, that is the difference between a smooth handover and a burst of timeouts that clients read as an outage.

Package for Repeatability

Pin dependencies, keep build tooling out of the runtime image, inject secrets at runtime, and run with the least privilege the platform allows. Appendix J has starter Dockerfile, Compose, and Kubernetes templates.

Rollback Leaves Clients With the Newer Catalog

The first remote release should be narrow on purpose: one deployment target, one auth path, one monitoring stack, and one limited user group. Useful rollout tactics include:

  • internal-only launch before customer traffic,
  • read-oriented tools before write tools,
  • canary or allowlisted tenants,
  • and feature flags around especially expensive or dangerous tool families.

Rolling the code back is the easy half. Version 4.2 added bulk_update_tickets; clients listed tools, cached the catalog, and models started planning around the new tool. You roll back to 4.1. The pods are healthy, but clients still hold the 4.2 catalog until they list tools again or receive notifications/tools/list_changed, and many do neither mid-conversation. Their next bulk_update_tickets call hits a server that has never heard of it, and the model gets a bare “unknown tool” with no idea what to do instead.

Plan for that before the release, not during the rollback. Any tool that has shipped to callers must be removable as a recoverable error, not a disappearance: the rolled-back server keeps a stub for bulk_update_tickets that returns a tool error naming the replacement (update_ticket, one call per ticket). Tools callers have never seen can stay hidden; tools they have used get an explicit error. Chapter 28 shows the pattern. It also means the previous version is only a safe rollback target if it carries those stubs, so build them into the release, not the incident.

Failure Modes

  1. Audience mismatch behind a proxy or vanity domain. Metadata, token audience, and validator disagree, and clients loop on 401 while every health check passes.
  2. Canonical URL changed without migrating clients. The new endpoint works, the old one returns 404 or 421, and client configs, registry entries, and onboarding pages still point at the old one.
  3. Rollback leaves a stale catalog. Clients call tools the running version does not know, and the model gets an error it cannot act on.
  4. Readiness true before warm-up. New instances take traffic with cold pools and empty caches, and the first minute after every deploy looks like an incident.
  5. SSE buffered by ingress. A proxy holds the response stream until it closes, so progress notifications arrive in one lump and long calls look hung.

Implementation Checklist

Run Appendix F alongside this list before the first release.

  • The tool set is deliberately small, and risky actions are clearly named and gated.
  • Auth, tenant scope, approval semantics, and audit are tested end to end through the public hostname.
  • Every alias hostname returns 404 for MCP and well-known paths, and a scripted check compares resource across all names on each deploy.
  • A registry entry or equivalent names the canonical URL and a help page, one per environment.
  • Timeouts, rate limits, and dependency guards exist on the hot paths.
  • An operator can roll back, disable one tool family, and identify affected tenants without a code change.
  • Every tool that has shipped has a removal stub ready before the release that adds it.
  • The unsupported-version probe runs in CI against the deployed hostname.

A good first production MCP service makes the fewest surprising promises, rather than offering the most capabilities. One name, one audience, one catalog that can shrink without breaking anyone: that is the promise.

What Comes Next

Deployment puts the server in front of an audience. The next chapter is how you see what that audience, and the model driving it, is actually doing.