Deploying an MCP Server on EC2 After the Stateless Spec Change
Moving an MCP server from localhost to EC2: streamable HTTP, nginx and bearer auth, then what stateless sessions and OAuth 2.1 mean for a real deployment.
This article is published in English.
Sources: MCP Servers Explained: A Complete Guide to What I Learned Deploying One to AWS EC2
From localhost to a real server
Tutorials about MCP tend to stay abstract. They show a client drawn next to a server, compare the protocol to a USB-C port, or run a five-line example on localhost that never leaves the developer's laptop. The real work starts after that: a tool written locally has to live on an actual server, behind an actual reverse proxy, where an actual client can call it across the public internet. It's a distinct problem, and it gets little coverage.
The deployment described here starts with a local MCP server, relocates it to an EC2 instance and serves it through the streamable HTTP transport, with nginx demanding a bearer token up front. It ran fine. Shortly afterwards the protocol moved under it. On July 28, 2026, the largest revision of the MCP specification so far went from release candidate to final, and it turns the protocol's core stateless. Teams often tick off "MCP support" as a single item, but it breaks into three choices: transport, auth and, since this update, scaling. A wrong call on any of them is what separates a demo-grade tool from one worth trusting in production.
Coverage runs through several questions: which parts of the problem MCP defines and which it leaves open, why streamable HTTP superseded the SSE-based transport, how nginx and a bearer token expose a local server on EC2, what the stateless rewrite changes, why a static token counts as having auth yet falls short of spec-compliant auth, and which parts of the deployment had to change to keep pace.
What MCP does and doesn't standardize
On paper, the Model Context Protocol pins down a single thing: the way an AI application finds and invokes tools, and reads resources, offered by a separate server process, through JSON-RPC messages carried over a defined transport. Tool logic, agent design and deployment all sit outside it. It's a standard for connecting, not a framework. Previously, each AI application wanting access to something like an internal API required a bespoke integration. Under MCP, one server serves every compliant client.
Seen that way, it's clear why deployment causes the difficulty. The protocol describes message structure and stays silent on whether the server can be reached from only one laptop or from anywhere, whether anything guards it, and whether it copes with several clients at once. The operator owns every one of those questions.
Why a public deployment is different
- Local servers expose almost nothing, public ones expose plenty. With a public URL, the server becomes an authenticated API like any other, so token handling and input validation matter just as much.
- Session and scaling rules were murky before. Guessing wrong produced either a server that failed quietly under concurrent load or one burdened with sticky sessions it never needed.
- The spec isn't standing still. A deployment built on six-month-old assumptions can drift out of compliance without a single error, because clients just negotiate down to an older protocol revision.
The architecture looks like this.
AI Client ── HTTPS POST ──▶ nginx (TLS termination, auth check, reverse proxy)
│
▼
MCP Server Process
(Streamable HTTP transport)
│
┌────────────┴────────────┐
Tools Resources
One principle guides all of this. After an MCP server gets a public URL, handle it like any authenticated API exposed from EC2, since structurally it is one. MCP is merely the message format on top, while the security and scaling concerns below are identical to those of any backend.
1. Streamable HTTP transport
Streamable HTTP is one HTTP endpoint that takes POST requests carrying JSON-RPC messages. It took over from the earlier HTTP+SSE transport, which needed a persistent server-sent-events stream next to regular HTTP calls.
One POST endpoint is familiar territory for any reverse proxy, load balancer or API gateway, so there's no struggle to keep a long-lived stream open through nginx or an ALB. Traffic looks like a regular HTTP API, and the nginx location block stays short.
location /mcp {
proxy_pass http://127.0.0.1:8000;
proxy_set_header Host $host;
proxy_set_header Authorization $http_authorization;
proxy_http_version 1.1;
proxy_read_timeout 300s;
}
A tip for production: even with a single-endpoint transport, keep the proxy's read timeout generous. Individual tool calls can legitimately run longer than a typical API request, and an overly tight timeout kills valid long-running calls along with the genuinely hung ones.
2. Bearer-token auth
In this scheme a fixed token sits in the Authorization header and is verified before the request gets near the MCP server process, whether in nginx or in application middleware. For anything on a public URL, it's the least security acceptable, and a reasonable first step. Yet the token never expires, isn't tied to any particular server and offers no protection if it ends up in a log. It tells you only that a request carries a credential.
A FastAPI middleware sketch begins with the imports.
from fastapi import Request, HTTPException
Then comes the check itself.
VALID_TOKEN = "your-rotated-secret-token"async def verify_bearer(request: Request):
auth = request.headers.get("authorization", "")
if auth != f"Bearer {VALID_TOKEN}":
raise HTTPException(status_code=401, detail="Unauthorized")
Another tip: keep the raw Authorization header out of access logs. Default nginx and application log formats make it easy to miss, and once it's there, the log file becomes a leaked credential.
3. Statelessness, the core of the July 2026 rewrite
With the 2026-07-28 specification, protocol-level sessions disappear. There's no Mcp-Session-Id header anymore, and the transport also drops the long-lived GET stream that earlier versions used to keep client and server aligned through a conversation. Every request carries everything it needs.
Scaling is the point. The older model let a server remember things about a client across calls, and once several instances sat behind a load balancer, that memory demanded sticky sessions. Without protocol-level state, any instance may handle any request, which gives genuine horizontal scaling and frees clients from being pinned to one machine.
In practice, a server that kept in-memory state about a client between calls will break under the new spec. Whatever state matters should move into the request payload or an external store such as Redis that every instance can read.
4. Multi round-trip requests
Even a stateless protocol needs some way for a server to put a question to the client partway through a tool call, such as asking for confirmation or a missing parameter. The new spec solves it with Multi Round-Trip Requests. Rather than keeping a stream open, the server answers with an InputRequiredResult that lists its questions along with an opaque requestState value. The client gathers replies and sends the original call again, adding those replies and returning the state unchanged.
This is what makes true statelessness workable. Since everything the server needs to resume travels in the payload the client sends back, any instance, not necessarily the one that began the call, can take the retry and finish it.
Clients should leave requestState alone: no inspecting, no editing. Keep it and hand it back exactly as it arrived, or a resume that relies on its integrity may fail.
5. Authorization hardening
The revised spec steers remote MCP servers to OAuth 2.1 with PKCE and requires resource indicators (RFC 8707), which tie an issued token to the one server it was intended for.
Replay is the concern. If a static bearer token leaks, any server that accepts it can be targeted. A token bound to a resource closes that hole, so one issued for one MCP server won't work on another, even if both rely on the same identity provider.
A bearer-token setup, which is where many people start, meets the bar of "this endpoint requires authentication". It falls short of the interoperability and replay protection the new spec expects of a compliant remote server, and the gap deserves closing before the URL reaches anyone outside the core team.
6. Deprecation policy and extensions
Capabilities in the spec now follow a formal lifecycle of Active, Deprecated and Removed, with at least twelve months separating deprecation from removal. New features, such as server-rendered UI or long-running tasks, arrive first as opt-in Extensions instead of going straight into the core. Some older features, among them roots, sampling and logging in their original form, are heading down the deprecation path in favor of replacements.
For operators, that means a working integration won't be broken overnight by a future revision, and new capabilities can be adopted deliberately and not under pressure to keep pace with everything the spec adds.
An end-to-end walkthrough
The path from a local prototype to a spec-current EC2 deployment looks like this:
- Local testing. The server runs over stdio against a local client, with no network and no auth, so iteration is fast.
- Switch to streamable HTTP. The server is reconfigured to use the streamable HTTP transport and deployed to an EC2 instance.
- nginx in front. nginx terminates TLS, checks the bearer token before anything reaches the MCP process, and then reverse-proxies the request.
- A plain tool call. One POST carries a JSON-RPC request, and the result comes back in that same request and response.
- Version negotiation. With the 2026-07-28 spec final, client and server negotiate a protocol revision. A client that hasn't upgraded falls back to 2025-11-25 automatically and doesn't fail outright.
- More input is needed. Rather than leaving the connection open, the server replies with an
InputRequiredResultlisting what it still has to ask. - Resuming. The client repeats the call with answers attached and
requestStateechoed back. The retry has no dependency on a particular instance, so any machine behind nginx can take it. - Auth failures. Requests with no token or a bad one get turned away at nginx. The application code never encounters them.
Special cases
Slow operations. Work that once relied on a stream held open for the duration should switch to the Tasks extension. Recreating a persistent connection on a transport built without one is the wrong direction.
Tools that will never go remote. A server that only ever runs locally beside its client is best served by stdio, and almost none of the deployment concerns here apply.
Mixed-version clients. Because older clients fall back to 2025-11-25 automatically, one deployment can reasonably serve both protocol revisions for a while, so not every client has to upgrade in lockstep with the server.
Scaling and production challenges
Multiple instances. Under the session model, keeping a client tied to the instance that knew it meant sticky sessions or shared session storage. That need is gone, and for anyone operating more than one box, it's the main benefit of the rewrite.
TLS across a growing fleet. Handling a certificate on every EC2 instance gets unwieldy quickly. A managed load balancer holding an ACM certificate in front of the fleet costs much less effort than nginx TLS configuration on each box.
Token issuance and rotation. Static bearer tokens have a security ceiling, and they're also an operational headache beyond a couple of clients, because each rotation requires manual coordination. OAuth 2.1-based issuance fixes the security gap and the rotation burden together.
Tracing across calls without server-side sessions. With no session linking a sequence of calls on the server, correlation IDs carried in the request payload become the only dependable way to reconstruct what a single agent interaction did across multiple calls.
Handling the resume on the client
The nginx config and bearer-check middleware cover transport and baseline auth. Here's a minimal pattern for the client's side of a Multi Round-Trip Request.
async def call_tool_with_resume(client, tool_name, params):
result = await client.call_tool(tool_name, params)
if result.get("type") == "InputRequiredResult":
answers = collect_answers(result["questions"])
return await client.call_tool(
tool_name,
{**params, "answers": answers, "requestState": result["requestState"]},
)
return result
What counts is the last line, where requestState goes back exactly as the server supplied it.
Common pitfalls
- Treating "deployed" as "production-ready." nginx plus a bearer token clears the lowest bar, not the whole bar. Hold the MCP endpoint to the standard of any public API, with proper auth, careful logging and rate limiting before the URL is widely shared.
- Leaning on server memory. A few early deployments silently counted on remembering a client from a prior request. Plan on any request reaching a different instance than the previous one, since the current spec allows exactly that.
- Pinning a single protocol version. A client locked to one revision stops working when a server upgrades. Build in negotiation with a sensible fallback, and one side's upgrade won't knock out the other.
- Shrugging off auth for an internal tool. A tool that's internal now may be handed to a partner team later, and at that point a static token isn't enough. When the server might plausibly leave a laptop or VPC, aim for OAuth 2.1 and resource indicators from day one.
Practices that hold up in production
- Avoid state that your infrastructure can't scale. Anything that has to survive between calls belongs in the payload or an external store.
- Tie each token to its intended resource, because a token accepted by every server you trust will sooner or later get used where it shouldn't.
- Negotiate protocol versions and don't assume one. Clients and servers rarely upgrade on the same day, so plan for the gap.
- Check auth at the edge. When nginx turns away a bad request, the MCP process stays simpler and exposes less.
- Leave opaque fields alone.
requestStateand its kind exist so the server can rely on what returns, so clients shouldn't read or change them.
Where this leaves deployment
MCP in theory is simple. Deployment holds the decisions that matter, and the July 28, 2026 spec update altered them. Streamable HTTP is easier to run behind ordinary infrastructure, statelessness makes adding an EC2 instance a realistic option instead of a session-management project, and the authorization model now fits a public-facing server. None of it is hard once seen, and it's the piece that theoretical write-ups skip.