Configuring MCP installations for production deployments

Kashish Hora

Kashish Hora

Co-founder of AgentCat

Try out AgentCat

Most "deploy MCP to production" advice is generic DevOps wearing an MCP hat: a Dockerfile, a Kubernetes Deployment, a Prometheus scrape config. You already know how to run a Node or Python process in a container. The parts that are actually specific to MCP, and the parts that are easy to get wrong, are narrower than that: the transport changes from stdio to Streamable HTTP, the server takes on a real authorization role defined by the spec, and a short list of MCP-specific headers and checks become load-bearing. This guide covers those, verified against the current spec revision — 2026-07-28 — and the SDKs that implement it, and skips the infrastructure you can get from any platform's docs.

The throughline: a local stdio server and a production HTTP server are not the same program with a flag flipped. They have different transports, different auth, and a different threat model. Treating production as "the dev server, but hosted" is where the real bugs come from.

The config you start from is the wrong shape for production

The first thing to unlearn is the JSON block everyone copies from a Claude Desktop tutorial:

{
  "mcpServers": {
    "my-server": {
      "command": "node",
      "args": ["/opt/mcp/server.js"],
      "env": { "API_KEY": "..." }
    }
  }
}

This is client-side config for launching a local stdio server. The command/args/env shape tells a desktop client how to spawn a subprocess on the same machine. It has nothing to do with a server you host behind a URL. A production remote server is not "launched" by the client at all; the client connects to it over HTTP and authenticates. So the moment you move to production, this config stops being relevant. What replaces it is a transport setup in your server code plus an OAuth relationship, both described below.

The two transports the spec defines are stdio and Streamable HTTP, and nothing else. (MCP transports, 2026-07-28) stdio is for local subprocesses. Production remote servers use Streamable HTTP.

Transport: Streamable HTTP, single endpoint, the right headers

Streamable HTTP replaced the old two-endpoint HTTP+SSE transport in the 2025-03-26 spec revision. If a tutorial has you stand up a separate /sse endpoint and a separate POST endpoint, or sets transport="sse", it is describing the deprecated transport; the SSE transport is back-compat only. (MCP transports, 2026-07-28) The current shape is one endpoint that accepts POST:

"The server MUST provide a single HTTP endpoint path (hereafter referred to as the MCP endpoint) that supports POST. For example, this could be a URL like https://example.com/mcp." (Streamable HTTP, 2026-07-28)

That single word — POST, not "both POST and GET" — is the part worth flagging if you deployed before July 2026. Revisions 2025-03-26 through 2025-11-25 also required a GET on the same path, and that GET stream was how a server pushed unsolicited messages back at a client. The 2026-07-28 revision removed it. Every JSON-RPC message is now its own POST; the server answers each one with either a single application/json body or a text/event-stream stream scoped to that request; and on any stream the server "MUST NOT send independent JSON-RPC requests." Long-lived change notifications come from a client-initiated subscriptions/listen request whose response stream stays open, and anything the server needs from the client — sampling, elicitation, roots — comes back embedded in the result as a Multi Round-Trip Request instead of a server-initiated call. A server on this revision that receives a GET or DELETE on the MCP endpoint SHOULD answer 405 Method Not Allowed. (Streamable HTTP, 2026-07-28)

Headers do real work in production and are the usual source of "it works locally, breaks behind the proxy" bugs. Three matter:

  • MCP-Protocol-Version. The client sends this on every POST, starting with the first one — there is no initialization phase to send it "after" any more. It mirrors the io.modelcontextprotocol/protocolVersion field in the request body's _meta, and if the two disagree the server "MUST reject the request with 400 Bad Request and a HeaderMismatch JSON-RPC error" (-32020). An unsupported version is also a 400, carrying an UnsupportedProtocolVersionError that lists what the server does support. If your load balancer or framework strips unknown headers, every request fails. (Streamable HTTP, 2026-07-28)
  • Mcp-Method and Mcp-Name. New in 2026-07-28 and REQUIRED. Mcp-Method carries the JSON-RPC method on every request; Mcp-Name carries params.name or params.uri on tools/call, resources/read, and prompts/get. They exist so intermediaries can route, rate-limit, and observe without parsing the body, and any server that reads the body must validate that they match it — a mismatch, or a missing required header, is 400 plus -32020. Add both to the header allow-list at your edge. (Streamable HTTP, 2026-07-28)
  • Accept. Clients send Accept: application/json, text/event-stream. If a caching layer or gateway rewrites Accept, streaming responses can break in ways that look like random hangs.

Mcp-Session-Id used to be on that list, and if you are maintaining a server written against an earlier revision you will find it there. It is gone: protocol-level sessions were removed in 2026-07-28, and a server on this revision should simply ignore the header when an older client sends one. Last-Event-ID goes the same way — SSE resumability was removed, so a broken stream is not resumed, it is re-issued as a new request with a new request ID.

One feature you should not carry forward: JSON-RPC batching. It was added in 2025-03-26 and removed in 2025-06-18. Code that bundles multiple JSON-RPC messages into one array to "optimize throughput" is implementing a feature the current spec no longer has.

Serve both eras, because your clients haven't moved

The 2026-07-28 revision landed on 28 July 2026, and essentially every shipping client still speaks 2025-11-25 or earlier. A server that implements only the new revision fails against all of them — a legacy client's initialize gets rejected, and over HTTP its requests are missing the now-required headers and _meta fields, so they 400. The practical answer is a dual-era server: one that serves the per-request-metadata shape and still accepts the older initialize handshake, selecting behavior from how the client opens. All Tier 1 SDKs (TypeScript, Python, Go, C#) ship dual-era support, so in production this is usually a default you keep rather than code you write. (MCP versioning, 2026-07-28)

Statelessness and horizontal scaling

Session affinity used to be the hard part of running MCP over HTTP: a session ID pinned a client to whichever replica held its session in memory, so "just add more replicas" meant sticky routing or a shared session store. That problem is gone, because statelessness is no longer a mode you opt into:

"The Model Context Protocol (MCP) is a stateless protocol: all the information needed to process a request is contained in the request itself. A server processes each request independently; no state should be inferred from previous requests, even those on the same connection or stream." (MCP base protocol, 2026-07-28)

Concretely: servers "MUST NOT rely on prior requests over the same connection to establish context (e.g., capabilities, protocol version, client identity)" — every request supplies its own io.modelcontextprotocol/protocolVersion and io.modelcontextprotocol/clientCapabilities in _meta — and state that has to span requests "MUST be referenced by an explicit identifier the client passes on each request." That last rule is the spec's non-normative Stateful Tools pattern: a creation tool returns an opaque, server-minted handle, and later tools accept that handle as an ordinary argument. Your cart ID or workflow ID lives in the tool schema, not in the transport.

For deployment that means no sticky routing, no shared session store, and no session-affinity annotations on your ingress. Any replica can answer any request; scale on CPU and let the load balancer round-robin. Whatever cross-request state you keep lives in your own database, keyed by a handle you minted and bound to the authenticated caller (see State handle hijacking, below).

The SDK surface follows. In the TypeScript 2.0.0 packages, createMcpHandler() from @modelcontextprotocol/server is the HTTP entry point; it serves 2026-07-28 per request and, on its default legacy: 'stateless' setting, serves 2025-era traffic statelessly too. (typescript-sdk, supporting 2026-07-28) In the Python SDK the class is MCPServer, imported from mcp.server.mcpserver, and the transport switches moved off the constructor onto run():

from mcp.server.mcpserver import MCPServer

mcp = MCPServer("my-server")

if __name__ == "__main__":
    mcp.run(transport="streamable-http", json_response=True, stateless_http=True)

In mcp 1.x that class was spelled FastMCP and imported from mcp.server.fastmcp, with stateless_http and json_response passed to the constructor; in 2.0.0 the old import path is removed rather than deprecated, and the positional argument order changed, so a mechanical rename is not enough. (python-sdk migration guide)

Origin validation and DNS rebinding: not optional

This is the single most common production-security miss for HTTP MCP servers, and the spec states it as a MUST:

"Servers MUST validate the Origin header on all incoming connections to prevent DNS rebinding attacks." (Streamable HTTP, 2026-07-28)

The attack it prevents is concrete. A malicious web page the user visits can re-point a hostname it controls at 127.0.0.1 (DNS rebinding) and then make requests to an MCP server listening on localhost, bypassing the same-origin policy and invoking your tools as the user. The spec's mitigations are: validate Origin and answer 403 Forbidden when a present Origin is invalid, bind to 127.0.0.1 rather than 0.0.0.0 when running locally, and authenticate connections. (Streamable HTTP, 2026-07-28)

The trap is that the official TypeScript SDK did not enable this protection by default. CVE-2025-66414 (CVSS 7.6) covers exactly this: versions of @modelcontextprotocol/sdk before 1.24.0 ship StreamableHTTPServerTransport (and the legacy SSEServerTransport) with DNS rebinding protection off unless you opt in. (GHSA-w48q-cv73-mx4w / CVE-2025-66414) Upgrade to at least 1.24.0 and turn it on explicitly:

// Legacy line: @modelcontextprotocol/sdk 1.x (1.30.0 is current and still npm `latest`),
// which is what most shipping clients connect to today.
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";

const transport = new StreamableHTTPServerTransport({
  sessionIdGenerator: undefined, // no protocol sessions
  // Off by default before 1.24.0 (CVE-2025-66414). Turn it on.
  enableDnsRebindingProtection: true,
  allowedHosts: ["mcp.example.com"],
  allowedOrigins: ["https://app.example.com"],
});

The enableDnsRebindingProtection, allowedHosts, and allowedOrigins options are that line's surface for the spec's Origin requirement. On the 2.0.0 packages the same check is middleware rather than a transport option: hostHeaderValidation() and localhostHostValidation() moved to @modelcontextprotocol/express with their (allowedHostnames: string[]) signature unchanged, and the framework-agnostic validateHostHeader, localhostAllowedHostnames, and hostHeaderValidationResponse helpers are exported from @modelcontextprotocol/server. (typescript-sdk, upgrade to v2) Either way, if TLS terminates at a load balancer (the usual setup) the server sees plain HTTP from the LB, so make sure the LB forwards the real Origin/Host and that your allow-lists reflect the public hostname, not the internal one.

Authorization: your server is an OAuth 2.0 Resource Server

This is the part most "production MCP" writing gets wrong, because it predates the model. Authorization is optional in the spec, but when an HTTP server does authenticate, the spec defines exactly how, and it is not "bring your own JWT middleware." A protected MCP server is an OAuth 2.1 resource server. (MCP authorization, 2026-07-28) Three obligations follow, all MUSTs:

1. Advertise your authorization server via Protected Resource Metadata. "MCP servers MUST implement OAuth 2.0 Protected Resource Metadata (RFC9728). MCP clients MUST use OAuth 2.0 Protected Resource Metadata for authorization server discovery." On a 401, the server returns a WWW-Authenticate header pointing at its resource-metadata URL, and serves that metadata (listing the authorization server) at /.well-known/oauth-protected-resource. (MCP authorization, 2026-07-28) The client reads the metadata, discovers the authorization server, runs the OAuth flow, and comes back with a token. You implement the resource-server half; the authorization server can be a separate identity provider.

2. Validate that the token was issued for you. This is the requirement the older generic-OAuth advice gestures at without naming. The spec is blunt:

"MCP servers MUST validate that access tokens were issued specifically for them as the intended audience, according to RFC 8707 Section 2." And: "MCP servers MUST only accept tokens that are valid for use with their own resources. MCP servers MUST NOT accept or transit any other tokens." (MCP authorization, 2026-07-28)

Clients implement the matching half via RFC 8707 Resource Indicators: they send a resource parameter (the canonical URI of your server, e.g. https://mcp.example.com/mcp) on both the authorization and token requests, so the issued token is bound to your audience. (MCP authorization, 2026-07-28) Your job on the server is to verify that audience on every request and 401 anything that doesn't match. With sessions gone, this is the only thing establishing who a caller is: every request carries its own Authorization header and gets its own independently derived auth context.

3. Never pass the client's token through to an upstream API. If your server calls a third-party API on the user's behalf, it acts as an OAuth client to that API and uses a separate token. The spec forbids forwarding the inbound token:

"MCP servers MUST NOT accept any tokens that were not explicitly issued for the MCP server." (MCP security best practices, 2026-07-28)

Token passthrough is named and forbidden because it defeats audience binding and turns your server into a confused deputy: the downstream API sees a token it can't attribute to your server, your rate limits and audit trail are bypassed, and a stolen token works across services. (MCP security best practices, 2026-07-28)

A note on registration, because this is where the 2026-07-28 revision moved the ground. Dynamic Client Registration (RFC 7591) used to be the recommended path; it is now formally Deprecated, with Client ID Metadata Documents in its place. The spec's own framing: authorization servers and MCP clients "SHOULD support OAuth Client ID Metadata Documents," and "MAY support the OAuth 2.0 Dynamic Client Registration Protocol (RFC7591). Note that Dynamic Client Registration is deprecated and retained for backwards compatibility with authorization servers that do not support Client ID Metadata Documents." (MCP authorization, 2026-07-28) Under CIMD a client's client_id is an HTTPS URL pointing at a JSON metadata document it controls, which the authorization server fetches on demand instead of every client pre-registering. DCR keeps working — the deprecation policy guarantees at least twelve months — but if you're building the authorization-server side, design for CIMD plus pre-registration rather than relying on open DCR.

For stdio servers, none of this applies. The spec says implementations using stdio "SHOULD NOT follow this specification, and instead retrieve credentials from the environment." (MCP authorization, 2026-07-28) That is the one place the local-style "secrets in env vars" pattern is correct, and it's worth saying out loud because so much production advice tries to bolt OAuth onto a stdio process.

The threats the spec actually names

The security best practices document is the most MCP-specific thing in this whole topic, and it names attack classes worth designing against rather than generic "implement security." The ones that bite production HTTP servers:

State handle hijacking. This is what the old "session hijacking" advice turned into. Because "MCP is stateless and has no protocol-level sessions," a server that needs state across calls mints an explicit handle — a cart ID, a workflow ID — and takes it back as an ordinary tool argument, which makes the handle the thing an attacker will try to guess or steal. Three rules: servers "MUST NOT treat possession of a state handle as authentication"; handles SHOULD be non-deterministic, generated with a secure RNG, and ideally expiring; and handles SHOULD be bound server-side to the authenticated user, "for example by keying stored state as <user_id>:<handle> where the user ID is derived from the verified token rather than supplied by the client." (MCP security best practices, 2026-07-28) Authorization is re-derived per request from the token on that request; the handle is a lookup key, never a credential. This is exactly the detail that goes wrong when you move state into Redis and key it on the handle alone.

Confused deputy. If your server proxies a third-party API and uses a static client ID with that API's authorization server while letting MCP clients register dynamically, a consent cookie from a legitimate flow can let an attacker skip the consent screen and capture an authorization code. The mitigation is per-client consent enforced before forwarding to the third party, exact redirect_uri matching, and single-use state. (MCP security best practices, 2026-07-28)

SSRF in OAuth discovery. A malicious server can hand a client metadata URLs that point at internal addresses (http://169.254.169.254/ for cloud metadata, private ranges, localhost services). Clients deployed server-side "MUST consider SSRF risks": enforce HTTPS, block private/loopback/link-local ranges, validate redirect targets, and prefer an egress proxy over hand-rolled IP parsing. (MCP security best practices, 2026-07-28) This matters if your production deployment is itself an MCP client (a gateway or an agent calling other MCP servers) — and, as of 2026-07-28, if you run an authorization server that accepts Client ID Metadata Documents, since accepting a CIMD means fetching a URL supplied by an unknown client.

Scope minimization. Don't publish every scope in scopes_supported or hand out omnibus scopes; start clients on a minimal baseline and elevate incrementally via WWW-Authenticate scope= challenges, so a leaked token has a small blast radius. (MCP security best practices, 2026-07-28)

None of these are things a generic web-app checklist will surface, and they're the durable, MCP-specific core of "securing a production deployment."

Secrets, TLS, and the infrastructure you already know

The genuinely MCP-specific guidance on secrets is short: on stdio, pass credentials through the environment (that's what the spec calls for); on HTTP, the credential is the OAuth token, validated per request, and you generally are not putting long-lived API keys in client config at all. Beyond that, MCP servers are ordinary network services, so the rest is standard practice rather than anything MCP invents:

  • Terminate TLS at your load balancer or ingress; OAuth-related URLs are expected to be HTTPS in production, with http:// reserved for loopback addresses during development. (MCP security best practices, 2026-07-28)
  • Keep server-side secrets (database credentials, upstream API keys) in your platform's secret manager and inject them at runtime; never commit them.
  • Run the container as a non-root user, set resource limits, and add the liveness/readiness probes your orchestrator expects.

These are real, but they are not different for MCP than for any other service, so reach for your platform's documentation rather than an MCP-flavored restatement of it. The MCP-specific health-check concern (is the protocol actually responding, not just the port) is covered in implementing connection health checks, and the scaling specifics (load balancing Streamable HTTP, stateless scale-out) in building a Streamable HTTP MCP server.

A production readiness pass

Before you call an MCP server production-ready, the checks that are specific to MCP and easy to miss:

  • Transport is Streamable HTTP on a single POST endpoint, not the deprecated two-endpoint HTTP+SSE, and not transport="sse". GET and DELETE on that path answer 405.
  • MCP-Protocol-Version, Mcp-Method, Mcp-Name, and Accept survive your proxy and load balancer intact, and the header values still match the request body when the server validates them.
  • The server is dual-era: it serves 2026-07-28 and still accepts the initialize handshake from the many clients that haven't moved yet.
  • Origin validation is on, answering 403 on a bad origin. On the legacy TypeScript line that means @modelcontextprotocol/sdk >= 1.24.0 with enableDnsRebindingProtection: true and correct allowedHosts/allowedOrigins (CVE-2025-66414); on the 2.0.0 packages it means the host-validation middleware.
  • If the server authenticates, it serves Protected Resource Metadata, returns WWW-Authenticate on 401, and validates the token audience on every request.
  • The server never forwards a client's token to an upstream API; it uses its own token there.
  • Any cross-request state handles are unguessable, bound server-side to the authenticated user, and never treated as authentication.
  • Scopes are minimal, not an omnibus grant.

Once it's serving real clients, the questions turn operational: which tools actually get called, what arguments clients send, where calls error or stall, and whether a client is repeatedly hitting 401s because its token audience is wrong. That visibility across a fleet of production MCP servers is the gap AgentCat fills.