
This lesson is part of Learn MCP, our free course on the Model Context Protocol, within The MCP Guide.
In this lesson you will turn the bookshop server into a remote server that other people can reach by URL. You will serve it over Streamable HTTP in Python and TypeScript and lock down which hostnames it answers. Then you will plan for several instances and compare hosting options.
A local server runs as a child process of one person's AI app over stdio. A remote server listens on an HTTPS URL, and any authorized client can connect. The Python SDK's running guide puts the choice in one line: stdio for local servers, Streamable HTTP for "anything you deploy."
The local HTTP code here ran on 28 October 2026 with mcp 2.3.0, uvicorn 0.54.0, @modelcontextprotocol/server 2.4.0 and @modelcontextprotocol/node 2.1.2. Hosting steps are summarized from the platforms' docs; nothing was deployed to a public host for this lesson.
Key Takeaways
- Remote MCP servers use the Streamable HTTP transport, usually at a
/mcppath; the older HTTP+SSE transport is superseded. - Both SDKs refuse unknown
Hostheaders by default to block DNS rebinding, so you must allowlist your real hostname before going live. - On the 2026-07-28 protocol, requests are self-contained, so any instance can serve any request without sticky sessions.
- Multi-step tools (elicitation) carry a sealed
requestState; every instance must share the sealing keys. - A public server needs authorization (lesson 22), TLS, logging and rate limits, not just a URL.
Step 1: Serve the Python server over HTTP
The quickest way is one argument to run(), from the running guide:
if __name__ == "__main__":
mcp.run(transport="streamable-http", port=3001)
Clients then connect to http://127.0.0.1:3001/mcp. For a real deployment, build the ASGI app yourself so you can configure it and run it under uvicorn. This version adds a Host allowlist and a health route:
from mcp.server import MCPServer
from mcp.server.transport_security import TransportSecuritySettings
from starlette.requests import Request
from starlette.responses import PlainTextResponse
mcp = MCPServer("Bookshop")
CATALOG = {"Dune": "Frank Herbert", "Neuromancer": "William Gibson"}
@mcp.tool()
def search_books(query: str) -> list[str]:
"""Search the catalog by title or author."""
q = query.lower()
return [t for t, a in CATALOG.items() if q in t.lower() or q in a.lower()]
@mcp.custom_route("/health", methods=["GET"])
async def health(request: Request) -> PlainTextResponse:
return PlainTextResponse("ok")
security = TransportSecuritySettings(
allowed_hosts=["localhost:*", "127.0.0.1:*", "mcp.example.com", "mcp.example.com:*"],
allowed_origins=["https://app.example.com"],
)
app = mcp.streamable_http_app(transport_security=security)
Run it:
uv run uvicorn http_server:app --port 8765
The Python deploy guide explains the allowlist. Without transport_security, the app accepts only localhost Host headers. Behind a real hostname, that default "rejects every request until you say otherwise." The guide also notes that a custom health route is never authenticated, which suits a liveness probe but nothing private.
A local check confirmed both sides. A well-formed MCP request with Host: evil.example got HTTP/1.1 421 Misdirected Request, and the server logged "Invalid Host header." The same request with Host: localhost:8765 got 200 OK.
Step 2: Serve the TypeScript server over HTTP
The TypeScript SDK uses createMcpHandler, which takes a factory and returns a web-standard handler. The TypeScript HTTP guide mounts it on Node like this:
import { createServer } from 'node:http';
import { localhostHostValidation, localhostOriginValidation, toNodeHandler } from '@modelcontextprotocol/node';
import { createMcpHandler, McpServer } from '@modelcontextprotocol/server';
import * as z from 'zod/v4';
const CATALOG: Record<string, string> = { Dune: 'Frank Herbert', Neuromancer: 'William Gibson' };
const handler = createMcpHandler(() => {
const server = new McpServer({ name: 'bookshop', version: '1.0.0' });
server.registerTool(
'search_books',
{ description: 'Search the catalog by title or author', inputSchema: z.object({ query: z.string() }) },
async ({ query }) => {
const q = query.toLowerCase();
const hits = Object.entries(CATALOG).filter(([t, a]) => t.toLowerCase().includes(q) || a.toLowerCase().includes(q));
return { content: [{ type: 'text', text: hits.map(([t]) => t).join('\n') || 'No matches.' }] };
}
);
return server;
});
const nodeHandler = toNodeHandler(handler);
const validateHost = localhostHostValidation();
const validateOrigin = localhostOriginValidation();
createServer((req, res) => {
if (!validateHost(req, res) || !validateOrigin(req, res)) return;
void nodeHandler(req, res);
}).listen(3000, '127.0.0.1', () => console.error('MCP endpoint on http://127.0.0.1:3000/mcp'));
Install @modelcontextprotocol/node first. Key points from the guide:
- The factory runs once per HTTP request. Register tools inside it, and create database pools once at module level.
- The handler "validates no
Hostheader, noOriginheader, and no token." The guards in front of it do that job. The Express, Fastify and Hono helpers arm both checks by default on localhost binds. - By default the handler also serves 2025-era clients statelessly from the same factory.
The Inspector CLI called search_books successfully in both protocol eras against this server. A request with a foreign Host header got 403.
Step 3: Test the remote endpoint
Use the Inspector over HTTP, exactly as you did over stdio:
npx @modelcontextprotocol/inspector --cli --transport http \
--server-url http://127.0.0.1:3000/mcp \
--protocol-era modern --method tools/call \
--tool-name search_books --tool-args-json '{"query":"gibson"}' --format json
Then repeat with --protocol-era legacy. Both returned "Neuromancer" on 28 October 2026.
To add a remote server to Claude Code, Cloudflare's guide shows claude mcp add --transport http <name> <url>.
Step 4: Plan for more than one instance
The 2026-07-28 spec made scaling simpler. The Python deploy guide says a modern request is "one self-contained POST," with no session ID. So "any worker serves any request." Legacy clients differ. In the Python SDK's default mode, they hold a session in one worker's memory and need sticky sessions. Our spec reference on why MCP became stateless covers the background.
One thing still needs care: multi-step tools. When a tool asks the user a question (lesson 18), the server hands the client a sealed requestState token. The retry may land on a different instance. The Python guide's checklist is "same keys, same name":
from mcp.server.mcpserver import MCPServer, RequestStateSecurity
mcp = MCPServer(
"bookshop", # same name on every instance
request_state_security=RequestStateSecurity(keys=[SHARED_KEY]), # same 32+ byte secret
)
Generate the key once with python -c "import secrets; print(secrets.token_hex(32))" and store it as a secret. The TypeScript SDK has a matching createRequestStateCodec with a key of at least 32 bytes shared across instances, per its input_required guide.
Behind a TLS-terminating proxy, the Python guide says to run uvicorn with --proxy-headers --forwarded-allow-ips=<proxy address>. Otherwise redirects point at http://, and the Python client refuses to downgrade.
Step 5: Choose where to host it
Three common routes, summarized from each platform's docs on 28 October 2026:
| Option | How it works | Notes from the docs |
|---|---|---|
| Cloudflare Workers | npm create cloudflare@latest with the remote-mcp-authless template, then npx wrangler@latest deploy | Serves at https://<name>.<account>.workers.dev/mcp. The template uses a stateless createMcpHandler. Auth is added with Workers OAuth Provider. |
| Vercel | Add mcp-handler (v2.1.1) with @modelcontextprotocol/server v2 to a Next.js app at app/api/mcp/route.ts | Clients connect to /api/mcp. withMcpAuth verifies tokens; it "does not issue tokens." |
| Your own container or VM | Run the Python ASGI app under uvicorn or gunicorn, or the Node server under a process manager, behind a TLS proxy | You own TLS, scaling, logs and patching. |
Sources: Cloudflare's remote MCP server guide and Vercel's MCP deployment docs. Both platforms change quickly, so recheck commands before you follow them.
Step 6: Run a go-live checklist
Before you share the URL:
- Authorization. A server that touches private data needs OAuth. That is lesson 22.
- Host and Origin allowlists set to your real hostname.
- HTTPS only. The authorization spec's security considerations require authorization server endpoints to be served over HTTPS, and redirect URIs to be localhost or HTTPS. Serve the MCP endpoint over HTTPS too.
- Rate limits. The tools spec says servers must rate limit tool invocations.
- Logs that record who called which tool, without logging secrets.
- A smoke test from lesson 19, pointed at the deployed URL.
Try it
- Run the Python app under uvicorn with
--workers 2. - Call
search_booksseveral times with the Inspector in modern mode. Every call should succeed, whichever worker answers. - Add the lesson 18
order_booktool, then call it with quantity 5 from a test client. Note any "Invalid or expired requestState" errors. - Add
RequestStateSecurity(keys=[...])and run the test again.
Recap
- Remote means Streamable HTTP at a URL, usually
/mcp. - Allowlist your hostname; both SDKs block unknown Host headers by default.
- Modern requests need no sticky sessions; multi-step tools need shared
requestStatekeys. - Pick a host, then add auth, HTTPS, rate limits, logs and a smoke test.
Next step
Read the Streamable HTTP transport and DNS rebinding and local servers for the reasons behind these defaults. If you want a production deployment designed and reviewed, see eSEOspace MCP development.
Course navigation
Previous lesson: Lesson 20: Handle Errors Well.
Next lesson: Lesson 22: Add OAuth Sign-In.
Back to the Learn MCP course outline.
Frequently asked questions
Should I still support the old HTTP+SSE transport?
Only if you have clients that cannot move. The Python running guide says SSE was superseded by Streamable HTTP in the 2025-03-26 protocol revision and tells you not to build anything new on it.
Can a remote server still use stdio?
No. Stdio means the client launches your server as a local process. A remote server must listen on HTTP.
Do I need sticky sessions?
Not for 2026-07-28 clients. For 2025-era clients, the Python SDK needs them in its default mode, or you can use its stateless legacy mode and lose the server-to-client back-channel. TypeScript's createMcpHandler serves legacy clients statelessly by default.
Where should secrets like API keys live?
In your platform's secret store, read as environment variables. Never in the code or the tool descriptions, and never in error messages.
Is a health check route safe?
Only if it reveals nothing private. The Python guide notes custom routes are never authenticated, even when the rest of the server is.











