MCP in Production · Chapter 9
The first MCP server I ever shipped to a paying customer ran on a VPS for fourteen months before I admitted it was a problem.
It was a stdio server, originally. Worked great on my laptop, worked great on the customer's laptop, and then a second customer signed up and asked the obvious question: "Can my whole team use this?" I said yes, of course, and proceeded to spend a weekend gluing an HTTP transport onto a binary that had no business being a network service. I deployed it as a single systemd unit. No reverse proxy. No TLS termination beyond what Cloudflare gave me for free. No metrics. The logs were a 4 GB text file that I tailed when something broke.
It was fine. It was actually fine for a long time. Then the third customer signed up, then the fifth, and one Tuesday morning I woke up to a Slack DM that said "your server is down" from a VP who had a board demo in ninety minutes. I SSHed in, found the disk full of logs, truncated the file, restarted the service, and lied gently about the cause. That weekend I rebuilt the whole thing on Fly.io, learned more about HTTP session affinity than I had ever wanted to know, and started the project that eventually became Yaw MCP.
I am telling you this story because every chapter on hosting starts with a tidy decision tree and pretends that the author arrived at their architecture through pure reason. That is never how it happens. You ship something that works. It works for longer than it should. Then it breaks in a way that costs you sleep, and the next version of the system is shaped by exactly that pain. This chapter is the version of that hard-won knowledge that I wish someone had handed me in month two.
Let me get the easiest decision out of the way first. If your server speaks stdio, you do not need to host it. Stop. Go publish it to npm or PyPI, write a README that explains how to add it to a Claude Desktop config or a Cursor mcp.json, and call it a day. The user runs the binary. The user pays for the compute. You ship code, not infrastructure.
This sounds obvious until you watch a team spend three sprints building Kubernetes manifests for a server that nobody outside their company will ever run. Stdio servers are the right answer for a huge fraction of the MCP ecosystem -- developer tools, local file utilities, anything that wants to read the user's environment, anything that benefits from running with the user's credentials rather than a service account. If the server only ever has one user at a time and that user is the person who launched it, stdio is correct.
The dividing line is multi-user. The moment two humans need to talk to the same instance of your server -- whether because it has shared state, holds a license to an upstream API, or simply because nobody on the team wants to install another binary -- you are in HTTP territory, and HTTP means hosting. If you are still weighing which side of that line a given server sits on, local vs remote MCP servers walks the tradeoff case by case.
There is a soft middle ground worth naming. Some teams ship an HTTP server that they expect customers to self-host on their own infrastructure. That is a perfectly fine business model -- it is what the OSS-with-enterprise-support crowd has been doing for twenty years -- but it is still hosting, just hosting that someone else does. You still owe those customers a Dockerfile, a Helm chart, and clear guidance on resource sizing, and you will spend a remarkable amount of time on a support call helping them debug their ingress controller. Plan for it.
The rest of this chapter assumes you have decided to host an HTTP MCP server yourself, for paying or potential customers. We are going to walk from packaging through deploy through operations, and along the way I will be honest about where Yaw MCP (my product) is the right call and where it absolutely is not.
Almost every hosting target you will care about wants a container. Even Cloudflare Workers, which does not run containers, wants you to think in terms of an immutable artifact. Get the container right and most of the rest of this chapter becomes a question of where you point it.
Here is the Dockerfile pattern I use for every TypeScript MCP server I ship. It is unspectacular. That is the point.
# syntax=docker/dockerfile:1.7
# Stage 1: install deps and build
# Replace the digest with the actual one for the tag you want to pin (run
# `docker buildx imagetools inspect node:20.18.1-bookworm-slim`
# to look it up). Do not ship with this placeholder.
FROM node:20.18.1-bookworm-slim@sha256:REPLACE_WITH_REAL_DIGEST AS build
WORKDIR /app
# Copy lockfile-bearing files first so layer cache holds across code changes.
COPY package.json package-lock.json ./
RUN --mount=type=cache,target=/root/.npm \
npm ci --include=dev
COPY tsconfig.json ./
COPY src ./src
RUN npm run build
# Prune dev deps for the runtime stage.
RUN npm prune --omit=dev
# Stage 2: minimal runtime (same digest as the build stage)
FROM node:20.18.1-bookworm-slim@sha256:REPLACE_WITH_REAL_DIGEST
WORKDIR /app
# Non-root user. Hosting platforms vary on whether they enforce this,
# but you should never rely on the platform to enforce it for you.
RUN useradd --system --uid 10001 --no-create-home mcp
USER mcp
COPY --from=build --chown=mcp:mcp /app/node_modules ./node_modules
COPY --from=build --chown=mcp:mcp /app/dist ./dist
COPY --chown=mcp:mcp package.json ./
ENV NODE_ENV=production
ENV PORT=8080
EXPOSE 8080
# No init shim here -- node runs directly as PID 1. If your platform doesn't
# reap zombies or forward signals cleanly, add `docker run --init` or a tini ENTRYPOINT.
ENTRYPOINT ["node", "--enable-source-maps", "dist/server.js"]
A few things to notice. The base image is pinned by digest, not just by tag. node:20.18.1-bookworm-slim will silently move under you the next time the upstream image rebuilds, which happens for security reasons every few weeks. The digest does not move. If your build pipeline pulls a tag, your "reproducible" build is reproducible only until the next CVE patch lands, at which point your January build and your March build are subtly different binaries that both claim to be node:20.18.1. I have debugged a P0 outage that was caused by exactly this. Pin the digest.
The two-stage pattern is doing real work, not just looking professional. The build stage holds your dev dependencies, your TypeScript compiler, your test artifacts. The runtime stage holds none of that. A typical TypeScript MCP server image goes from 1.4 GB single-stage to about 280 MB two-stage. That difference is real money on egress charges, real seconds on cold-start times, and real surface area for a security scanner to complain about.
The non-root user is not optional. Some hosting platforms refuse to run images that declare USER root or no user at all. Set the UID explicitly so that volume permissions are predictable across rebuilds. Numeric UIDs survive renames; named users do not.
Now the part that nobody likes hearing: tag your built image with the git SHA, not with latest, and reference it in your deployment manifest by digest, not by tag.
# WRONG. The tag will move under you.
image: registry.example.com/my-mcp-server:latest
# OK-ish. The tag is stable only by convention; anyone with push access can move it.
image: registry.example.com/my-mcp-server:v1.4.2
# RIGHT. The digest is cryptographically pinned (full 64 hex chars; this
# is the literal digest your CI emitted, not a truncated example).
image: registry.example.com/my-mcp-server@sha256:REPLACE_WITH_REAL_DIGEST
The reason for the digest reference is that tags are mutable in every container registry I have used. Someone with push access can re-tag v1.4.2 to point at a different image, deliberately or accidentally. With a digest, that is impossible. You are saying "deploy this exact byte sequence," and the registry refuses to serve anything else under that name. When a customer reports a bug, you can say with certainty which image they hit. When you need to roll back, you can say with certainty what you are rolling back to.
Recording the digest as the output of your build pipeline is one of those small disciplines that pays off over and over. Your CI prints the digest. Your release notes include the digest. Your incident postmortems reference the digest. Tags lie; digests do not.
I built the first version of my server's container image on my laptop for a month before the inevitable happened: I spent a Friday evening trying to reproduce a bug that only existed in production, and discovered that the image I had built that morning had different node_modules than the image that was currently running, because I had run npm install on a slightly newer machine in between. The lockfile was the same. The output was different. Some transitive dependency had a postinstall script that did something different based on the host's glibc version. I lost three hours to it before I gave up and started over on a clean build host.
That night I moved every release build to GitHub Actions, and I have never moved one back.
The principle is straightforward: builds tied to your workstation inherit your workstation's state, which means the artifact you ship is shaped by whatever was installed on your laptop the day you cut the release. That is fine for a hobby project. It is not fine for anything a customer pays you for. The artifact a coworker builds next week from the same commit should be byte-identical to the artifact you build today, and the only way to make that true is to build in a controlled environment where the inputs are explicit.
In practice, this means one of two things. Either you build on a CI runner with a pinned base image and a locked toolchain, or you build inside a container that has its own pinned base image and locked toolchain, on whatever machine. The second approach -- building in a container -- gives you a reproducible build even when your CI is unavailable, but you have to be disciplined about not letting your laptop's environment leak in.
Here is the pattern I use for releases:
# .github/workflows/release.yml (sketch)
on:
push:
tags:
- 'v*'
jobs:
build-and-push:
runs-on: ubuntu-24.04
permissions:
contents: read
packages: write
id-token: write
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: docker/setup-buildx-action@v3
- uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push
id: build
uses: docker/build-push-action@v6
with:
context: .
push: true
tags: |
ghcr.io/${{ github.repository }}:${{ github.ref_name }}
ghcr.io/${{ github.repository }}:sha-${{ github.sha }}
provenance: true
sbom: true
- name: Record digest
run: |
echo "Image digest: ${{ steps.build.outputs.digest }}"
echo "${{ steps.build.outputs.digest }}" > image-digest.txt
- uses: actions/upload-artifact@v4
with:
name: image-digest
path: image-digest.txt
The digest comes back from the registry as a side effect of the push. You record it. You reference it in the deployment that consumes it. The provenance and SBOM attestations let you answer the question "what was actually in this image" months later, which is a question every security-conscious customer will eventually ask you.
One thing I want to call out specifically because it is a place I see teams cut corners. Do not run kubectl apply from your CI runner using a service account credential stored as a CI secret. It is tempting -- the build just produced the artifact, the deploy is the next step, why not do both? Because cluster access is authoritative state and CI runners are ephemeral infrastructure. A compromised CI runner with cluster credentials is a much bigger blast radius than a compromised CI runner that can only push images.
Build artifacts in CI; push them to your registry; let a separate process, ideally one running with your own short-lived credentials on a workstation you control, consume the artifact and apply it to the cluster. GitOps workflows like Argo CD and Flux are the cleaned-up version of this pattern -- the cluster pulls from a git repo that holds the desired state, and humans never kubectl apply directly. If you are at any kind of scale, that is the right shape.
Now we get to the part where you have to pick a place to put the thing. I am going to walk through the major options and tell you when each one is the right call. I run a managed MCP hosting platform, so I have an obvious bias toward one of these answers, but I also lose money every time someone signs up for Yaw MCP when they should have shipped to Workers, because the support load on a misfit customer is brutal. My genuine interest is in routing you to the right answer.
There are now several managed platforms purpose-built for MCP servers. The ones I would actually consider in 2026, in alphabetical order: glama.ai, Yaw MCP (disclosure: I run this one), and Smithery. Each one points at a container image or git repo, handles authentication and routing between protocol versions, and offers a registry where users can discover your server. The feature mixes differ -- glama.ai leans into the directory-and-discovery experience, Yaw MCP emphasizes conformance grading and per-tenant isolation, Smithery has the most polished one-click install for end users -- but the value proposition is the same: you ship an MCP server, they handle the operational substrate. (Yaw MCP started life as mcp.hosting -- the rename and the architecture behind it are covered here.)
When managed hosting is the right call: you are shipping an MCP server to multiple customers, you do not have an in-house platform team, you want to focus on the tools your server exposes rather than on the operational substrate, and you would rather pay a subscription than spin up your own observability stack.
When it is not the right call: you have a single-tenant deployment for a single enterprise customer, you have hard data residency requirements that mean the server must run in a specific cloud region or VPC, you are running tools that need GPU access or unusual local resources, or you are operating at a scale where the per-call pricing crosses over the cost of running your own infra. The crossover point depends on your tool mix, but rule of thumb: if you are doing more than about 50M tool calls a month and your tools are cheap (no LLM-on-the-backend), you can run it cheaper yourself.
Cloudflare Workers is a V8-isolate edge runtime. It does not run Node.js. It does not run containers. It runs JavaScript modules with a constrained set of platform APIs, and it does so on Cloudflare's global anycast network, which means your server is geographically close to wherever the request originates.
When it is the right call: your tools are stateless, your server is genuinely small (a few thousand lines of TS), you have no native dependencies, and you want global distribution for free. Cloudflare's free tier is generous enough to host a real MCP server, and the cold-start characteristics of V8 isolates are dramatically better than container-based runtimes. If your tool surface is "wrap an HTTP API and expose it via MCP," Workers is often the boring correct answer.
When it is not the right call: you have any native dependency, you need a long-running connection for streaming responses (SSE works but the long-running connection model has caveats on the free tier), you need to write to a local filesystem for any reason, or your server has a startup phase that takes more than a few hundred milliseconds. Cloudflare imposes CPU and wall-clock limits that will bite tools that do real computation per call -- the specifics shift across plan tiers and have moved more than once since this book started, so check the current limits on the pricing page rather than trusting numbers in print.
The MCP-specific gotcha with Workers is that the standard MCP HTTP transport assumes session continuity -- the client establishes a session, the server holds session state, and subsequent requests on the same session land on the same logical server. Workers, being stateless and edge-distributed, does not natively give you that. The pattern that works is to externalize session state into Cloudflare's Durable Objects or KV, or to design your server to treat every request as a new session and put any continuity into the auth token or query parameters. If your tools are pure functions of their inputs, this is fine. If they hold meaningful state across calls, you are about to learn more about Durable Objects than you wanted to.
A specific pattern I have seen work well: use Workers for a "thin" MCP gateway that handles auth, rate limiting, and routing, and have it forward to a heavier backend (a container on Fly or a Lambda) for the actual tool execution. The Workers layer terminates TLS, validates the bearer token, and adds a signed internal token before forwarding. You get edge-fast auth and a sensible place to do per-customer routing without forcing the whole tool runtime into the isolate model.
Fly runs containers on a global anycast network with reasonable defaults and a very forgiving free tier. It is the platform I reach for first when I need a container in production and I do not want to think about Kubernetes.
When it is the right call: you have a container, you want it to run somewhere with TLS terminated and a real URL pointed at it, you do not want to write Terraform or learn ECS, and you would like global distribution to be a flag rather than a project. Fly's primitive is "an app made of one or more processes," and the deployment model -- fly deploy reads your fly.toml and pushes the container -- is honestly closer to Heroku-circa-2012 than to anything in the cloud-native world. That is high praise.
When it is not the right call: you need very fine-grained control over networking (private VPC peering, transit gateways, the kind of things that come up in regulated industries), you have an enterprise procurement process that requires AWS or GCP, or you are at the scale where Fly's pricing stops being competitive (which happens earlier than you might guess for sustained high-traffic workloads).
The thing I love about Fly for MCP servers specifically is that the platform's built-in Anycast plus their fly-replay header gives you a reasonable answer to session affinity without much work. A request lands on whichever region is closest, your server inspects the session header, and if the session lives in a different region, you reply with fly-replay: region=ord and the request is replayed there. It is not free -- the replay adds a round trip -- but it is a much simpler mental model than running a sticky-session load balancer yourself.
ECS on Fargate is the boring enterprise answer. You define a task, you point a service at it, the platform runs it, and an Application Load Balancer terminates TLS in front of it. If your customers require that your data and compute live in AWS -- and many enterprise customers do -- this is where you end up.
When it is the right call: you are selling to enterprises with AWS-only procurement, you have an existing AWS footprint, you have someone on the team who knows IAM well enough to not get hurt, or you need integration with AWS-native primitives like Secrets Manager, RDS, or KMS. Fargate is the right level of abstraction for most teams -- it is ECS without the EC2 capacity-management tax.
When it is not the right call: you are a small team without AWS expertise. The learning curve from "I have a container" to "this container is running in production with sane networking, secrets, and observability" is genuinely large in AWS, and the failure modes are subtle. Misconfigured security groups, IAM roles that look correct but silently deny, and the unique horror of debugging a Network Load Balancer health check are all things you will inherit. I have shipped on ECS many times. I would not recommend a team of three start there.
The MCP-specific note: ECS works fine for HTTP MCP servers, and the ALB handles session affinity via cookies if you turn on stickiness on the target group. The Mcp-Session-Id header is not natively understood by the ALB, so if you want session-aware routing rather than cookie-based stickiness, you put a small layer in front (CloudFront with a function, or a Lambda@Edge, or your own forwarder) that reads the header and rewrites it into a cookie or a routing decision.
Pick a Linux VPS provider, install your container runtime, run the container. Hetzner, Vultr, OVH, Linode, DigitalOcean -- any of them work, and the cost is hard to beat at small scale.
When it is the right call: you are at the scale where commodity VPS pricing is dramatically cheaper than managed alternatives, you have the operational maturity to run your own machines (and I mean that seriously -- backups, patching, intrusion detection, log shipping, the works), and your customers do not have compliance requirements that make a single-server deployment a non-starter.
When it is not the right call: you are not the kind of person who wants a Slack alert at 3 AM because your VPS provider rebooted a host for unrelated reasons. The single-VPS pattern works until it does not, and the failure modes are exactly the ones that wake you up at the worst times. If you are going to self-host, plan from day one for at least two machines with health-checked failover, a load balancer in front, and a deploy process that does not require SSH-ing in.
If you already have a Kubernetes cluster, deploy your MCP server to it. The patterns are well-trodden: a Deployment with a sane resource request, a Service of type ClusterIP, an Ingress with TLS terminated by cert-manager, and Secrets for your bearer tokens.
If you do not already have a Kubernetes cluster, do not stand one up to run an MCP server. I say this as someone who has used Kubernetes in production for five years and likes it. The operational tax of running a cluster is real, and an MCP server is not the workload that justifies paying it. Pick Fly, pick Yaw MCP, pick Workers if your tools fit. Kubernetes is the right answer when the workload mix at your company already required a cluster for other reasons.
The two K8s-specific things I want to call out are the resource sizing trap and the secrets pattern. Both of them have eaten me alive at some point.
The resource sizing trap. Never reduce Kubernetes resource requests based on observed idle traffic. I have seen this play out three times: someone deploys a service, watches it sit at 5% CPU for a week, and "rightsizes" the request down. A week later real traffic arrives, the pod gets CPU-throttled and OOM-killed in turn, the scheduler can't find a node that satisfies the request because the cluster autoscaler tuned itself for the old footprint, and the service goes into a CrashLoop. Size requests to the expected peak working set, not to current idle. Reduce requests only after observing actual sustained load on a representative workload, not on a development environment with zero users.
The secrets pattern is more subtle. The wrong way:
# WRONG. This goes in git. This shows in `kubectl describe`.
# Anyone with read access to the namespace can grab it.
env:
- name: UPSTREAM_API_KEY
value: "sk-prod-9f3a7b2c1d..."
The right way:
# RIGHT. The secret resource is its own object, scoped, rotatable.
env:
- name: UPSTREAM_API_KEY
valueFrom:
secretKeyRef:
name: upstream-api-credentials
key: api-key
The secret resource itself is created out-of-band -- via Sealed Secrets, SOPS, External Secrets Operator with a backend like Vault or AWS Secrets Manager, or, in a small-team setup, just a one-shot kubectl create secret that nobody commits. The principle is that the deployment manifest references the secret by name; the value lives somewhere with proper access controls.
The reason this matters specifically for MCP servers is that almost every interesting MCP server has at least one upstream credential -- the API key for the SaaS it wraps, the database password, the OAuth client secret. Putting them inline in the manifest leaks them into the audit log, the kubectl history of every developer who runs describe, and into git if the manifest is checked in. Secret resources are not perfect (a determined attacker with cluster admin reads them just fine) but they are dramatically better than inline values, and they are the floor below which you should not go.
Here is the heuristic I actually use when someone asks. Pick the most boring option that fits.
The wrong reason to pick a hosting target is "it is what we use for everything else." That reason is sometimes correct, but it is correct by accident. The right reason is that the workload's shape (stateful or stateless, latency profile, tool compute, customer requirements) matches the platform's strengths.
The MCP HTTP transport has a few properties that interact with your hosting choice in ways that surprise people the first time.
Streamable HTTP -- the current transport -- collapses what the older SSE-based transport split across two endpoints. A single endpoint, typically POST /, accepts a JSON-RPC request and replies either with a single JSON object (for a one-shot tool call) or with a streaming SSE-style body in the same HTTP response (for long-running or multi-message replies). The server picks per request, based on what the call needs.
A typical tool-call request:
POST / HTTP/1.1
Host: github-mcp.example.com
Content-Type: application/json
Accept: application/json, text/event-stream
Mcp-Session-Id: 4f8b2e1a-7c3d-4e9f-a1b2-c3d4e5f6a7b8
Authorization: Bearer eyJhbGc...
{
"jsonrpc": "2.0",
"id": 42,
"method": "tools/call",
"params": {
"name": "list_my_repos",
"arguments": { "sort": "updated" },
"_meta": { "progressToken": "rp-7f3a" }
}
}
The fast-path response is just JSON:
HTTP/1.1 200 OK
Content-Type: application/json
Mcp-Session-Id: 4f8b2e1a-7c3d-4e9f-a1b2-c3d4e5f6a7b8
{"jsonrpc":"2.0","id":42,"result":{"repos":[...]}}
For a slower or multi-message tool the same HTTP response shape switches to SSE, with monotonic event IDs that matter on reconnect:
HTTP/1.1 200 OK
Content-Type: text/event-stream
Mcp-Session-Id: 4f8b2e1a-7c3d-4e9f-a1b2-c3d4e5f6a7b8
event: message
id: 1
data: {"jsonrpc":"2.0","method":"notifications/progress","params":{"progressToken":"rp-7f3a","progress":0.25}}
event: message
id: 2
data: {"jsonrpc":"2.0","method":"notifications/progress","params":{"progressToken":"rp-7f3a","progress":0.75}}
event: message
id: 3
data: {"jsonrpc":"2.0","id":42,"result":{"repos":[...]}}
Same endpoint. Same session id. Server-driven choice of batch vs stream. The headers worth committing to memory are Accept: application/json, text/event-stream (the client signals it can handle either), Mcp-Session-Id (the session continuity token, covered next), and Last-Event-ID (only on reconnect, also next).
The transport carries a session identifier in the Mcp-Session-Id header. The server allocates a session ID on the first request and the client echoes it on subsequent requests. The session is the unit of continuity for things like streaming responses, server-initiated notifications, and any tool state that lives across calls. If your tool exposes a file handle, a database transaction, or a cursor, the lifetime of that handle is tied to the session.
The session model is what lets a client survive a network blip mid-stream without redoing the work. The contract:
id: field.Mcp-Session-Id, and adds Last-Event-ID: <last-id-it-saw>.last-id + 1 onward, and replays them down the new HTTP response body.From the client's perspective the stream never broke. From the user's perspective, the four-minute tool call that survived a wifi flicker just kept going. Implemented well, this is invisible. Implemented poorly, every connection blip throws away the in-flight work and the user sees a hang followed by a fresh start.
The SDK's StreamableHTTPServerTransport handles the event-id counter and the buffered replay for you. What it does not handle is the cross-process side of session lookup, which matters the moment you have more than one replica of your server running behind a load balancer:
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
import express from "express";
import { randomUUID } from "node:crypto";
const app = express();
app.use(express.json());
// Session store. In-process Map is fine for a single replica;
// for multiple replicas this needs to be Redis (or sticky load balancing).
const sessions = new Map<string, StreamableHTTPServerTransport>();
app.post("/", async (req, res) => {
const sessionId = req.header("Mcp-Session-Id");
let transport = sessionId ? sessions.get(sessionId) : undefined;
if (!transport) {
transport = new StreamableHTTPServerTransport({
sessionIdGenerator: () => randomUUID(),
onsessioninitialized: (id) => sessions.set(id, transport!),
});
transport.onclose = () => {
if (transport!.sessionId) sessions.delete(transport!.sessionId);
};
const server = buildServerForSession(/* per-session auth, deps, etc. */);
await server.connect(transport);
}
await transport.handleRequest(req, res, req.body);
});
app.listen(3000);
Two things this code gets right that I have seen first-deploy mistakes on. First, the sessions Map is in-process. That works for a single-replica server. It does not work for two replicas behind a load balancer unless the LB has session affinity turned on, because a reconnect has roughly a 50% chance of landing on the wrong replica and finding no session there. The fix is either sticky sessions at the LB, or moving the session map to a shared store like Redis. Plan for that from day one rather than retrofitting.
Second, buildServerForSession returns a fresh McpServer per session, not a shared one. This matters because tool calls within a session may carry session-scoped state (auth context, rate-limit counters, in-flight cancellation tokens), and accidentally sharing one server instance across all sessions is how you invent a multi-tenancy bug. We covered the auth side of this in Chapter 4; the same principle applies to any per-session state.
If you are hosting on a platform with aggressive idle-connection timeouts, the streaming response model assumes long-lived connections. Cloudflare's free tier and a fair number of default load-balancer configs will close an "idle" SSE stream after 30-60 seconds, mid-tool-call, with no graceful signal to the server. The client sees a clean disconnect and assumes the work is done.
The fix is platform-specific in the configuration but consistent in the pattern: send a heartbeat comment frame every 15 seconds on idle streams. SSE supports : (colon-prefixed) lines as comments; clients ignore them, but the bytes-on-wire reset whatever idle counter the platform is using. On nginx-ingress, the additional fix is the nginx.ingress.kubernetes.io/proxy-read-timeout annotation -- I default to 600 seconds for MCP workloads because the 60-second default will kill any meaningful streaming tool call mid-flight, and the symptom (clean disconnect at exactly the 60-second mark) is hard to recognize until you've been bitten by it once.
The choice for hosting: your load balancer must route requests with the same session ID to the same backend instance, or you must externalize session state to a shared store. Both are valid; neither is free.
The sticky-session approach uses session affinity at the load balancer. AWS ALB calls this stickiness; Nginx calls it ip_hash or sticky cookie; Fly handles it via the fly-replay header described earlier. The downside is that sticky sessions interact poorly with autoscaling -- when a backend goes away, every session pinned to it has to reconnect, and your autoscaler will be reluctant to scale down because every instance has live sessions. For most MCP workloads this is fine; the sessions are short-lived enough that the disruption is bounded.
The shared-store approach moves session state into a backing store -- Redis, a database, Durable Objects -- and any backend can serve any request. The downside is the extra round trip per request and the additional infrastructure. The upside is that you can scale up and down freely and a backend going away costs you only the in-flight request, not the session.
The decision point is when you go from one replica to two. Below that, the in-process Map is fine; above it, you have minutes-to-debug from a passing test to a wedged session that landed on the wrong replica on reconnect. Plan for the second replica before you spin one up.
The MCP HTTP transport does not mandate a specific authentication scheme; it punts that to the deployment. In practice you have four patterns, and you will probably end up using at least two of them.
Bearer tokens are the workhorse. The client presents an Authorization: Bearer <token> header on every request; the server validates it. This is what most managed MCP platforms issue to customers, what Cloudflare Workers sees by default, and what almost every getting-started example shows. The token is opaque to the client; the server knows how to validate it (typically against a database or a JWT signature). If you get one thing right, get this right: never log the token, never accept it as a query parameter (it ends up in HTTP server logs that leak to all kinds of places), and always rotate on a schedule.
OAuth 2.1 with PKCE is the right answer when your MCP server fronts a service that already has an OAuth identity model -- a customer's GitHub, their Google Workspace, their Salesforce. The MCP client (Claude Desktop, Cursor, etc.) walks the user through the OAuth dance, ends up with an access token, and presents that token to your server. Your server validates the token against the upstream identity provider, possibly caches the validation, and uses the user's identity to scope tool calls. PKCE specifically protects against authorization code interception in the redirect flow, which matters because MCP clients often run on devices where the redirect URL is a custom scheme rather than a real HTTPS endpoint. Use a real OAuth library; do not roll this yourself.
mTLS -- mutual TLS -- is overkill for almost everyone but exactly right for a small set of cases. If your MCP server exposes tools to a known set of internal services (an enterprise rolling out an internal MCP gateway), pinning client certificates at the load balancer gives you very strong identity guarantees without any application-layer auth code. The operational cost is real (certificate rotation, the user experience of distributing certificates) but the security properties are excellent. If you are reading this and thinking "we use mTLS for everything internal," you already know the answer; if you are reading this and have never deployed mTLS, do not start with your MCP server.
IP allowlists are the simplest and most rigid. The server only accepts requests from a specific list of source IPs. Useful for a single-tenant deployment where the customer has a fixed egress IP, or as a belt-and-suspenders layer on top of bearer tokens for a high-value internal deployment. The failure mode is that customer egress IPs change without warning, often when their cloud provider rebuilds a NAT gateway, and you find out from a support ticket.
The combination I recommend by default for a multi-tenant SaaS-style MCP server is bearer tokens with rotation, plus rate limiting per token, plus structured audit logging of every tool call by token ID. That gets you 95% of the security value at 10% of the implementation cost. mTLS and IP allowlists are layers you add when a specific customer's threat model or contract requires them.
Here is a thing that sounds fancy but is actually load-bearing for any MCP host that wants to survive the protocol's evolution: smart routing between protocol versions.
The MCP protocol has changed in incompatible ways since launch and will keep changing. A given customer is using a given client (Claude Desktop, Cursor, Continue, a custom integration) which speaks a specific protocol version. Your server, depending on which version of the SDK it was built against, speaks one or two versions. Without a router in the middle, every protocol bump forces every customer to upgrade their client at the same time as you upgrade your server, which is operationally impossible at any scale.
The shape of a smart-routing layer: it sits between the client's HTTP request and the server's container, identifies the protocol version from the request (via the MCP-Protocol-Version header or by handshake sniffing), maps it to a compatible server endpoint, translates the request and response shapes if necessary, and passes through. From the customer's point of view, their client just works. From the server developer's point of view, they are running one version of their server but accepting traffic from clients on three different protocol generations.
You can build this yourself. It is not hard for a single version transition. It gets harder when you have three live versions and a per-customer override for someone who is testing a beta. Most of the managed hosts in the section above ship a smart router as a feature; if you self-host, this is one of the first pieces of glue you will write the second time the protocol bumps.
Whatever you use, the principle is: the protocol layer is its own concern, and treating it as a thin proxy in front of your tool runtime is a sounder architecture than tangling protocol negotiation into your tool code. Tools change for product reasons; protocol versions change for ecosystem reasons; do not let the latter drag the former around.
If your MCP server has a database -- and a surprising number do, for caching, for per-customer configuration, for audit logs -- you need a migration story. The version of the migration story I keep seeing fail is "we apply migrations manually before each deploy."
Apply migrations automatically on application startup, before the server begins serving traffic. Use a migration tool with versioned files (drizzle-kit, prisma migrate, knex, golang-migrate, alembic). Track migration state in a dedicated table. Make migrations forward-only in production -- no auto-rollback, because rollback in a database is almost always more dangerous than forward-fix.
// On app boot, before binding the HTTP listener:
import { migrate } from "./db/migrate";
import { startServer } from "./server";
async function main() {
await migrate(); // throws if anything fails; pod crashes; deploy stalls.
await startServer();
}
main().catch((err) => {
console.error("Boot failed:", err);
process.exit(1);
});
The reason this matters: the deploy is a single atomic operation from the platform's point of view. The pod starts; if migrations succeed, the readiness probe passes and traffic ships; if migrations fail, the pod crash-loops and the platform refuses to roll the deployment forward. You get a loud, visible failure at the right time. The alternative -- deploys that succeed silently while the database is in an inconsistent state -- produces the worst kind of bug, which is the kind that only shows up under load three hours after the deploy that caused it.
The two gotchas. First, your migrations must be safe to run concurrently if you have more than one replica starting at the same time. The standard pattern is an advisory lock: the first pod to acquire the lock runs the migrations, the others wait. Most migration tools handle this; verify yours does. Second, your migrations must be backward-compatible with the previous version of the application code, because there is a window during the deploy where old pods and new pods are both running against the migrated schema. Drop columns in a follow-up release, not in the same release that stops writing them.
The minimum observability story for a production MCP server has four pieces: structured logs, distributed traces, customer-level metrics, and explicit error budgets.
Structured logs means JSON, with consistent fields across log lines. At minimum, log the customer/token ID, the session ID, the tool name, the elapsed time, and either a success indicator or the error class. Do not log the tool arguments or the tool result by default -- they can contain customer secrets, and the log volume will eat you alive. Sample the high-volume cases (successful calls) at 1-5%; log every error at 100%.
Distributed traces matter most for debugging tool calls that fan out into upstream API calls. OpenTelemetry is the floor here -- every modern tracing backend (Honeycomb, Datadog, Tempo, Jaeger) speaks it. Instrument the MCP request handler at the top of the call stack, propagate the trace context into your tool implementations, and instrument every outbound HTTP call. When a customer says "tool X is slow on Tuesdays," you want to be able to pull up the trace and see which span is the offender, not guess from logs.
Customer-level metrics matter for a managed product but also for understanding usage patterns in any deployment. Track tool calls per customer per day, per-tool error rates, and p50/p95/p99 tool-call latency broken down by customer. The p95 number is the one I check first every morning -- the average hides everything important, and the p99 is too noisy to react to. P95 tells you what your bottom-quartile customers are experiencing.
Error budgets are how you make tradeoffs between shipping new features and improving reliability. Pick a target -- 99.5% successful tool calls per month, say -- and track it. When you are within budget, ship features. When you are out of budget, the team works on reliability until you are back in budget. This is well-trodden SRE territory; the only MCP-specific note is that you should set the target on tool-call success, not on uptime, because a server that is up but returns errors is functionally indistinguishable from a server that is down.
The thing I want to flag from running a hosted product: customer-level usage metrics are also your billing substrate. Even if you do not bill on usage, you will eventually want to answer questions like "which customer is our heaviest user," "is anyone abusing the rate limit," and "what is the shape of our traffic." Build the metrics infrastructure on day one rather than retrofitting it later. The cost of recording structured per-customer counters is negligible; the cost of trying to reconstruct usage from logs three months in is not.
I started this chapter with the story of the VPS that ran for fourteen months on borrowed time. The version of me that was running that VPS would have read this chapter and thought it was a lot of ceremony for a small service. He was wrong, but only because he had not yet had the bad week that taught him otherwise.
The right shape for production hosting is the simplest configuration that survives the bad week. Containers built reproducibly on a clean host. Images pinned by digest. Secrets in secret resources. Migrations that run on boot and fail loud. A hosting target chosen because it matches the workload, not because it is what your team uses for unrelated workloads. Auth, observability, and session affinity treated as first-class concerns rather than things you bolt on after the first incident.
If you take one rule from this chapter, take this: pick the most boring option that fits, and write down the digest of what you ship. Everything else is variation.
In the next chapter we look at security review survival -- the threat model for a hosted MCP server, the questions an auditor will actually ask, and the small set of annotations and structural choices that keep the server safe to run in front of a paying customer. Hosting decisions and security decisions constrain each other in both directions: where the server runs determines half its threat model, and the threat model determines which hosting shapes are even legal. The next chapter is the other half of this one.
Take the authenticated server from Chapter 4 and make it a real internet service. Refactor to dual transport (one binary, switch on MCP_TRANSPORT=http or --http), implement session management with Mcp-Session-Id and Last-Event-ID reconnect, package as a multi-stage container with the base image pinned by digest and a non-root runtime user, deploy it to whichever platform fits your situation (Yaw MCP, Fly.io, Cloudflare Workers, ECS, your own VPS), and connect a fresh Claude Code install on a different machine to the public URL with bearer-token auth.
The bar to clear: a clean machine you have never logged into can install Claude Code, point it at your URL, present a token you generated, and call a tool. Production-shaped, even if the audience is one person.
Solution and starter code at https://github.com/YawLabs/mcp-in-production-companion, tag module-5-final. The companion repo is public -- just clone it.
Often, yes. If your server speaks stdio, do not host it at all -- publish it to npm and users run it on their own machines. For HTTP servers, Cloudflare Workers' free tier is generous enough to host a real MCP server as long as your tools are stateless with no native dependencies, and Fly.io's free tier is forgiving for small containers. What free tiers handle poorly is long-lived streaming: aggressive idle-connection timeouts can close an SSE stream mid-tool-call, so send heartbeat frames and check the platform's current limits before relying on them.
Only once you run more than one replica. The streamable HTTP transport carries an Mcp-Session-Id header, and reconnect-with-replay assumes the request lands on an instance that knows the session. With a single replica, an in-process session map works. The moment you put two replicas behind a load balancer, you must either turn on sticky sessions (ALB stickiness, Nginx ip_hash, Fly's fly-replay header) or externalize session state to a shared store like Redis. Pick one before you spin up the second replica.