Skip to main content

How to Deploy a Remote MCP Server

Short answer

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.

Tested withMCP spec 2026-07-28Python SDK (mcp) 2.3.0TypeScript SDK (@modelcontextprotocol/server) 2.3.1 and (@modelcontextprotocol/node) 2.1.1MCP Inspector 2.11.0Node.js 24.19 (the Inspector needs 22.19 or later)Docker Engine 28.4Checked 20 Oct 2026

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​

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.

pyproject.toml
[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

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.

server.py
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.run serves it. The SDK's ASGI guide describes the same app.
  • TransportSecuritySettings lists 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.

Both versions read the same environment variables:

VariableDefaultWhat it sets
HOST127.0.0.1The address to listen on. The container image sets 0.0.0.0.
PORT8000The port to listen on.
ALLOWED_HOSTSlocalhost,127.0.0.1Comma-separated hostnames without ports, such as mcp.example.com. Requests with any other Host header are refused.
BOOKMARKS_FILE~/.bookmarks-mcp.jsonThe 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.

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)

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.

Dockerfile
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"]

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:

  1. Point a DNS record, such as mcp.example.com, at the proxy.
  2. Set ALLOWED_HOSTS=mcp.example.com on the container, as in step 5.
  3. Forward https://mcp.example.com/mcp to http://<container>:8000/mcp.
  4. Make sure the proxy passes the public Host header 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​

warning

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.