Tutorials Builder
How to deploy a remote MCP server with Streamable HTTP
Move your MCP server off stdio to Streamable HTTP and deploy it to Cloudflare Workers, Fly.io or a VPS — so your team, Claude and ChatGPT can connect.
A stdio server lives and dies on one machine. That is exactly right while you are learning — no ports, no TLS, no auth surface. But the moment you want a second person to use your server, stdio stops scaling: every teammate has to clone the repo, install the runtime, and fix their own path issues. And some clients never run local processes at all — ChatGPT connectors only accept a remote URL.
Remote solves all three problems at once: one deployment, one URL, zero local install for every client that connects.
graph LR
subgraph "Local (stdio)"
A[Client] -->|stdin/stdout| B[Server process per machine]
end
subgraph "Remote (Streamable HTTP)"
C[Your client] -->|HTTPS POST /mcp| D[One server, one URL]
E[Teammate's client] -->|HTTPS POST /mcp| D
F[ChatGPT connector] -->|HTTPS POST /mcp| D
end
Step 1 — Know which HTTP transport you are targeting
There have been two remote transports, and old tutorials still teach the wrong one.
The original HTTP+SSE transport (spec 2024-11-05) used two endpoints — a GET /sse stream plus a separate POST endpoint. It was deprecated in spec 2025-03-26, replaced by Streamable HTTP: a single endpoint (conventionally /mcp) that accepts POST for messages and can optionally stream responses back. As of spec 2025-11-25, Streamable HTTP is the only remote transport you should build on.
If you followed an older guide, the migration signs are:
| You have | Replace with |
|---|---|
SSEServerTransport and a /sse route (TypeScript) | StreamableHTTPServerTransport on a single /mcp route |
mcp.run(transport="sse") (Python) | mcp.run(transport="streamable-http") |
Client config pointing at https://…/sse | https://…/mcp with transport http |
Clients still ship an sse option purely for legacy servers — do not use it for anything new.
Step 2 — Migrate the Python server
With the official SDK (mcp package, stable 1.28.1), FastMCP makes this a one-line change plus host/port settings:
from mcp.server.fastmcp import FastMCP
mcp = FastMCP(
"team-tools",
host="0.0.0.0", # bind for containers/VMs; keep 127.0.0.1 for local-only
port=8000,
stateless_http=True, # see Step 4 — design stateless from day one
)
@mcp.tool()
def ping() -> str:
"""Confirm the server is reachable."""
return "pong"
if __name__ == "__main__":
mcp.run(transport="streamable-http")
Your tools do not change at all — transports are deliberately independent of tool logic. Run it and the server listens on http://0.0.0.0:8000/mcp. Note the /mcp path: FastMCP mounts the endpoint there by default, and forgetting it in client config is the single most common connection failure (see Troubleshooting).
Test locally with the Inspector before deploying anything:
npx @modelcontextprotocol/inspector
# in the UI: transport "Streamable HTTP", URL http://127.0.0.1:8000/mcp
Step 3 — Migrate the TypeScript server (stateless mode)
With @modelcontextprotocol/sdk (stable 1.29.0), swap StdioServerTransport for StreamableHTTPServerTransport behind any HTTP framework — Express here. In stateless mode you create a fresh server + transport per request, which is exactly what serverless platforms want:
import express from "express";
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
const app = express();
app.use(express.json());
app.post("/mcp", async (req, res) => {
const server = new McpServer({ name: "team-tools", version: "1.0.0" });
server.registerTool(
"ping",
{ description: "Confirm the server is reachable" },
async () => ({ content: [{ type: "text", text: "pong" }] })
);
const transport = new StreamableHTTPServerTransport({
sessionIdGenerator: undefined, // undefined = stateless mode
});
res.on("close", () => {
transport.close();
server.close();
});
await server.connect(transport);
await transport.handleRequest(req, res, req.body);
});
app.listen(3000);
sessionIdGenerator: undefined is the whole stateless switch: no Mcp-Session-Id header is issued, so any instance of your server can answer any request. That costs you nothing for typical tool servers and buys you horizontal scaling for free.
Step 4 — Validate the Origin header (this is not optional)
A remote MCP server on a reachable port is a DNS-rebinding target: a malicious web page can trick a browser into sending requests to your server with the browser’s network position. The spec’s defence is Origin validation — as of spec 2025-11-25, servers must validate the Origin header and respond HTTP 403 to anything unexpected.
In the TypeScript SDK, the transport has it built in:
const transport = new StreamableHTTPServerTransport({
sessionIdGenerator: undefined,
enableDnsRebindingProtection: true,
allowedHosts: ["mcp.example.com"],
});
For a Python server behind a reverse proxy, enforce it at the proxy — for example in nginx:
location /mcp {
if ($http_origin !~* "^(|https://app\.example\.com)$") {
return 403;
}
proxy_pass http://127.0.0.1:8000;
}
(Most non-browser MCP clients send no Origin at all, which is why the empty match is allowed.) Also bind to 127.0.0.1 behind the proxy rather than exposing 0.0.0.0 directly. The point is not the exact snippet — it is that some layer must reject unexpected origins before your server logic runs.
Step 5 — Sessions today, stateless tomorrow
Streamable HTTP as specified in 2025-11-25 supports stateful sessions: the server may return an Mcp-Session-Id header on initialization, and the client echoes it on every subsequent request. That is how a server keeps per-connection state.
Here is why you should mostly ignore that feature: the 2026-07-28 release candidate (finalising July 28, 2026) moves MCP to a stateless core — the initialize handshake and Mcp-Session-Id are removed entirely. Servers built around session state will need rework; servers built stateless will not notice. If a tool needs continuity across calls, key it on something the caller passes explicitly (a resource ID, a user ID from the auth token) rather than on transport-level session identity. Both code samples above are already stateless for exactly this reason.
Step 6 — Choose where to deploy
Honest comparison, having deployed to all three:
| Option | Best for | Effort to first URL | Watch out for |
|---|---|---|---|
| Cloudflare Workers | TypeScript servers, fastest zero-to-URL, generous free tier | Minutes — npm create cloudflare --template=remote-mcp-authless gives a working remote server | It is a Workers runtime, not Node — some npm packages won’t run; Python servers are a poor fit |
| Fly.io | Python servers, anything with a Dockerfile | ~15 min — fly launch detects your Dockerfile, gives you TLS + a URL | Fewer free resources than before; you manage the container image |
| VPS + Docker | Full control, internal networks, strict data-residency needs | Longest — you own TLS (Caddy/nginx), updates, monitoring | Everything is your problem, including the Origin validation from Step 4 |
Verdict: for a TypeScript server, start on Cloudflare Workers — the remote-mcp-authless template is the fastest honest path to a live URL, and Cloudflare’s remote MCP guide (developers.cloudflare.com/agents/guides/remote-mcp-server) covers the details. For Python, Fly.io or a VPS with the Dockerfile below:
FROM python:3.12-slim
WORKDIR /app
COPY . .
RUN pip install "mcp[cli]"
EXPOSE 8000
CMD ["python", "server.py"]
Note there is no -i anywhere: that flag matters for stdio servers in Docker, which need stdin kept open. An HTTP server needs the opposite — a published port (docker run -p 8000:8000) and a 0.0.0.0 bind.
Step 7 — Connect your clients
Claude Code — one command, note --transport http:
claude mcp add --transport http team-tools https://mcp.example.com/mcp
claude mcp list # verify it shows as connected
Cursor — .cursor/mcp.json in your project (or the global one):
{
"mcpServers": {
"team-tools": {
"url": "https://mcp.example.com/mcp"
}
}
}
ChatGPT — Settings → Connectors → Create, paste the URL. This is the client that forced you remote: it cannot run local processes at all. An authless server like today’s build is only usable in ChatGPT’s developer mode; production connectors expect OAuth 2.1 with dynamic client registration, which is the next tutorial.
Claude Desktop / claude.ai — remote servers attach as connectors in Settings rather than through claude_desktop_config.json, same URL.
Troubleshooting
Origin validation is rejecting the caller — either the SDK’s DNS-rebinding protection (HTTP 403 Forbidden on every request, works with curl but not from a browser-based clientallowedHosts doesn’t include your public hostname) or your reverse-proxy rule. Add the real hostname/origin to the allowlist. Do not fix this by disabling the check; it exists to stop DNS-rebinding attacks (Step 4).
The URL is missing the endpoint path. Streamable HTTP serves on a specific route — 404 Not Found when the client connects, but the server is clearly running/mcp by convention and by default in both SDKs — so the client URL must be https://host/mcp, not https://host/. Check with: curl -i -X POST https://host/mcp -H “Content-Type: application/json” — a 4xx JSON-RPC complaint means the endpoint is there; an HTML 404 means your path or proxy route is wrong.
Almost always a transport mismatch: the client is speaking legacy SSE to a Streamable HTTP server, or vice versa. In Claude Code, check you used MCP error -32000: Connection closed (or endless reconnects) right after adding the server—transport http, not —transport sse; remove and re-add if needed (claude mcp remove team-tools). If you migrated from an old tutorial, also confirm the URL ends in /mcp, not /sse.
Server worked in Docker locally via stdio, but the deployed container never responds
You are mixing the two Docker modes. Stdio servers need docker run -i and no ports; HTTP servers need no -i but do need -p 8000:8000 (or the platform’s port config) and the app bound to 0.0.0.0, not 127.0.0.1 — inside a container, localhost-only binds are unreachable from outside.
Works for you, times out for teammates
Usually a corporate proxy or firewall dropping long-lived streaming responses. Streamable HTTP degrades gracefully — plain POST/response works — but if your platform buffers responses (some proxies buffer aggressively), disable buffering for the /mcp route (proxy_buffering off; in nginx).
Next step
Your server is live at a URL, which means anyone who finds that URL can call your tools. Time to fix that: the next tutorial adds OAuth 2.1 the way the spec intends — without hand-rolling an authorization server.
Next in this learning path How to add OAuth to an MCP server, step by stepWas this guide useful?
Thanks — noted. It shapes what gets written next.
newsletter
One practical MCP guide in your inbox. No news, no hype.
Tutorials and decision frameworks as they ship. Unsubscribe anytime.