Deploying a remote MCP server on Cloudflare Workers, Vercel, or your own host
Deploy a Streamable HTTP MCP server to Cloudflare Workers, Vercel, or a Node or Python host behind a reverse proxy, and avoid the platform defaults that show up as transport diagnostics.
On this page
The MCP part of a deployment is small: one endpoint that accepts POST, answers with JSON or a per-request event stream, and validates Host and Origin. Most failures after a successful local test come from the platform in front of that endpoint, not from the server code. This guide covers three common homes for a Streamable HTTP server and, for each, the defaults that later appear in an mcptest report as transport diagnostics. If you have not moved the server off stdio yet, start with converting a stdio server to remote.
The diagnostics to design against
These are the stable diagnostic ids mcptest reports, with the platform cause behind each one in practice.
| Diagnostic | What the client saw | Usual deployment cause |
|---|---|---|
transport.get-stream-405 | GET on the endpoint answered 405 | Normal for 2026 and for stateless 2025 serving; only a problem if a 2025 client needs server push |
transport.accept-header-406 | POST answered 406 Not Acceptable | A hand-written or gateway Accept check stricter than "lists both application/json and text/event-stream" |
transport.unsupported-media-type-415 | POST answered 415 | A body parser or WAF rule that rejects application/json; charset=utf-8 or only allows another type |
transport.sse-content-type-mismatch | A success response with the wrong Content-Type | A CDN, proxy, or catch-all route answering with text/html or text/plain |
transport.cors-expose-headers-missing | initialize worked in a browser, the next request was rejected | Mcp-Session-Id issued but not listed in Access-Control-Expose-Headers |
transport.target-rate-limited-429 | The server answered 429 | An edge or proxy rate limit, often without Retry-After or without CORS headers |
Errors block a release in the mcptest release decision; the GET stream 405 is a warning that only asks for review. The status-code guide explains the protocol rule behind each code, and the CORS reference lists the full header set.
Cloudflare Workers
Workers serve a { fetch } object, which is exactly what the SDK's handler is. There are two good starting points.
With the Agents SDK
createMcpHandler from agents/mcp/server wraps the SDK v2 handler with routing, CORS, and Host and Origin checks. Create it once at module scope:
import { McpServer } from '@modelcontextprotocol/server';
import { createMcpHandler } from 'agents/mcp/server';
import { z } from 'zod';
function createServer() {
const server = new McpServer({ name: 'orders', version: '1.0.0' });
server.registerTool('ping-order', { description: 'Echo an order id', inputSchema: { id: z.string() } }, async ({ id }) => ({
content: [{ type: 'text', text: id }],
}));
return server;
}
const ALLOWED_ORIGINS = new Set(['https://mcptest.io', 'https://app.example.com']);
const mcp = createMcpHandler(createServer, {
route: '/mcp',
allowedHostnames: ['mcp.example.com'],
allowedOriginHostnames: ['mcptest.io', 'app.example.com'],
corsOptions: {
origin: 'https://mcptest.io',
headers: 'Content-Type, Accept, Authorization, MCP-Protocol-Version, Mcp-Method, Mcp-Name, Mcp-Session-Id, Last-Event-ID',
exposeHeaders: 'Mcp-Session-Id, MCP-Protocol-Version, WWW-Authenticate, Retry-After',
maxAge: 7200,
},
});
// corsOptions.origin is a single string, so echo any other allowed origin.
function withRequestOrigin(request: Request, response: Response): Response {
const origin = request.headers.get('Origin');
const headers = new Headers(response.headers);
headers.append('Vary', 'Origin');
if (origin && ALLOWED_ORIGINS.has(origin)) {
headers.set('Access-Control-Allow-Origin', origin);
}
return new Response(response.body, { status: response.status, statusText: response.statusText, headers });
}
export default {
async fetch(request, env, ctx) {
return withRequestOrigin(request, await mcp(request, env, ctx));
},
} satisfies ExportedHandler;Four defaults matter here. The handler's built-in allowlist covers localhost and the Worker's workers.dev hostname, so a custom domain needs allowedHostnames and allowedOriginHostnames or browser requests are rejected with 403. allowedOriginHostnames only decides which origins pass validation; corsOptions.origin takes one fixed string, which every response, preflights included, sends as Access-Control-Allow-Origin. A browser at any other allowed origin rejects that response, so the wrapper replaces the value with the request's Origin when it is on the same allowlist and adds Vary: Origin. The default CORS options expose only mcp-session-id, so a browser cannot read MCP-Protocol-Version, WWW-Authenticate, or Retry-After until you set exposeHeaders. And each corsOptions field replaces its default as a whole string, so restate every header when you change one.
McpAgent is now a feature-frozen path for existing stateful SDK v1 deployments, backed by a Durable Object per session. Use it only to keep such a deployment running.
With the SDK alone
Without the Agents SDK, export default handler from createMcpHandler in @modelcontextprotocol/server is the whole Worker. It performs no Host or Origin validation and adds no CORS headers, so put hostHeaderValidationResponse and originValidationResponse in front of handler.fetch and answer preflights yourself. The CORS reference has a complete Worker with both. Deploy with npx wrangler deploy.
Workers pitfalls
405onGET. In cloudflare/agents#403 (opens in a new tab), the reference remote server answeredGET /mcpwith405, which a developer expected to be a stream for notifications and elicitation. The spec permits405for 2025 clients, and the 2026 revision removed theGETstream entirely. Today's stateless handler answersGETandDELETEwith405by design, and change notifications for 2026 clients travel onsubscriptions/listen. mcptest shows this as a warning, not a blocker.- Edge-generated responses skip your code. A WAF or rate-limiting rule that answers
429in front of the Worker returns no CORS headers and often noRetry-After. A browser client sees a CORS failure instead of a rate limit. Size limits so one connection, which is a discovery request plus up to four list calls with pagination, does not trip them. - Notifications are isolate-local.
handler.notifyreaches subscription streams in the same isolate only.
Vercel
On Vercel, a Next.js route handler receives a web-standard Request, so the SDK's handler mounts without adapters. Vercel's mcp-handler package (version 2 is built on SDK v2) wraps that for you. It serves 2026 clients natively and 2025 clients statelessly, so GET and DELETE session operations answer 405, and it adds no CORS headers to the MCP route. Add them yourself and export an OPTIONS handler for preflights:
// app/mcp/route.ts
import { createMcpHandler } from 'mcp-handler';
import { z } from 'zod';
const mcp = createMcpHandler((server) => {
server.registerTool(
'ping-order',
{ description: 'Echo an order id', inputSchema: z.object({ id: z.string() }) },
async ({ id }) => ({ content: [{ type: 'text', text: id }] })
);
});
const ALLOWED_ORIGINS = new Set(['https://mcptest.io', 'https://app.example.com']);
function withCors(request: Request, response: Response): Response {
const origin = request.headers.get('Origin');
const headers = new Headers(response.headers);
headers.append('Vary', 'Origin');
if (origin && ALLOWED_ORIGINS.has(origin)) {
headers.set('Access-Control-Allow-Origin', origin);
headers.set('Access-Control-Allow-Methods', 'GET, POST, DELETE, OPTIONS');
headers.set(
'Access-Control-Allow-Headers',
'Content-Type, Accept, Authorization, MCP-Protocol-Version, Mcp-Method, Mcp-Name, Mcp-Session-Id, Last-Event-ID'
);
headers.set('Access-Control-Expose-Headers', 'Mcp-Session-Id, MCP-Protocol-Version, WWW-Authenticate, Retry-After');
headers.set('Access-Control-Max-Age', '7200');
}
return new Response(response.body, { status: response.status, statusText: response.statusText, headers });
}
const handle = async (request: Request) => withCors(request, await mcp(request));
export const maxDuration = 60;
export { handle as GET, handle as POST, handle as DELETE };
export async function OPTIONS(request: Request) {
return withCors(request, new Response(null, { status: 204 }));
}Without an OPTIONS export, Next.js answers preflights itself with an Allow header and no CORS headers, and the browser blocks the request.
Vercel pitfalls
- Function duration. A long tool call that streams progress runs until the function's maximum duration. Set
maxDurationfor the route to the longest call you support, within your plan's limit. - Deployment protection. Standard Protection, the recommended setting, protects every domain except production domains. An unauthenticated client calling a protected preview URL gets Vercel's authentication response instead of MCP, which mcptest reports as a failed negotiation or an authorization challenge from the platform. Test the production domain, or configure a bypass for automation.
- Catch-all routes. A rewrite that sends unknown paths to a page answers
GET /mcpwith200 text/html. Mount the MCP route explicitly and publish its full URL.
A Node or Python host behind a reverse proxy
On a VM, container platform, or Kubernetes, the server listens on a private port and a reverse proxy terminates TLS. The application is unchanged; the proxy configuration is where streaming breaks.
location /mcp {
proxy_pass http://127.0.0.1:3000;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header Connection "";
proxy_buffering off;
proxy_cache off;
proxy_read_timeout 300s;
client_max_body_size 4m;
gzip off;
}| Directive | nginx default | Why change it |
|---|---|---|
proxy_buffering | on | Buffered SSE arrives all at once or after the call finishes. Both SDKs also send X-Accel-Buffering: no on event streams, which nginx honors unless proxy_ignore_headers drops it. |
proxy_read_timeout | 60s | A quiet stream longer than this is cut, which a 2026 server treats as cancellation of the request. |
client_max_body_size | 1m | Both SDKs accept 4 MiB request bodies by default; a larger tool call gets nginx's 413 HTML page. |
limit_req_status | 503 | If you rate-limit in nginx, return 429 and add Retry-After so clients can back off. |
Keep Host intact. Both SDKs validate it: the TypeScript Express and Hono apps through allowedHosts, the Python app through TransportSecuritySettings(allowed_hosts=[...]). A Python app deployed without that allowlist answers every request with 421 Misdirected Request, and the reason appears only in the server log. If the proxy already controls Host, TransportSecuritySettings(enable_dns_rebinding_protection=False) is the honest setting.
For Python behind a TLS-terminating proxy, run uvicorn with --proxy-headers --forwarded-allow-ips='<proxy address>'. Without it the app believes it is served over http://, a redirect from /mcp to /mcp/ points at plain HTTP, and the Python client refuses to downgrade.
Every response the proxy generates itself, such as 413, 502, 504, or a limit_req rejection, bypasses the application's CORS headers. Add CORS headers for those error pages at the proxy with add_header ... always, or a browser reports them as CORS failures.
Scaling past one instance
A 2026 request is self-contained, so any instance can answer it. A 2025 client served statefully, which is the Python SDK's default leg, holds an Mcp-Session-Id in one process's memory and needs sticky routing; a request that reaches another worker gets 404 Session not found. Either enable session affinity or set stateless_http=True for the 2025 leg. Python multi-round-trip tools also need the same RequestStateSecurity keys and the same server name on every instance.
Test the deployed endpoint in the playground
Open the production URL in the playground: https://mcptest.io/server/ followed by the endpoint without the scheme and a final /mcp for Streamable HTTP, for example mcptest.io/server/mcp.example.com/mcp/mcp. The connection runs from your browser, so the report shows the preflight, the readable response headers, and every transport diagnostic above with the exact request and response that triggered it.