How to Deploy a Remote MCP Server
A remote MCP server is one that other machines reach over the network, instead of a program each AI app starts on its own computer. It serves Streamable HTTP: each client message is a POST to one URL, usually /mcp. To deploy one, serve that URL over HTTP instead of stdio, allow only the hostnames you use, run it in a container, and put HTTPS in front of it. Add authorization before you share the URL. The Python and TypeScript code below does all of this for a bookmarks server, and you test it over HTTP with MCP Inspector.
What you'll have at the end
A bookmarks server in a container, answering on /mcp and reporting its health on /health. This is the health check from the container we ran for this guide:
curl http://localhost:8000/health
{"status":"ok"}
The proxy handles the certificate. Your server speaks plain HTTP inside the container, and it keeps its data on a volume so a restart doesn't lose it.
Before you start
- Python: Python 3.10 or later and uv. The container image uses Python 3.13.
- TypeScript: Node.js 22.19 or later, the version MCP Inspector needs. The container image uses Node 22.
- Docker, for step 5.
- A domain name you control, for step 6. You can skip it if you only want to run the server locally.
- The build guide explains the basics. You don't need it first, because the full code is below.
1. Set up the project
- Python
- TypeScript
Create a project folder and go into it:
mkdir bookmarks-remote
cd bookmarks-remote
Save this as pyproject.toml in that folder. The server imports starlette and uvicorn directly, so they're listed next to the MCP SDK.
[project]
name = "bookmarks"
version = "1.0.0"
requires-python = ">=3.10"
dependencies = [
"mcp[cli]>=2.3.0",
"starlette>=0.48.0",
"uvicorn>=0.31.1",
]
Then install the dependencies. This also creates uv.lock, which the container image uses to install the same versions.
uv sync
Create a project folder and go into it:
mkdir bookmarks-remote
cd bookmarks-remote
Save these two files in that folder. @modelcontextprotocol/server builds the MCP server, and @modelcontextprotocol/node connects it to Node's built-in HTTP server.
{
"name": "bookmarks",
"version": "1.0.0",
"type": "module",
"bin": {
"bookmarks": "./build/index.js"
},
"scripts": {
"build": "tsc && chmod 755 build/index.js"
},
"files": [
"build"
],
"dependencies": {
"@modelcontextprotocol/node": "^2.1.1",
"@modelcontextprotocol/server": "^2.3.1",
"zod": "^4.6.7"
},
"devDependencies": {
"@types/node": "^22.20.5",
"typescript": "^7.0.2"
}
}
{
"compilerOptions": {
"target": "ES2022",
"module": "Node16",
"moduleResolution": "Node16",
"types": ["node"],
"outDir": "./build",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true
},
"include": ["src/**/*"],
"exclude": ["node_modules"]
}
Then install the dependencies and create the source folder:
npm install
mkdir src
2. Write the server
A stdio server reads and writes on its standard streams, so only the app that launched it can talk to it. A remote server listens on a port, and it has to answer on a hostname, so it needs three things the stdio version doesn't: configuration from environment variables, a list of hostnames it accepts, and a health route for your platform to check.
- Python
- TypeScript
import json
import logging
import os
from pathlib import Path
from typing import Annotated
import uvicorn
from mcp.server import MCPServer
from mcp.server.transport_security import TransportSecuritySettings
from pydantic import Field
from starlette.requests import Request
from starlette.responses import JSONResponse, Response
# Logs go to stderr, which container platforms collect as well.
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger("bookmarks")
BOOKMARKS_FILE = Path(
os.environ.get("BOOKMARKS_FILE", Path.home() / ".bookmarks-mcp.json")
)
# 127.0.0.1 keeps the server private until you deploy it behind a proxy.
HOST = os.environ.get("HOST", "127.0.0.1")
PORT = int(os.environ.get("PORT", "8000"))
# Hostnames clients may use to reach this server, comma-separated.
ALLOWED_HOSTS = [
host.strip()
for host in os.environ.get("ALLOWED_HOSTS", "localhost,127.0.0.1").split(",")
if host.strip()
]
mcp = MCPServer("bookmarks")
def load_bookmarks() -> list[dict]:
if not BOOKMARKS_FILE.exists():
return []
return json.loads(BOOKMARKS_FILE.read_text())
def save_bookmarks(bookmarks: list[dict]) -> None:
BOOKMARKS_FILE.write_text(json.dumps(bookmarks, indent=2))
@mcp.tool()
def save_bookmark(
url: Annotated[str, Field(description="Full URL of the page, including https://")],
title: Annotated[str, Field(description="Short human-readable title for the page")],
tags: Annotated[
list[str] | None,
Field(description='Optional lowercase tags, e.g. ["recipes", "python"]'),
] = None,
) -> str:
"""Save a web page to the user's bookmarks.
Use this when the user asks to save, bookmark or remember a link.
"""
bookmarks = load_bookmarks()
if any(b["url"] == url for b in bookmarks):
return f"Already saved: {url}"
bookmarks.append({"url": url, "title": title, "tags": tags or []})
save_bookmarks(bookmarks)
logger.info("Saved bookmark %s", url)
return f"Saved '{title}'. Total bookmarks: {len(bookmarks)}"
@mcp.tool()
def search_bookmarks(
query: Annotated[
str, Field(description="One or more words to look for, case-insensitive")
],
) -> str:
"""Search saved bookmarks by words in the title, URL or tags."""
words = query.lower().split()
matches = [
b
for b in load_bookmarks()
if all(
w in " ".join([b["title"], b["url"], *b["tags"]]).lower() for w in words
)
]
if not matches:
return f"No bookmarks match '{query}'."
return "\n".join(f"- {b['title']}: {b['url']}" for b in matches)
# Plain HTTP route for load balancers and container health checks. It needs no MCP client.
@mcp.custom_route("/health", methods=["GET"])
async def health(request: Request) -> Response:
return JSONResponse({"status": "ok"})
# Each allowed hostname needs an entry with and without a port.
# Browser origins get the same hostnames, over http and https.
security = TransportSecuritySettings(
allowed_hosts=[entry for host in ALLOWED_HOSTS for entry in (host, f"{host}:*")],
allowed_origins=[
entry
for host in ALLOWED_HOSTS
for scheme in ("http", "https")
for entry in (f"{scheme}://{host}", f"{scheme}://{host}:*")
],
)
app = mcp.streamable_http_app(transport_security=security)
if __name__ == "__main__":
logger.info("Serving bookmarks on http://%s:%s/mcp", HOST, PORT)
uvicorn.run(app, host=HOST, port=PORT, proxy_headers=True)
Save this as server.py in the project folder. The key lines:
mcp.streamable_http_app(...)returns an ASGI app with the MCP endpoint at/mcp.uvicorn.runserves it. The SDK's ASGI guide describes the same app.TransportSecuritySettingslists the hostnames the server accepts. Without it, the SDK accepts only requests addressed to localhost.@mcp.custom_route("/health")adds a plain HTTP route for load balancers and health checks. It doesn't go through the MCP endpoint.
#!/usr/bin/env node
import { existsSync, readFileSync, writeFileSync } from "node:fs";
import { createServer } from "node:http";
import { homedir } from "node:os";
import { join } from "node:path";
import {
hostHeaderValidation,
originValidation,
toNodeHandler,
} from "@modelcontextprotocol/node";
import { createMcpHandler, McpServer } from "@modelcontextprotocol/server";
import { z } from "zod";
const BOOKMARKS_FILE =
process.env.BOOKMARKS_FILE ?? join(homedir(), ".bookmarks-mcp.json");
// 127.0.0.1 keeps the server private until you deploy it behind a proxy.
const HOST = process.env.HOST ?? "127.0.0.1";
const PORT = Number(process.env.PORT ?? "8000");
// Hostnames clients may use to reach this server, comma-separated.
const ALLOWED_HOSTS = (process.env.ALLOWED_HOSTS ?? "localhost,127.0.0.1")
.split(",")
.map((host) => host.trim())
.filter(Boolean);
if (!Number.isInteger(PORT) || PORT < 1 || PORT > 65535) {
throw new Error(`PORT must be a number from 1 to 65535, got "${process.env.PORT}"`);
}
interface Bookmark {
url: string;
title: string;
tags: string[];
}
function loadBookmarks(): Bookmark[] {
if (!existsSync(BOOKMARKS_FILE)) return [];
return JSON.parse(readFileSync(BOOKMARKS_FILE, "utf8")) as Bookmark[];
}
function saveBookmarks(bookmarks: Bookmark[]): void {
writeFileSync(BOOKMARKS_FILE, JSON.stringify(bookmarks, null, 2));
}
function text(value: string) {
return { content: [{ type: "text" as const, text: value }] };
}
// createMcpHandler calls this for every request, so each request gets its own server.
function createMcpServer() {
const server = new McpServer({ name: "bookmarks", version: "1.0.0" });
server.registerTool(
"save_bookmark",
{
description:
"Save a web page to the user's bookmarks. Use this when the user asks to save, bookmark or remember a link.",
inputSchema: z.object({
url: z.string().url().describe("Full URL of the page, including https://"),
title: z.string().describe("Short human-readable title for the page"),
tags: z
.array(z.string())
.optional()
.describe('Optional lowercase tags, e.g. ["recipes", "python"]'),
}),
},
async ({ url, title, tags }) => {
const bookmarks = loadBookmarks();
if (bookmarks.some((b) => b.url === url)) {
return text(`Already saved: ${url}`);
}
bookmarks.push({ url, title, tags: tags ?? [] });
saveBookmarks(bookmarks);
console.error(`Saved bookmark ${url}`);
return text(`Saved '${title}'. Total bookmarks: ${bookmarks.length}`);
},
);
server.registerTool(
"search_bookmarks",
{
description: "Search saved bookmarks by words in the title, URL or tags.",
inputSchema: z.object({
query: z.string().describe("One or more words to look for, case-insensitive"),
}),
},
async ({ query }) => {
const words = query.toLowerCase().split(/\s+/).filter(Boolean);
const matches = loadBookmarks().filter((b) => {
const haystack = [b.title, b.url, ...b.tags].join(" ").toLowerCase();
return words.every((w) => haystack.includes(w));
});
if (matches.length === 0) return text(`No bookmarks match '${query}'.`);
return text(matches.map((b) => `- ${b.title}: ${b.url}`).join("\n"));
},
);
return server;
}
const mcpHandler = createMcpHandler(createMcpServer);
const nodeHandler = toNodeHandler(mcpHandler, {
onerror: (error) => console.error("MCP request failed:", error),
});
const validateHost = hostHeaderValidation(ALLOWED_HOSTS);
const validateOrigin = originValidation(ALLOWED_HOSTS);
const httpServer = createServer(async (req, res) => {
// These guards answer rejected requests with 403 themselves.
if (!validateHost(req, res) || !validateOrigin(req, res)) return;
const { pathname } = new URL(req.url ?? "/", "http://localhost");
if (pathname === "/health") {
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({ status: "ok" }));
return;
}
if (pathname !== "/mcp") {
res.writeHead(404, { "Content-Type": "text/plain" });
res.end("Not found");
return;
}
await nodeHandler(req, res);
});
async function shutdown(): Promise<void> {
httpServer.close();
await mcpHandler.close();
process.exit(0);
}
process.on("SIGINT", () => void shutdown());
process.on("SIGTERM", () => void shutdown());
httpServer.listen(PORT, HOST, () => {
console.error(`Bookmarks MCP server on http://${HOST}:${PORT}/mcp`);
});
Save this as src/index.ts. The key lines:
createMcpHandlertakes a function that builds a freshMcpServer, and returns a handler.toNodeHandleradapts that handler to Node'shttpserver.hostHeaderValidationandoriginValidationcheck theHostandOriginheaders againstALLOWED_HOSTS. They answer a refused request with403themselves, so the code returns straight away.- The
/healthroute is handled before the MCP route, so it works without an MCP client.
Both versions read the same environment variables:
| Variable | Default | What it sets |
|---|---|---|
HOST | 127.0.0.1 | The address to listen on. The container image sets 0.0.0.0. |
PORT | 8000 | The port to listen on. |
ALLOWED_HOSTS | localhost,127.0.0.1 | Comma-separated hostnames without ports, such as mcp.example.com. Requests with any other Host header are refused. |
BOOKMARKS_FILE | ~/.bookmarks-mcp.json | The JSON file where bookmarks are stored. |
The default listen address is 127.0.0.1 on purpose. The MCP spec says a local server "SHOULD bind only to localhost (127.0.0.1) rather than all network interfaces (0.0.0.0)". You change it only in the container, where the port mapping is the way in.
Browsers send an Origin header, and the guard checks it too. Non-browser clients don't send one, and the TypeScript guard lets requests without an Origin header through.
3. Run it on your machine
Start the server and leave it running in its own terminal.
- Python
- TypeScript
uv run python server.py
INFO:bookmarks:Serving bookmarks on http://127.0.0.1:8000/mcp
INFO: Started server process [56228]
INFO: Waiting for application startup.
INFO:mcp.server.streamable_http_manager:StreamableHTTP session manager started
INFO: Application startup complete.
INFO: Uvicorn running on http://127.0.0.1:8000 (Press CTRL+C to quit)
npm run build
node build/index.js
Bookmarks MCP server on http://127.0.0.1:8000/mcp
In a second terminal, check the health route:
curl http://127.0.0.1:8000/health
{"status":"ok"}
Now send a request with a hostname the server doesn't allow. The Python SDK answers 421 Misdirected Request, and the TypeScript guard answers 403 Forbidden. Either way, the request never reaches your tools.
curl -i -H "Host: bookmarks.example.com" http://127.0.0.1:8000/mcp
HTTP/1.1 421 Misdirected Request
4. Test it with MCP Inspector over HTTP
MCP Inspector can connect to a URL the same way an AI app does. A URL ending in /mcp tells it to use Streamable HTTP. Run the list and call commands from the second terminal:
npx @modelcontextprotocol/inspector --cli http://127.0.0.1:8000/mcp --protocol-era modern --method tools/list
The --protocol-era flag picks which protocol era the Inspector negotiates. modern is the 2026-07-28 revision. legacy uses the 2025-era initialize handshake, which older apps still use, so run the same list again with --protocol-era legacy. Both runs should show save_bookmark and search_bookmarks. Then save a bookmark:
npx @modelcontextprotocol/inspector --cli http://127.0.0.1:8000/mcp --protocol-era modern \
--method tools/call --tool-name save_bookmark \
--tool-arg url=https://modelcontextprotocol.io --tool-arg title="MCP docs"
The response contains "text": "Saved 'MCP docs'. Total bookmarks: 1". By default the file is ~/.bookmarks-mcp.json on your machine. Set BOOKMARKS_FILE to change it.
5. Run it in a container
The container image needs two things that the local run didn't: it listens on every interface, and it keeps the bookmarks file on a volume.
- Python
- TypeScript
FROM python:3.13-slim
COPY --from=ghcr.io/astral-sh/uv:0.8.0 /uv /usr/local/bin/uv
WORKDIR /app
ENV UV_COMPILE_BYTECODE=1 \
UV_LINK_MODE=copy \
PATH="/app/.venv/bin:$PATH"
COPY pyproject.toml uv.lock ./
RUN uv sync --locked --no-install-project
COPY server.py ./
# Listen on all interfaces inside the container. Allow only the hostnames you serve.
ENV HOST=0.0.0.0 \
PORT=8000 \
BOOKMARKS_FILE=/data/bookmarks.json
RUN useradd --create-home --uid 10001 app \
&& mkdir /data \
&& chown app /data
USER app
EXPOSE 8000
CMD ["python", "server.py"]
FROM node:22-slim AS build
WORKDIR /app
COPY package.json package-lock.json tsconfig.json ./
RUN npm ci
COPY src ./src
RUN npm run build
FROM node:22-slim
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev \
&& mkdir /data \
&& chown node /data
COPY --from=build /app/build ./build
# Listen on all interfaces inside the container. Allow only the hostnames you serve.
ENV NODE_ENV=production \
HOST=0.0.0.0 \
PORT=8000 \
BOOKMARKS_FILE=/data/bookmarks.json
USER node
EXPOSE 8000
CMD ["node", "build/index.js"]
Save the file as Dockerfile in the project folder, then build and run it. Stop the local server from step 3 first, so port 8000 is free:
docker build -t bookmarks-remote .
docker run -d --name bookmarks -p 8000:8000 -v bookmarks-data:/data bookmarks-remote
curl http://localhost:8000/health
The -v bookmarks-data:/data flag stores the bookmarks in a Docker volume. Without it, the file lives inside the container and is lost when the container is removed.
Check the container with the same Inspector command from step 4, changing the URL to http://localhost:8000/mcp. To hand the server a real hostname, remove the test container and start it again with ALLOWED_HOSTS set:
docker rm -f bookmarks
docker run -d --name bookmarks -p 8000:8000 -v bookmarks-data:/data \
-e ALLOWED_HOSTS=mcp.example.com \
bookmarks-remote
Keep HOST at 0.0.0.0 inside the container. If you set it to 127.0.0.1, the port mapping has nothing to connect to, and curl exits with code 56.
6. Serve it on a hostname over HTTPS
Deploying the container on a host gives it an address. Clients need an HTTPS URL on a name you control, so put a TLS proxy or load balancer in front of the container. Any reverse proxy or managed load balancer that terminates HTTPS and forwards to port 8000 will do. The steps are the same for each:
- Point a DNS record, such as
mcp.example.com, at the proxy. - Set
ALLOWED_HOSTS=mcp.example.comon the container, as in step 5. - Forward
https://mcp.example.com/mcptohttp://<container>:8000/mcp. - Make sure the proxy passes the public
Hostheader through unchanged. If it rewrites the header to the internal name, the host check refuses every request.
The Python server reads X-Forwarded-* headers only from the addresses listed in FORWARDED_ALLOW_IPS. Set that to your proxy's address. The TypeScript example doesn't read forwarded headers, so it has no equivalent setting. The SDK's deploy guide explains why this matters.
7. Add authorization before you share the URL
This example has no sign-in. Anyone who can reach the URL can read and add bookmarks. Keep it on a private network, or add authorization before you share the URL. The spec says "Servers SHOULD implement proper authentication for all connections." Authorization for remote MCP servers covers the OAuth side.
8. Connect an AI app to the URL
In your AI app, add https://mcp.example.com/mcp as a remote server. Each app has its own place for this, and the connect guides show it step by step. See Cursor, Claude Code and Codex.
Common problems
421 Misdirected Request or 403 Forbidden on every request.
The Host header isn't in ALLOWED_HOSTS. The Python SDK refuses it with 421 and logs Invalid Host header. The TypeScript guard refuses it with 403. Set ALLOWED_HOSTS to the hostname your clients use, without a port, and restart the server.
{"jsonrpc":"2.0","error":{"code":-32000,"message":"Invalid Origin: evil.example.com"},"id":null}
A browser sent an Origin header that isn't in the allowlist, so the server refused the request with 403. Non-browser clients don't send Origin, so this usually means a web page is calling your server. If you own that page, add its hostname to ALLOWED_HOSTS and restart. If you don't, the refusal is the server doing its job.
curl exits with code 56 when you connect to the container.
The server inside the container is listening on 127.0.0.1. Start the container with HOST=0.0.0.0, which the image already sets, or remove any -e HOST= override.
Bookmarks disappear after you recreate the container.
The bookmarks file isn't on a volume. Run the container with -v bookmarks-data:/data, as in step 5. The Docker volume keeps the file when the container restarts.
MCPError: Redirect to http://... not followed: it would downgrade this HTTPS endpoint to plain HTTP.
This comes from the Python client when the server sits behind a TLS proxy. The proxy forwards plain HTTP, so the server's redirects point at http://, and the client refuses to follow them. The server already turns on proxy headers. Set FORWARDED_ALLOW_IPS to your proxy's address, as step 6 describes. The SDK's deploy guide covers the same fix.
FAQ
Is a remote MCP server the same as a REST API?
No. A remote MCP server speaks the MCP protocol over HTTP, so an AI app can list its tools and call them without custom integration code. A REST API usually needs a separate integration for each app. See MCP vs API for the full comparison.
Should my server use stdio or Streamable HTTP?
Use stdio when the AI app starts the server on the same computer. Use Streamable HTTP when other machines or hosted apps need to reach it. See Local vs remote MCP servers for the trade-offs.
Why does my server answer 421 or 403 to every request?
The server refuses requests whose Host header isn't in its allowlist. Add the hostname your clients use to ALLOWED_HOSTS, without a port, and restart the server.
Can I run several copies of this server behind a load balancer?
Not with this example. Its bookmarks live in one JSON file, so each copy would keep its own. Modern MCP requests carry no session state, so the protocol doesn't stop you from running more copies. The shared storage does.
- Authorization for remote serversRequire a token before your server answers anyone.
- Run an MCP server in productionLogging, tracing, timeouts, rate limits and changing tools safely.
- Test with MCP InspectorUse the web UI and CLI to check tools, resources and prompts.
- Local vs remote serversChoose between stdio and Streamable HTTP for your server.
- Publish to the MCP RegistryList your server where clients and directories can find it.