AI agents
Ren Okabe9 min read1 views

Remote MCP Server Tutorial (2026): Serve Tools over Streamable HTTP

A runnable 2026 tutorial for turning a local MCP server into a remote one over Streamable HTTP. Serve tools with FastMCP, test the endpoint with curl and MCP Inspector, validate the Origin header, add a bearer token, then connect Claude. Covers the Mcp-Session-Id requirement and the DNS-rebinding gotcha the docs warn about but most walkthroughs skip.

Isometric dark-mode diagram of a remote server node streaming HTTP requests and responses to a laptop client
Isometric dark-mode diagram of a remote server node streaming HTTP requests and responses to a laptop client
On this page

Python Anthropic

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 in 2026 the way to serve one is the Streamable HTTP transport.

This is a runnable walkthrough. 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 (July 2026)

To build a remote MCP server, write your tools with FastMCP and start it with mcp.run(transport="streamable-http"). That exposes a single HTTP endpoint (default http://127.0.0.1:8000/mcp) that accepts POST for client messages and GET for a server-to-client SSE stream. To make it genuinely remote you bind it behind an HTTPS reverse proxy, validate the Origin header on every request, require an Authorization token, and hand clients the public URL. Two things trip people up: clients must send Accept: application/json, text/event-stream, and a stateful server issues an Mcp-Session-Id header on initialize that the client must echo on every later call.

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/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+SSE transport from the 2024-11-05 spec. The mechanics are worth reading once at the source, because the header and session rules are strict: see the official MCP transports specification. The short version: 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.

1. Build the tool server

We will keep the tools deterministic so the whole thing runs with no API key. Install the official SDK, which bundles FastMCP:

bash
pip install "mcp[cli]"

Now the server. Note it is ordinary FastMCP code; nothing here is HTTP-specific yet.

python
# server.py
from mcp.server.fastmcp import FastMCP

# host and port decide where the HTTP endpoint binds.
# Keep 127.0.0.1 while developing; see the security section before exposing it.
mcp = FastMCP("remote-tools", host="127.0.0.1", port=8000)


@mcp.tool()
def add(a: int, b: int) -> int:
    \"\"\"Add two integers.\"\"\"
    return a + b


@mcp.tool()
def reverse(text: str) -> str:
    \"\"\"Reverse a string.\"\"\"
    return text[::-1]


if __name__ == \"__main__\":
    # This single line is the whole difference from a local stdio server.
    mcp.run(transport=\"streamable-http\")

Run it:

bash
python server.py

FastMCP now serves the MCP endpoint 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.

2. Prove it works with curl

Before wiring a real client, confirm the endpoint speaks MCP. The first message in any session is initialize. The request must set the Accept header to both content types and declare a protocol version:

bash
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 in the response. First, the body carries an InitializeResult with the server name and capabilities. Second, and easy to miss, the response headers include:

text
mcp-session-id: 1868a90c...

That session id is not decoration. If the server runs stateful sessions, every subsequent request (listing tools, calling a tool) must repeat that header, or the server answers 400 Bad Request. Send a request without Accept: text/event-stream and you will get an error too. These are the two failure modes that make a first remote connection look broken when the server is fine.

3. Inspect it visually with MCP Inspector

curl proves the wire protocol; MCP Inspector gives you a UI to browse tools and call them. Run it with no install:

bash
npx @modelcontextprotocol/inspector

In the 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 set to stdio by mistake.

4. 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 local MCP servers get hijacked:

  1. Validate the Origin header on every request. Without this, a malicious website in the user's browser can use DNS rebinding to reach a server bound to localhost and drive your tools. Reject requests whose Origin is not on your allow-list.
  2. Bind to 127.0.0.1, not 0.0.0.0, when the server is meant to be local. Only widen the bind when a proxy in front is doing TLS and auth.
  3. Require authentication for anything reachable off-box. At minimum an Authorization: Bearer the client must present; the MCP spec also defines an OAuth-based authorization flow for richer setups.

A practical pattern for a real deployment: run FastMCP 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 Origin and the bearer token before forwarding to /mcp. Your FastMCP process never faces the raw internet.

5. 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:

  • Bind FastMCP to the port your platform injects (read it from the environment), and set host=\"0.0.0.0\" only because the 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 and Origin check at the proxy.
  • Expose the /mcp path; that is the endpoint clients will 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 now reachable by any compliant client, anywhere.

6. 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:

bash
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. This is the same shape you would use to give an agent access to the remote tools:

python
# client.py
import asyncio
from mcp import ClientSession
from mcp.client.streamable_http import streamablehttp_client


async def main():
    url = \"http://127.0.0.1:8000/mcp\"
    async with streamablehttp_client(url) as (read, write, _):
        async with ClientSession(read, write) 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())

streamablehttp_client handles the Accept header, the session id round-trip, and the SSE parsing for you, which is why the client code stays this small. If you built a stdio server earlier and wired your first server to Claude, the only thing that changed is the transport line and the URL; the tool contract is identical.

Verify it end to end

With server.py running, run python client.py. You should see:

text
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 and add the bearer token, and the same client talks to the remote server.

Limitations and open questions

  • Deterministic tools, on purpose. add and reverse need no model so the tutorial runs anywhere. Real tools that call a model (for example Anthropic claude-sonnet-5, 2026) or hit a database change nothing about the transport, but they do introduce latency and cost you must budget for on a shared server.
  • Statefulness is a choice. FastMCP can run stateless (no Mcp-Session-Id) or stateful. 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 decision 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, not MCP.

Build the local version first, keep the tools deterministic while you learn the wire protocol, and only widen the bind once the Origin check and auth are in place. The remote transport is a thin layer over tools you already know how to write.

R

Written by

Ren Okabe

Frequently 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. In 2026 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+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?

Build the server with FastMCP from the official MCP Python SDK, then call mcp.run(transport='streamable-http'). By default it serves the MCP endpoint at http://127.0.0.1:8000/mcp. Set host and port on the FastMCP constructor to change where it binds. To go truly remote you put it 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. FastMCP manages this for you, 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 header 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. In practice you terminate TLS at a reverse proxy, require an Authorization bearer token or OAuth, and only then forward to the FastMCP process.

MCP servers

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.

5 min read88