How to Deploy a MCP Server: Remote Hosting Guide (2026)
How to deploy a MCP server as a remote endpoint: Streamable HTTP, OAuth per the spec, Cloudflare Workers, containers, serverless, scaling and security.
To deploy an MCP server, serve it over the Streamable HTTP transport at a single HTTPS endpoint (usually /mcp) and host it on any platform that runs a web handler, such as Cloudflare Workers, a container on any cloud, or a serverless function. Put OAuth bearer-token checks in front of it as the MCP authorization spec describes. Under the current 2026-07-28 spec the protocol is stateless, so a deployed server scales horizontally behind a plain load balancer.
This guide assumes you already have a working server. If you don't, start with build an MCP server (TypeScript) or the Python MCP server guide. The examples use the official SDK v2 releases: @modelcontextprotocol/server 2.3.1 and mcp 2.3.0.
What changes when a server goes remote
A local server is a child process that one client launches over stdio. A remote server is a URL that many clients share. Four things become your job:
- Transport: Streamable HTTP. Every message is an HTTP POST to one endpoint, and the server answers with JSON or an SSE stream. The old two-endpoint HTTP+SSE transport is deprecated, see is MCP SSE deprecated.
- Authentication: anyone on the internet can reach the URL, so it needs real access control.
- Hosting and scaling: process lifetime, timeouts, multiple instances.
- Hardening: Host/Origin checks, rate limits, logging and secret handling.
Pick a hosting option
| Option | Good for | Watch out for |
|---|---|---|
| Cloudflare Workers (or Deno/Bun) | Small, stateless servers, global edge, free tier | CPU/time limits per request, no Node APIs unless enabled |
| Container (Cloud Run, ECS/Fargate, Fly.io, Kubernetes, a VM) | Any language, long-running tools, private networking | You manage the image, TLS and scaling settings |
| Serverless functions (AWS Lambda, Vercel, Netlify) | Spiky traffic, pay-per-call | Function timeouts cut off long streams |
| Managed MCP platforms | Teams that want gateway features like auth, audit and a registry | Vendor lock-in; compare in MCP gateway vs proxy |
The modern spec has no sessions, so all four work the same way: a request comes in and a response goes out. Choose based on where your data and dependencies live, not on MCP.
A concrete example: Cloudflare Workers
The TypeScript SDK's createMcpHandler returns a web-standard fetch handler. That is exactly the shape a Worker exports. We tested this setup locally with wrangler dev (Wrangler 4.151) using the createServer factory from the build guide. It needs no nodejs_compat flag.
src/worker.ts:
import {
createMcpHandler,
hostHeaderValidationResponse,
} from '@modelcontextprotocol/server';
import { createServer } from './server.js';
const handler = createMcpHandler(createServer);
const ALLOWED_HOSTS = [
'npm-info-mcp.your-subdomain.workers.dev',
'localhost',
'127.0.0.1',
];
export default {
async fetch(request: Request): Promise<Response> {
if (new URL(request.url).pathname !== '/mcp')
return new Response('Not found', { status: 404 });
const rejected = hostHeaderValidationResponse(request, ALLOWED_HOSTS);
return rejected ?? handler.fetch(request);
},
};
wrangler.jsonc:
{
"name": "npm-info-mcp",
"main": "src/worker.ts",
"compatibility_date": "2026-10-01"
}
Run and deploy:
npm install -D wrangler
npx wrangler dev # http://localhost:8787/mcp
npx wrangler deploy # prints https://npm-info-mcp.<your-subdomain>.workers.dev
Check the live endpoint with the MCP Inspector:
npx @modelcontextprotocol/inspector --cli https://npm-info-mcp.your-subdomain.workers.dev/mcp \
--transport http --method tools/list
Store API keys with npx wrangler secret put NAME, never in code. Cloudflare also has its own MCP tooling in the Agents SDK, which helps if you want Durable Objects-backed state.
Containers and other runtimes
On Node you mount the same handler on Express. Use createMcpExpressApp with an explicit host allowlist, because binding to 0.0.0.0 turns off the localhost-only default:
import { createMcpExpressApp } from '@modelcontextprotocol/express';
import { toNodeHandler } from '@modelcontextprotocol/node';
import { createMcpHandler } from '@modelcontextprotocol/server';
import { createServer } from './server.js';
const app = createMcpExpressApp({
host: '0.0.0.0',
allowedHosts: ['mcp.example.com'],
});
const node = toNodeHandler(createMcpHandler(createServer));
app.all('/mcp', (req, res) => void node(req, res, req.body));
app.listen(Number(process.env.PORT ?? 3000), '0.0.0.0');
In Python, build the ASGI app with an allowlist and run it with any ASGI server. Without the allowlist, every request behind a real hostname gets 421 Misdirected Request:
from mcp.server.transport_security import TransportSecuritySettings
app = mcp.streamable_http_app(
transport_security=TransportSecuritySettings(allowed_hosts=["mcp.example.com", "mcp.example.com:*"])
)
# uvicorn server:app --host 0.0.0.0 --workers 4 --proxy-headers --forwarded-allow-ips='<proxy ip>'
If TLS ends at a load balancer, --proxy-headers stops redirects from pointing at http://. Package either app in a normal Dockerfile and deploy it like any other web service. Add a health route (@mcp.custom_route("/health", methods=["GET"]) in Python), because the SDKs don't ship one.
Add authentication the way the spec describes
The MCP authorization spec treats your server as an OAuth 2.1 resource server. It checks tokens, while a separate authorization server (Auth0, Okta, Entra ID, Keycloak, WorkOS and so on) issues them. The flow:
- A request without a valid token gets
401with aWWW-Authenticate: Bearerheader that points to your Protected Resource Metadata document (RFC 9728). - That document names your authorization server. The client finds it, registers (the 2026-07-28 spec prefers Client ID Metadata Documents and deprecates Dynamic Client Registration), runs the user through login with PKCE, and retries with the token.
- Your server validates the token, including its audience. A token issued for another resource must be rejected.
With the TypeScript SDK on a fetch runtime, the gate looks like this (verifyAccessToken is your JWT or introspection check):
import {
createMcpHandler,
requireBearerAuth,
} from '@modelcontextprotocol/server';
const mcpServerUrl = new URL('https://mcp.example.com/mcp');
const gate = requireBearerAuth({
verifier: { verifyAccessToken },
requiredScopes: ['mcp'],
expectedResource: mcpServerUrl,
});
const handler = createMcpHandler(createServer);
export default {
async fetch(request: Request): Promise<Response> {
const auth = await gate(request);
if (auth instanceof Response) return auth; // 401/403 with the WWW-Authenticate challenge
return handler.fetch(request, { authInfo: auth });
},
};
oauthMetadataResponse serves the well-known metadata documents, and tool handlers read the caller from ctx.http.authInfo. For internal, machine-only servers, a static API key in a header is acceptable. Treat it as a secret and rotate it. The whole flow, with diagrams, is in MCP authentication and OAuth.
Scaling and statefulness
- Modern clients (2026-07-28) send self-contained requests: no
initializehandshake and noMcp-Session-Id. Any instance can serve any request, so no sticky sessions are needed. - Older clients (2025 revisions) may expect sessions. The TypeScript handler serves them statelessly by default (
legacy: 'stateless'). In Python, legacy sessions live in one worker's memory, so either enable sticky sessions or run withstateless_http=True. - State that must survive across calls goes in your own storage (a database, KV, Redis), referenced by IDs the server hands out as tool arguments, not in process memory.
- Multi-round-trip tools (asking the user mid-call) hand the client a sealed
requestState. In Python, give every instance the sameRequestStateSecurity(keys=[...])and the same server name. Otherwise a retry that lands on another worker fails. - Change notifications (
subscriptions/listen) are long-lived streams pinned to one instance. Fan events out across instances with a shared pub/sub bus, and make sure your platform's timeout allows long streams.
Security checklist before launch
- HTTPS only. Validate
HostandOrigin(the SDK helpers above). - Require auth on every route except health and the OAuth metadata documents. Validate token audience and expiry.
- Never pass the client's token through to upstream APIs. Use your own credentials or a token exchange.
- Use least-privilege upstream credentials, stored in a secret manager.
- Rate limits per user and per IP, plus request size limits (the Python SDK caps bodies at 4 MiB by default).
- Mark destructive tools clearly and use read-only annotations where they're true.
- Log tool calls with the caller's identity, without secrets or full payloads.
- Run the MCP security best practices checklist, and test with the Inspector against the production URL.
Next steps
Once the server is live, publish its URL as a remotes entry (type streamable-http) in the official MCP Registry, and submit it to MCP.so so users find it in the MCP server directory. For examples of well-run remote servers, look at the Sentry and Notion official servers, or read the remote MCP servers guide.
Frequently asked questions
How do I deploy an MCP server?
- Serve it over the Streamable HTTP transport at one HTTPS endpoint such as /mcp, host it on Cloudflare Workers, a container or a serverless function, and put OAuth bearer-token checks in front of it. Then test the live URL with the MCP Inspector.
Can I deploy an MCP server for free?
- Yes. A small stateless server fits in the Cloudflare Workers free tier, and many container and serverless platforms have free allowances. You only pay once traffic or CPU time grows.
Do remote MCP servers need sticky sessions?
- Not for clients on the 2026-07-28 spec, because every request is self-contained and any instance can answer it. Older 2025-era clients may use sessions, so either serve them statelessly or enable session affinity.
How do I secure a deployed MCP server?
- Use HTTPS, validate the Host and Origin headers, require OAuth tokens and check their audience and expiry. Also add rate limits, keep upstream credentials in a secret manager and never pass the client token through to other APIs.