Announcing CIMD support for MCP Client registration
Learn more

Remote MCP server hosting: deploy, secure and scale your own

Diagram: an MCP client sends POST /mcp with a bearer token to an OAuth 2.1 check, which passes requests to three stateless server replicas
Nityashree Yadunath
Product Marketing Manager

To host a remote MCP server, you run it as an ordinary HTTPS service that speaks the Streamable HTTP transport at one endpoint, such as https://mcp.example.com/mcp, and put OAuth 2.1 in front of it so each request acts as the user who signed in. Under the current 2026-07-28 MCP spec every request stands alone, so the server can run on Cloudflare Workers, Vercel Functions or a container platform behind a plain load balancer. Older clients that still open sessions are the main thing to plan for.

Hosting your own server is one way to run MCP integrations, and the right one when the tools are yours. If you are still deciding whether a server should be local or remote, remote vs stdio MCP servers compares the two transports.

TL;DR

  • Cloudflare Workers, Vercel and Cloud Run all document remote MCP hosting. Cloudflare and Vercel ship stateless handlers that also serve 2025-era clients; Cloud Run runs any Streamable HTTP server you put in a container.
  • Auth follows the MCP authorization spec: publish OAuth protected resource metadata, accept only tokens issued for your server, and send 401 with a pointer to the metadata. The Python SDK serves the metadata route for you, and Vercel's mcp-handler has a helper for it.
  • Modern clients need no sticky routing. Older clients still create sessions, so either serve them statelessly or keep each one on a single instance. Tools that ask the user for input need the same request-state key on every replica.

What a remote MCP server needs

Whatever platform you pick, a production server has the same five parts:

  1. One HTTPS endpoint that accepts POST, usually /mcp. The spec requires a single endpoint path for all messages.
  2. Host and Origin checks. The spec says servers MUST validate the Origin header to block DNS rebinding and answer an invalid one with 403.
  3. OAuth 2.1 as a resource server. Publish protected resource metadata (RFC 9728) and refuse any token that was not issued for this server or has expired.
  4. A stateless deployment with a plan for clients still on 2025-11-25 or earlier, which expect an initialize handshake and a session.
  5. A health route and logs outside the MCP endpoint, so your platform can probe the service without a token.

Pick a deploy target

Each platform below has its own MCP hosting guide. The table summarises what each guide covers.

Platform
What you write
Older clients
Auth
Cloudflare Workers
A Worker that returns createMcpHandler(createServer) from the Agents SDK
Stateless handler with legacy compatibility
Workers OAuth Provider library, Cloudflare Access, or a third-party OAuth provider
Vercel
A Next.js route such as app/api/mcp/route.ts using mcp-handler v2
2025-era Streamable HTTP clients served statelessly from the same handler; SSE removed
withMcpAuth and protectedResourceHandler; you bring the authorization server
Cloud Run
Any Streamable HTTP server in a container, deployed from an image or from source
Depends on your SDK; the Python SDK serves both eras on one app
IAM invoker check by default, IAP, or your own OAuth on a public service
Any container host
The same image behind your own load balancer
Depends on your SDK
Your own OAuth; nothing platform-specific

Cloudflare Workers

Cloudflare's remote MCP server guide covers local testing with the MCP Inspector, deployment with Wrangler and GitHub OAuth. For new servers Cloudflare recommends createMcpHandler, which it lists as stateless with legacy compatibility. The older McpAgent class, which kept session state in a Durable Object, is deprecated and feature-frozen. The guide's quick-deploy templates are built on McpAgent, so Cloudflare points new servers to its mcp-worker example as the starting point. For auth, wrap the handler in the Workers OAuth Provider with apiRoute: "/mcp", or put Cloudflare Access in front when your users already sign in through an identity provider Access supports.

Vercel

Vercel's MCP deployment guide uses mcp-handler in a Next.js App Router route, and the server runs on Vercel Functions with Fluid compute, which Vercel positions for the long idle periods and short bursts typical of MCP traffic. Version 2 of the package is built on the v2 TypeScript SDK (@modelcontextprotocol/server), serves 2026-07-28 natively and answers 2025-era clients statelessly from the same handler. It removed the HTTP+SSE transport and the Redis option from 1.x. For auth, withMcpAuth calls your token check and returns 401 for a missing or invalid token and 403 for a missing scope, and protectedResourceHandler serves the metadata document. The package does not issue tokens, so you still need an authorization server.

Cloud Run and other containers

Google's Cloud Run MCP guide supports Streamable HTTP servers and not stdio servers. You deploy an image with gcloud run deploy --image or build from source with gcloud run deploy --source ., which uses your Dockerfile when one is present. Cloud Run checks the IAM invoker role on every request by default. That is a good fit for internal agents running on Google Cloud, but it does not work for clients such as Claude's custom connectors, which connect from Anthropic's cloud and need a server reachable over the public internet. For those, disable the invoker check and let OAuth do the gating, as the deploy step below does.

Build the server with the Python SDK

The example uses version 2 of the official MCP Python SDK. In v2 the high-level FastMCP class is called MCPServer, and the old mcp.server.fastmcp import path raises an error, so v1 code starts by changing that import; the SDK's migration guide lists the other renames. The server exposes one tool and a health route:

import os from starlette.requests import Request from starlette.responses import PlainTextResponse from mcp.server import MCPServer from mcp.server.transport_security import TransportSecuritySettings mcp = MCPServer("orders") ORDERS = {"A-1001": "shipped", "A-1002": "processing"} @mcp.tool() def order_status(order_id: str) -> str: """Look up the status of an order by its ID.""" return ORDERS.get(order_id, "not found") @mcp.custom_route("/health", methods=["GET"]) async def health(request: Request) -> PlainTextResponse: return PlainTextResponse("ok") # The hostname clients use, such as mcp.example.com. By default the SDK # only accepts requests addressed to localhost. PUBLIC_HOST = os.environ.get("PUBLIC_HOST", "localhost") app = mcp.streamable_http_app( # Clients on 2026-07-28 never get a session. This also stops the SDK from # creating sessions for older clients, so any replica can serve any request. stateless_http=True, transport_security=TransportSecuritySettings( allowed_hosts=[PUBLIC_HOST, f"{PUBLIC_HOST}:*"], ), )

Two settings matter for hosting:

  • allowed_hosts. With no transport_security argument, streamable_http_app() only accepts Host headers for localhost. On a real hostname every request returns 421 Misdirected Request until you list that hostname. Add allowed_origins only if browser-based clients call the server, since nothing else sends an Origin header. The SDK's deployment page covers both settings.
  • stateless_http=True. Requests on 2026-07-28 never create a session, with or without this flag. The flag only changes how the SDK serves older clients: it gives each request a throwaway session, so any replica can answer it.

Run it locally and call the tool from the MCP Inspector CLI, once as a modern client and once as an older one:

pip install "mcp==2.3.0" uvicorn uvicorn server:app --port 8000
# A client on the 2026-07-28 spec npx @modelcontextprotocol/inspector --cli http://localhost:8000/mcp --protocol-era modern \ --method tools/call --tool-name order_status --tool-arg order_id=A-1001 # The same call as an older client that opens a session with initialize npx @modelcontextprotocol/inspector --cli http://localhost:8000/mcp --protocol-era legacy \ --method tools/call --tool-name order_status --tool-arg order_id=A-1001

Both calls return shipped. The Inspector negotiates the older protocol unless you pass --protocol-era modern, so set the era explicitly when you test. In the modern result, _meta also carries io.modelcontextprotocol/serverInfo, which is how a 2026-07-28 server identifies itself now that there is no initialize handshake.

Put OAuth 2.1 in front of it

Authorization is optional in MCP, but the authorization spec says servers using HTTP should follow it, and a server that acts on user data needs it. As a resource server, your MCP server has three jobs:

  • Publish OAuth 2.0 Protected Resource Metadata, which names the authorization servers that issue tokens for it. Clients MUST use this document to find where to sign in.
  • Validate every access token, including that it was issued for this server as the audience. Invalid or expired tokens MUST get a 401.
  • Accept only its own tokens. The spec says servers MUST NOT accept or pass along any other tokens.

The authorization server does the sign-in, consent and client registration. The 2026-07-28 spec says clients and authorization servers SHOULD support Client ID Metadata Documents and marks Dynamic Client Registration as deprecated, kept for authorization servers without CIMD. With Scalekit MCP Auth, you register the server under MCP servers in the dashboard and can turn on both CIMD and DCR. Scalekit recommends keeping both on for servers that public clients such as Claude, Cursor and VS Code will connect to: clients that support CIMD use it, and the rest fall back to DCR. Each registered server gets its own issuer, <environment URL>/resources/<resource ID>, and its tokens carry the resource ID as an audience.

In the Python SDK you plug in a TokenVerifier and describe the resource with AuthSettings. The SDK then serves the metadata at /.well-known/oauth-protected-resource/mcp and answers requests without a valid token with a 401 whose WWW-Authenticate header points to it:

import os import anyio from pydantic import AnyHttpUrl from scalekit import ScalekitClient from scalekit.common.scalekit import TokenValidationOptions from mcp.server import MCPServer from mcp.server.auth.middleware.auth_context import get_access_token from mcp.server.auth.provider import AccessToken, TokenVerifier from mcp.server.auth.settings import AuthSettings from mcp.server.transport_security import TransportSecuritySettings ENV_URL = os.environ["SCALEKIT_ENVIRONMENT_URL"] RESOURCE_ID = os.environ["SCALEKIT_RESOURCE_ID"] # res_... from Dashboard > MCP servers MCP_URL = os.environ["MCP_URL"] # e.g. https://mcp.example.com/mcp PUBLIC_HOST = os.environ.get("PUBLIC_HOST", "localhost") ISSUER = f"{ENV_URL}/resources/{RESOURCE_ID}" scalekit = ScalekitClient( env_url=ENV_URL, client_id=os.environ["SCALEKIT_CLIENT_ID"], client_secret=os.environ["SCALEKIT_CLIENT_SECRET"], ) class ScalekitTokenVerifier(TokenVerifier): async def verify_token(self, token: str) -> AccessToken | None: # Checks signature, expiry, issuer and audience (this server's resource ID). options = TokenValidationOptions(issuer=ISSUER, audience=[RESOURCE_ID]) try: claims = await anyio.to_thread.run_sync( lambda: scalekit.validate_token(token, options=options) ) except Exception: return None # the SDK answers 401 with a pointer to the metadata document return AccessToken( token=token, client_id=claims.get("client_id", ""), scopes=str(claims.get("scope", "")).split(), expires_at=claims.get("exp"), subject=claims.get("sub"), claims=claims, ) mcp = MCPServer( "orders", token_verifier=ScalekitTokenVerifier(), auth=AuthSettings( issuer_url=AnyHttpUrl(ISSUER), resource_server_url=AnyHttpUrl(MCP_URL), validate_token_resource=False, # the verifier checks the audience itself ), ) ORDERS = {"A-1001": "shipped", "A-1002": "processing"} @mcp.tool() def order_status(order_id: str) -> str: """Look up the status of an order by its ID.""" caller = get_access_token() return f"{ORDERS.get(order_id, 'not found')} (asked by {caller.subject})" app = mcp.streamable_http_app( stateless_http=True, transport_security=TransportSecuritySettings( allowed_hosts=[PUBLIC_HOST, f"{PUBLIC_HOST}:*"], ), )

validate_token_resource=False is deliberate. That SDK option compares the token to the server URL, while Scalekit tokens always carry the resource ID as an audience, so the verifier checks the audience itself, which is the pattern the SDK's authorization page describes for authorization servers that use their own audience identifiers. Inside any tool, get_access_token() returns who is calling and with which scopes, so you can refuse a write when the token lacks the scope for it. For agents that call the server without a user, Scalekit issues machine-to-machine clients for the same MCP server, which get tokens with the client credentials flow. The MCP authorization guide walks through the full client flow, and shipping a secure MCP server covers scope design. If you build on the standalone FastMCP framework instead of the SDK, the Scalekit FastMCP quickstart wires the same checks through its auth provider.

Deploy it to Cloud Run

Cloud Run terminates TLS and forwards plain HTTP to your container on the port in $PORT (8080 by default). Tell uvicorn to trust the forwarded headers; otherwise a redirect the app issues points to http:// and the Python client refuses to follow it from an HTTPS endpoint. Save the auth version as server.py, list mcp==2.3.0, uvicorn and scalekit-sdk-python in requirements.txt, and add a Dockerfile:

FROM python:3.13-slim WORKDIR /app COPY requirements.txt . RUN pip install --no-cache-dir -r requirements.txt COPY . . # Cloud Run sends traffic to $PORT and terminates TLS in front of the container. CMD exec uvicorn server:app --host 0.0.0.0 --port ${PORT:-8080} --proxy-headers --forwarded-allow-ips="*"

When the service name is short enough, Cloud Run also assigns a deterministic URL in the form https://SERVICE-PROJECT_NUMBER.REGION.run.app, so you can register that URL in Scalekit before the first deploy. Store the client secret in Secret Manager as scalekit-client-secret, give the service's runtime service account the Secret Manager Secret Accessor role, and deploy with the invoker check disabled, since OAuth now decides who gets in:

HOST=orders-mcp-PROJECT_NUMBER.europe-west1.run.app gcloud run deploy orders-mcp --source . --region europe-west1 --no-invoker-iam-check \ --set-env-vars PUBLIC_HOST=$HOST,MCP_URL=https://$HOST/mcp \ --set-env-vars SCALEKIT_ENVIRONMENT_URL=https://YOUR_ENV.scalekit.cloud \ --set-env-vars SCALEKIT_CLIENT_ID=YOUR_CLIENT_ID,SCALEKIT_RESOURCE_ID=res_YOUR_RESOURCE_ID \ --update-secrets=SCALEKIT_CLIENT_SECRET=scalekit-client-secret:1

Then add it to a client. In Claude Code:

claude mcp add --transport http orders https://orders-mcp-PROJECT_NUMBER.europe-west1.run.app/mcp

Run /mcp inside Claude Code to sign in. Cursor, Codex and VS Code take the same URL in their MCP config files, and the remote vs stdio comparison shows each format.

Scaling and statelessness

The 2026-07-28 spec removed protocol sessions, the Mcp-Session-Id header and the initialize handshake. Each request carries its protocol version and client capabilities, so a load balancer can send any request to any instance. What changed with stateless MCP covers the rest of the release. Four details still need attention when you run more than one instance.

Older clients still create sessions

The spec's compatibility matrix says a legacy client against a server that only speaks 2026-07-28 fails, and legacy clients have no way to move forward on their own. A dual-era server answers initialize and serves that client under the older revision, scoped to a session. The Python SDK, Cloudflare's createMcpHandler and Vercel's mcp-handler are all dual-era. In the Python SDK the session lives in one process's memory, so a follow-up request that lands on another worker gets 404 Session not found. Remove stateless_http=True from the example, run it under uvicorn --workers 4, and modern clients succeed every time while most legacy connections fail. Put the flag back and both succeed.

The flag has a cost on the legacy path: it removes the channel the server uses to send requests to the client, so elicitation, sampling and roots requests raise an error and notifications are dropped. If your tools never call back into the client, take the flag. If they do, keep sessions for older clients and route them to one instance. On Cloud Run, session affinity uses a cookie and is best effort: it breaks when an instance shuts down or reaches its concurrency limit, so do not rely on it for correctness.

Tools that ask the user something

Under 2026-07-28 a tool that needs input from the user returns an InputRequiredResult and the client retries the call with the answer. The Python SDK seals the state between the two rounds with a key it generates per process, so a retry that reaches a different replica fails with Invalid or expired requestState. Give every instance the same key with RequestStateSecurity(keys=[...]) and the same server name, because the name is part of what the seal checks.

Change notifications

Clients that want list-change or resource-update notifications open a subscriptions/listen stream, which stays pinned to the replica that accepted it. A tool on another replica can only reach that stream through a shared bus. The Python SDK's built-in bus works within one process; across replicas you implement its two-method SubscriptionBus interface over Redis, NATS or whatever pub/sub you already run.

Timeouts and routing

A tool call holds its HTTP request open until it finishes, so check the platform timeout against your slowest tool. Cloud Run's default is 5 minutes and the maximum is 60. Every POST now carries Mcp-Method and, for tool calls, Mcp-Name headers, which the spec added so load balancers, gateways and observability tools can route and inspect requests without parsing the body.

When you do not need to host one

Host your own server when the tools are yours: internal systems, your product's API, data only you can reach. The remote MCP servers for agent workflows post shows that pattern for internal automation. If the tools are wrappers around SaaS APIs such as Gmail, Salesforce or Linear, a managed MCP gateway saves you from hosting one server per app and storing every user's tokens. Scalekit AgentKit covers 500+ connectors. You create a Virtual MCP server with only the tools an agent needs, all users share its URL, and before each run you mint a session token bound to one user and one server. The token lasts one hour by default, can be set from 60 seconds to 24 hours, and is minted again for the next run instead of being refreshed. Hosted MCP servers compared covers the other managed options and when each one fits better.

FAQs

What is a remote MCP server?

A remote MCP server is an MCP server that runs as a web service and is reached at a URL over the Streamable HTTP transport, instead of being launched on your machine as a subprocess. Many clients and users can share one deployment, and each request usually carries an OAuth access token that names the user.

How do I host an MCP server?

Build the server with an official SDK, serve it over Streamable HTTP at one endpoint such as /mcp, and deploy it like any other HTTPS service: Cloudflare Workers with createMcpHandler, a Next.js route on Vercel with mcp-handler, or a container on Cloud Run. Then add OAuth 2.1 by publishing protected resource metadata and validating every token.

Do remote MCP servers still need sticky sessions?

Not for clients on the 2026-07-28 spec, because the spec removed protocol sessions and every request stands alone. Older clients still open a session with initialize. In the Python SDK, stateless_http=True serves those clients without sessions at the cost of server-to-client requests; otherwise their traffic needs sticky routing.

Should a new remote MCP server also expose SSE?

No. The HTTP+SSE transport from 2024 has been deprecated since the 2025-03-26 spec, which says new implementations should not adopt it. Vercel's mcp-handler 2.x removed it, and Cloudflare keeps it only for existing McpAgent deployments while they migrate, so serve Streamable HTTP at a single endpoint such as /mcp.

Does a remote MCP server need OAuth?

Authorization is optional in the MCP spec, but servers using HTTP should follow its OAuth 2.1 rules when they protect anything. A server that acts on user data should publish protected resource metadata, accept only tokens issued for it, and answer missing or invalid tokens with 401.

Why does my deployed MCP server return 421 Misdirected Request?

The Python SDK enables DNS rebinding protection by default and only accepts Host headers for localhost. Behind a real hostname every request fails with 421 until you pass TransportSecuritySettings with your public hostname in allowed_hosts.

Details checked October 2026.

No items found.
On this page
Share this article

Acquire enterprise customers with
‍zero upfront cost.

Every feature unlocked. No hidden fees.