Remote MCP Server in Python (2026): Serve Tools over Streamable HTTP
A runnable 2026 tutorial for turning a local Python MCP server into a remote one over Streamable HTTP. Serve tools, prove the endpoint with curl and MCP Inspector, switch on the DNS-rebinding protection that ships off by default, add a bearer token, then connect a client. Covers the Mcp-Session-Id requirement and the host and port arguments that moved off the constructor between SDK majors.
Updated on September 21, 2026
On this page
Corrected September 21, 2026. This tutorial previously taught the Python MCP SDK 1.x API, and on a fresh install it raised in four separate places: the import, the constructor, the client function name, and the client tuple unpack. It now teaches the API that pip install mcp actually gives you, with the 1.x form kept as a supported alternative rather than a fallback. What was wrong and where each fact was verified is in how the code on this page was checked.
A local MCP server is easy: your client launches it as a subprocess and talks to it over stdin and stdout. That falls apart the moment the server needs to live somewhere else, on a box in the cloud, shared by a team, or reachable by an agent that is not running on your laptop. For that you need a remote MCP server, and the way to serve one is the Streamable HTTP transport.
This is a runnable walkthrough in Python. You will take a small tool server, serve it over HTTP, prove it works with curl and MCP Inspector, close the security holes the spec warns about, and connect a client. If you have never built a server at all, start with building an MCP server in Python first; this tutorial changes only the transport, not the tools.
Quick answer (September 2026)
A remote MCP server is an ordinary long-lived HTTP service. Write your tools once, serve them over the Streamable HTTP transport, and you get a single endpoint (http://127.0.0.1:8000/mcp by default) that accepts POST for client messages and GET for a server-to-client SSE stream. To make it genuinely remote, bind it behind an HTTPS reverse proxy, validate the Origin and Host headers on every request, require an Authorization bearer token, and hand clients the public URL.
Three things trip people up, and none of them is about which SDK release you run. Clients must send Accept: application/json, text/event-stream. A stateful server issues an Mcp-Session-Id header on initialize that the client must echo on every later call. And DNS-rebinding protection is off unless you switch it on.
One thing is release-specific and it is worth settling before you write a line: where you pass host and port. Check which major of the Python mcp package you have first.
stdio vs remote: pick the transport, not the framework
The Model Context Protocol defines two standard transports, and the choice is about where the server runs, not what it does.
- stdio: the client spawns the server as a child process and pipes JSON-RPC over stdin and stdout. One client, one process, same machine. This is the right default for a tool that ships alongside the client.
- Streamable HTTP: the server is an independent process that can handle many clients over the network. It exposes one endpoint that answers POST and GET. This is what you use for anything remote or shared.
Streamable HTTP replaced the older HTTP plus SSE transport from the 2024-11-05 spec. The header and session rules are strict enough to be worth reading once at the source: see the official MCP transports specification. The short version is that the client POSTs a JSON-RPC message to the endpoint with Accept: application/json, text/event-stream, and the server answers either with a single JSON object or by opening an SSE stream and sending the response as an event.
That contract is transport-level. It has not changed across the SDK releases below, which is why your tool code and your curl probes stay the same while the server setup moves around.
check which major of the Python SDK you have
One command, and it decides the shape of every server block on this page:
python -c "import importlib.metadata as m; print(m.version('mcp'))"
The MCP Python SDK shipped 2.0.0 on 28 July 2026, and a plain
pip install "mcp[cli]" has resolved to the 2.x line ever since. The current release is 2.2.0 (7 September 2026). The 1.x line is still maintained and still moving: 1.30.0 shipped on the same day as 2.2.0.
For a remote HTTP server, three things differ between the majors:
Scroll to see more
| 1.x | 2.x | |
|---|---|---|
| Import | from mcp.server.fastmcp import FastMCP | from mcp.server.mcpserver import MCPServer |
| Class | FastMCP("remote-tools") | MCPServer("remote-tools") |
Where host and port go | on the constructor | on run() |
That third row is the one that catches people, because it survives the rename. Fix your import, fix the class name, and MCPServer("remote-tools", host="127.0.0.1", port=8000) still raises, since the 2.x constructor has no host or port parameter and no catch-all for unknown keywords:
TypeError: MCPServer.__init__() got an unexpected keyword argument 'host'
Every transport-specific setting moved the same way: host, port, streamable_http_path, json_response, stateless_http and transport_security are all keyword arguments to run() now. The vendor's migration guide has a section for exactly this, transport-specific parameters moved from the MCPServer constructor to run(). Related and easy to miss on a deploy: 2.x no longer reads the SDK's own environment variables or .env files at all, so if you were configuring the bind that way, read the value yourself and pass it to run().
The code below is written for 2.x, which is what a fresh install gives you. If you are on 1.x and want to stay there, that is a legitimate choice: add an upper bound so a rebuilt container cannot move you across the boundary without warning.
mcp~=1.30
That is the compatible-release operator, meaning "at least 1.30, and stay on the 1.x major". Prefer it over the more obvious form with a less-than sign for a practical reason: an unquoted less-than sign on a shell command line is a redirect, not a version bound, so pip install quietly reads from a file instead of installing what you meant.
install the SDK
We will keep the tools deterministic so the whole thing runs with no API key. Install the official SDK, which bundles the server class and the mcp command:
pip install "mcp[cli]"
The cli extra still exists on 2.x, and mcp dev, mcp run, mcp install and mcp version all survive the migration.
build the tool server
Note that the tools themselves are ordinary code; nothing here is HTTP-specific.
# server.py
from mcp.server.mcpserver import MCPServer
# The constructor takes the server name. Where the HTTP endpoint binds is
# decided at run() time, not here.
mcp = MCPServer("remote-tools")
@mcp.tool()
def add(a: int, b: int):
"""Add two integers."""
return a + b
@mcp.tool()
def reverse(text: str):
"""Reverse a string."""
return text[::-1]
if __name__ == "__main__":
# This block is the whole difference from a local stdio server.
mcp.run(
transport="streamable-http",
host="127.0.0.1",
port=8000,
)
Two details worth knowing before you copy this. The parentheses on @mcp.tool() are not optional: it is a decorator factory, and 2.x raises a named TypeError telling you so if you forget them. And every parameter needs a type annotation, because that is what becomes the input schema; a return annotation is optional and only becomes mandatory if you force structured_output=True, which is why the tools above are annotated on the way in and not on the way out.
Keep 127.0.0.1 while developing. Read the security step before you widen that bind.
run it and prove it with curl
python server.py
The MCP endpoint is now at http://127.0.0.1:8000/mcp. The tools (add, reverse) are exactly what a stdio server would expose. Only the transport changed, which is the point: your tool code is portable across transports.
Before wiring a real client, confirm the endpoint speaks MCP. The first message in any session is initialize, and the request must set Accept to both content types:
curl -i http://127.0.0.1:8000/mcp \
-H "Content-Type: application/json" \
-H "Accept: application/json, text/event-stream" \
-H "MCP-Protocol-Version: 2025-06-18" \
-d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"curl","version":"1.0"}}}'
Two things to look for. First, the body carries an InitializeResult with the server name and capabilities. Second, and easy to miss, the response headers include:
mcp-session-id: 1868a90c...
That session id is not decoration. If the server runs stateful sessions, every subsequent request must repeat that header or the server answers 400 Bad Request; if the session has expired you get 404, which tells the client to re-initialize. Send a request without Accept: text/event-stream and you get an error too. These are the two failure modes that make a first remote connection look broken when the server is fine.
inspect it visually with MCP Inspector
curl proves the wire protocol; MCP Inspector gives you a UI to browse tools and call them. It is a separate tool from the SDK and needs no install:
npx @modelcontextprotocol/inspector
Choose the Streamable HTTP transport, enter http://127.0.0.1:8000/mcp, and connect. You should see add and reverse under Tools. Call add with a=2, b=3 and confirm you get 5 back. If Inspector connects but the tool list is empty, your decorators are not registered; if it will not connect at all, the transport dropdown is usually still set to stdio.
close the security holes before you expose it
A server bound to 127.0.0.1 is safe because only your machine can reach it. The instant you bind 0.0.0.0 or put it on a public host, three protections become mandatory. The spec states them plainly, and skipping them is how MCP servers get hijacked:
- Validate the
OriginandHostheaders on every request. Without this, a malicious website open in the user's browser can use DNS rebinding to reach a server bound to localhost and drive your tools. - Bind to
127.0.0.1, not0.0.0.0, when the server is meant to be local. Only widen the bind when something in front is doing TLS and auth. - Require authentication for anything reachable off-box. At minimum an
Authorizationheader carrying a bearer token that the client must present; the MCP spec also defines an OAuth-based authorization flow for richer setups.
The SDK ships the header check, and this is the part most walkthroughs skip: it is off by default. The middleware disables DNS-rebinding protection when you pass it nothing, for backwards compatibility. Construct the settings explicitly and it turns on:
# server.py, hardened
from mcp.server.mcpserver import MCPServer
from mcp.server.transport_security import TransportSecuritySettings
mcp = MCPServer("remote-tools")
@mcp.tool()
def add(a: int, b: int):
"""Add two integers."""
return a + b
if __name__ == "__main__":
mcp.run(
transport="streamable-http",
host="0.0.0.0",
port=8000,
transport_security=TransportSecuritySettings(
allowed_hosts=["tools.example.com"],
allowed_origins=["https://app.example.com"],
),
)
TransportSecuritySettings exists on both majors and behaves the same way, so this is not a migration change; only where you pass it moved, from the constructor to run(). Allowed hosts accept a :* suffix to match any port.
That covers headers. It does not cover TLS or auth, so a practical pattern for a real deployment is still: run the server bound to 127.0.0.1, put a reverse proxy (Caddy, nginx, or your platform's built-in) in front to terminate HTTPS, and have the proxy check the bearer token before forwarding to /mcp. Your MCP process never faces the raw internet, and the SDK's own header check becomes a second line rather than the only one.
deploy it remotely
Deployment is now a normal web-service problem. Any host that can run a long-lived Python process and give it a public HTTPS URL works (Fly.io, Render, Railway, a plain VM). The checklist that is specific to MCP:
- Read the port your platform injects from the environment yourself and pass it to
run(). On 2.x the SDK will not pick it up from an environment variable for you. - Set
host="0.0.0.0"only because a proxy, not the raw internet, sits in front. - Terminate TLS at the edge so clients connect to
https://your-host/mcp. - Enforce the bearer token at the proxy, and set
allowed_hostsandallowed_originsto your real public names. - Expose the
/mcppath; that is the endpoint clients point at.
Once it is live, the public MCP endpoint is just a URL. That is the whole payoff of the remote transport: the same tools you tested locally are reachable by any compliant client, anywhere.
connect a client
The fastest way to use a remote server is from a client that already speaks MCP. In Claude Code you add it by URL; see the Anthropic MCP docs for the current flags:
claude mcp add --transport http remote-tools https://your-host.example.com/mcp
To drive it from your own code, the Python SDK ships a Streamable HTTP client:
# client.py
import asyncio
from mcp import ClientSession
from mcp.client.streamable_http import streamable_http_client
async def main():
url = "http://127.0.0.1:8000/mcp"
async with streamable_http_client(url) as (read_stream, write_stream):
async with ClientSession(read_stream, write_stream) as session:
await session.initialize()
tools = await session.list_tools()
print("tools:", [t.name for t in tools.tools])
result = await session.call_tool("add", {"a": 2, "b": 3})
print("add(2, 3) =", result.content[0].text)
if __name__ == "__main__":
asyncio.run(main())
The function handles the Accept header, the session id round-trip and the SSE parsing for you, which is why the client stays this small.
Two client-side changes are worth flagging, because both hit remote servers specifically and one of them does not raise. The function used to be called streamablehttp_client and it used to yield three values; on 2.x the name carries underscores throughout and it yields two, so a three-name unpack fails with a plain not enough values to unpack (expected 3, got 2). That one announces itself. The quiet one is timeouts: headers, timeout and auth are no longer arguments to this function, they belong to an HTTP client you construct and pass as http_client, and the moment you pass your own the SDK stops applying its 300-second read timeout for long-lived streams. That is exactly the case a remote authenticated server is in. What the rename broke, and the dropped read timeout that bites has the before-and-after and the correct timeout arguments.
If you built a stdio server earlier and wired your first server to Claude, the only thing that changed is the transport and the URL; the tool contract is identical.
Verify it end to end
With server.py running, run python client.py. You should see:
tools: ['add', 'reverse']
add(2, 3) = 5
That output is the full loop: a client, over HTTP, discovered your tools and executed one. Swap 127.0.0.1:8000 for your deployed HTTPS URL, add the bearer token to your HTTP client, and the same code talks to the remote server.
How the code on this page was checked
Worth being explicit, because this page was wrong before and you should be able to tell how confident to be now.
There is no pip and no uv on the machine this was written on, and the mcp package is not installed there. So the SDK surface was read from the vendor's published source at tag v2.2.0, with 1.x comparisons at tag v1.30.0, rather than from an install. Each block was then assembled into a real file and executed on Python 3.13.5 against a stub mirroring those verified signatures. Both tool registrations succeed, the client prints the two lines above, and every block parses.
The four things that were wrong, and what each one does now:
Scroll to see more
| Old code | What happens on 2.x |
|---|---|
from mcp.server.fastmcp import FastMCP | ModuleNotFoundError from a stub module that exists only to name the rename and link the migration guide |
FastMCP("remote-tools", host=..., port=...) | TypeError, unexpected keyword argument host |
from mcp.client.streamable_http import streamablehttp_client | ImportError, the name is gone |
async with ...(url) as (read, write, _) | ValueError, expected 3 values and got 2 |
You can read the first one for yourself: the stub module at v2.2.0 is sixteen lines long and its whole job is to raise a better error than "No module named". The release dates come from the package's own release history.
What is not a migration change, because it is tempting to report as one: the return-annotation rule and TransportSecuritySettings behave identically on 1.30.0 and 2.2.0.
Limitations and open questions
- Deterministic tools, on purpose.
addandreverseneed no model so the tutorial runs anywhere. Real tools that call a model (for example Anthropicclaude-sonnet-5, 2026) or hit a database change nothing about the transport, but they introduce latency and cost you must budget for on a shared server. - Statefulness is a choice. The server can run stateless (no
Mcp-Session-Id) or stateful, and on 2.x that is thestateless_httpargument torun(). Stateless scales horizontally more easily; stateful lets you keep per-session context. Which you want depends on whether your tools need memory across calls, and that is not obvious until you have real traffic. - Auth here is the floor, not the ceiling. A shared bearer token is fine for a private team server. Multi-tenant or user-scoped access needs the OAuth flow the spec defines, plus per-token authorization on each tool, which is a real project on its own.
- SSE and infrastructure. Streamable HTTP leans on long-lived SSE connections. Some proxies, load balancers and serverless platforms buffer or time out streams; if responses arrive late or truncated, the culprit is almost always infra buffering rather than MCP. The client-side read timeout above is the other half of that story.
- Verified by reading, not by installing. The signatures and error messages here come from source at a tagged release plus local execution against a stub of it. That is strong on what the API is and weaker than a full install would be on how a real deployment behaves under load.
Build the local version first, keep the tools deterministic while you learn the wire protocol, and only widen the bind once the header checks and auth are in place. The remote transport is a thin layer over tools you already know how to write.
Written by
Ren OkabeFrequently asked questions
What is a remote MCP server?
A remote MCP server is a Model Context Protocol server that a client reaches over the network instead of launching as a local subprocess. The standard way to serve one is the Streamable HTTP transport: the server exposes a single HTTP endpoint (for example https://your-host/mcp) that accepts POST and GET requests, and can stream responses back using Server-Sent Events. Local servers use the stdio transport instead.
What is the Streamable HTTP transport in MCP?
Streamable HTTP is one of the two standard MCP transports as of the 2025-06-18 spec (stdio is the other). The server provides one endpoint that supports both POST (client sends a JSON-RPC message) and GET (client opens an SSE stream to receive server messages). It replaced the older HTTP plus SSE transport from the 2024-11-05 spec. Clients must send an Accept header listing both application/json and text/event-stream.
How do I serve an MCP server over HTTP in Python?
Write your tools with the official MCP Python SDK, then start the server with the streamable-http transport. It serves the MCP endpoint at http://127.0.0.1:8000/mcp by default. Where you set the bind address depends on which major of the mcp package you installed, so check that first with: python -c "import importlib.metadata as m; print(m.version('mcp'))". On 2.x the class is MCPServer, imported from mcp.server.mcpserver, and host and port are keyword arguments to run(). On 1.x the class is FastMCP, imported from mcp.server.fastmcp, and host and port are constructor arguments. Fixing only the import and the class name is not enough, because a 2.x constructor given host raises TypeError. To go truly remote you put the server behind an HTTPS reverse proxy and require authentication.
What is the Mcp-Session-Id header for?
When a server wants stateful sessions it returns an Mcp-Session-Id header on the initialize response. The client must then echo that header on every later request. If the server requires a session and you omit the header it returns 400 Bad Request; if the session has expired it returns 404, which tells the client to re-initialize. The SDK manages this for you on both the server and client side, but you will see the header when you test with curl.
How do I secure a remote MCP server?
The MCP spec is explicit: validate the Origin and Host headers on every connection to prevent DNS-rebinding attacks, bind to 127.0.0.1 rather than 0.0.0.0 when running locally, and implement real authentication for any connection that is reachable off-box. The Python SDK ships the header check as TransportSecuritySettings, and the important detail is that it is disabled by default for backwards compatibility, so you have to construct and pass the settings for it to do anything. That covers headers only. For TLS and auth you terminate TLS at a reverse proxy, require an Authorization bearer token or OAuth, and only then forward to the MCP process.
Related tutorials
How to Build an MCP Server in Python (2026 Tutorial)
A runnable 2026 tutorial: build a write-capable MCP server in Python with the official MCP SDK. Add tools with @mcp.tool(), back them with SQLite, test in the Inspector, and connect it to Claude for Desktop. Includes the 1.x and 2.x import difference and how to check which you have.
How to Build an MCP Server (2026): Your First Server, Wired to Claude
The Model Context Protocol (MCP) is a standard way to expose tools to any MCP-capable client, Claude Desktop, IDEs, or your own agents, so you write an integration once and reuse it everywhere. In this tutorial you build an MCP server in TypeScript that exposes a single typed tool over the stdio transport, test it with the MCP Inspector, then register it with Claude and call it from a real conversation. You will also learn the one rule that trips up everyone on stdio: never write to stdout. By the end you have a reusable server you can extend with your own tools.
MCP Error -32000 Connection Closed: What It Actually Means (2026)
MCP error -32000: Connection closed is generated inside the client SDK and is never sent by a server. It means your MCP server process died. Here is how to recover the real error, and a measured correction to the stdout advice (2026).