Chapter 7: Going Remote
Going remote is where an MCP server stops being just a convenient local integration and starts behaving like a network service. That sounds obvious, but it is easy to underestimate what changes. The work looks like mostly “put HTTP in front of it,” plus maybe a reverse proxy later. Then the first shared rollout arrives, a client retries discovery twice, a proxy strips a header the transport needed, the auth flow is half-finished, and suddenly the problem is not tools or prompts at all. The problem is that the server has crossed a trust boundary without being designed for it.
A local MCP server and a remote MCP server can expose the same tools, resources, and prompts, but they live in different operational worlds. Local stdio assumes one host process launching one server process on the same machine. Remote transport assumes networks, load balancers, timeouts, authorization, multiple users, and infrastructure that can fail in ways your local prototype never saw.
The good news is that the protocol does not force you to redesign your entire application. If your server is structured well, most of the domain logic survives intact. Tool handlers still validate input, call downstream systems, and return structured results. What changes is the transport layer and everything attached to it: identity, deployment, observability, health signaling, and blast radius.
Do not go remote because it feels more “real.” Go remote because the problem now requires shared access, centralized control, or service-grade operations.
The worst first remote rollout is the ambitious one: new transport, new auth, new multi-tenant model, big tool surface, and a half-tested proxy chain all introduced at once. This chapter’s rule is the opposite: ship the first remote release with one server, one audience, one auth path and no cross-call state the server did not mint. By the end you will have taken Chapter 5’s notes server remote under exactly that rule.
When Remote Is Actually the Right Move
Remote deployment is the right move when the capability itself should live as a service rather than a per-user local process.
That usually happens in four situations.
The first is multi-user access. If several users or several internal agents need the same system integration, a remote MCP server prevents duplicated configuration and inconsistent policy. One deployment can enforce the same tool behavior, permissions, rate limits, and logging for every client.
The second is centralized data or secrets. Some capabilities should not be copied onto end-user laptops at all. Internal ticket systems, finance APIs, production deployment controls, proprietary indexes, and customer data platforms are all safer when the MCP server runs near the protected systems and exposes only the narrow interfaces the model needs.
The third is operator ownership. A local server is often “best effort.” A remote MCP server is usually expected to be patchable, observable, and governed. That matters in enterprises, but it also matters in small teams once the server becomes part of a real workflow.
The fourth is service composition. Some MCP servers need access to infrastructure that already assumes networked services: managed databases, internal APIs, queues, object stores, or shared caches. Once the work depends on those systems anyway, keeping the MCP server local may add awkward tunnel logic without adding meaningful safety.
Just as important are the reasons not to go remote. If the server is a personal filesystem helper, an IDE-local code utility, or a one-user automation shim, stdio is often the better design. The MCP specification still says clients should support stdio whenever possible, and that is a useful signal. Local transport is often the correct production option for single-machine workflows, not a beginner option, because it avoids network overhead, avoids remote auth flows, and keeps the trust boundary small.
Remote is not more mature. Remote is more expensive and more exposed. Use it when that tradeoff buys something real.
What Changes and What Should Stay the Same
The protocol primitives do not change when you go remote. You still expose tools, resources, and prompts. You still answer discovery and the same tools/*, resources/* and prompts/* requests. You still want clear names, narrow schemas, predictable results, and error messages that help the model recover.
What should stay the same:
- domain logic,
- input validation,
- output shape,
- capability design,
- and most of the code that actually talks to downstream systems.
What changes:
- the transport,
- the process model,
- request handling and any cross-call state,
- authorization,
- infrastructure headers and proxy behavior,
- deployment packaging,
- health signaling,
- and your tolerance for network failure.
If your existing server cannot make that transition without major surgery, the design problem is usually coupling. A good MCP server keeps the transport adapter thin. Your stdio entrypoint or your HTTP entrypoint should mostly wire the same server implementation into different transports.
That separation matters because local and remote failures are different. In stdio, common failures include startup issues, missing environment variables, wrong working directories, and accidental stdout logging that corrupts the JSON-RPC stream. In remote deployments, the common failures are more distributed: dropped connections, stripped Mcp-Method headers, bad TLS termination, expired tokens, proxy buffering, and misconfigured health checks.
You want one business layer and two transport-specific reliability stories.
Streamable HTTP Is the Modern Remote Default
For remote deployments, the current protocol center is Streamable HTTP. In the 2025-03-26 specification, it replaced the older HTTP-plus-SSE design as the standard remote transport. That is not a cosmetic rename. The spec defines one MCP endpoint, with JSON-RPC messages sent by HTTP POST and optional SSE streams used for streaming a response.
The 2026-07-28 revision made this transport much closer to ordinary HTTP; Appendix A §Wire Examples shows the exact bytes. The rules that matter in practice:
- clients send JSON-RPC messages with HTTP POST, including
Mcp-MethodandMcp-Nameheaders so proxies and gateways can route without parsing the body, - clients must advertise support for
application/jsonandtext/event-stream, - servers can respond with plain JSON or an SSE stream scoped to that one request,
- there is no
initializehandshake and noMcp-Session-Id: every request carries its protocol version and client capabilities in_meta, - the standalone HTTP GET stream is gone; clients that want change notifications open a
subscriptions/listenrequest instead, - and there is no
Last-Event-IDresumability: if a stream breaks, the client re-sends the request with a new ID.
If you are reading older material (or supporting older clients), revisions up to 2025-11-25 had the handshake, Mcp-Session-Id sessions, the GET stream, and resumability. The current SDKs can speak both.
A basic remote MCP server can be quite simple. If you do not need server-initiated notifications or streaming behavior, you can run in JSON-response mode and treat each request as ordinary HTTP. If you do need progress updates on a single request, SSE remains available inside the Streamable HTTP model. Elicitation no longer needs a stream at all: the server returns an InputRequiredResult and the client retries.
Even so, one of the first real architecture choices when going remote is still how much state your application keeps.
A stateless server keeps the mental model close to a normal API. Every request contains what the server needs, and there is no server-maintained session state beyond ordinary auth and backend state. This tends to be easier to scale, easier to debug behind load balancers, and easier to cache.
Under 2026-07-28 the protocol itself is stateless, so application state that must span calls has to be explicit: the server mints a handle (a cart ID, a job ID, a workspace ID) and the model passes it back as an ordinary tool argument. That keeps any server instance able to serve any request, but it means you design where that state lives and how long it is valid. (Under 2025-11-25 and earlier, servers could instead assign an Mcp-Session-Id during initialization, which pinned clients to state and forced proxies to preserve the header.)
Here is the pattern on the notes server. An export can take a while, so it is split into a tool that mints a handle and a tool that reads it:
import uuid
EXPORTS: dict[str, str] = {} # production: a table or object store, not process memory
@mcp.tool()
def start_export(tag: str) -> dict:
"""Start exporting every note with a tag. Returns an export_id for get_export."""
export_id = f"exp_{uuid.uuid4().hex[:12]}"
rows = _get_db().execute(
"SELECT title, body FROM notes WHERE tag = ?", (tag,)
).fetchall()
EXPORTS[export_id] = "\n\n".join(f"# {r['title']}\n{r['body']}" for r in rows)
return {"export_id": export_id, "status": "ready"}
@mcp.tool()
def get_export(export_id: str) -> dict:
"""Fetch a finished export by the export_id that start_export returned."""
if export_id not in EXPORTS:
raise ToolError(f"Unknown export_id '{export_id}'. Call start_export first.")
return {"export_id": export_id, "markdown": EXPORTS[export_id]}
The model carries export_id from one call to the next as an ordinary argument; nothing in the transport remembers it. The comment on EXPORTS is the whole operational lesson: behind a load balancer, get_export may land on an instance that never saw start_export, so the handle must resolve from shared storage.
If you do not need state, do not invent it. A server with no cross-call state is the safer first remote release.
Converting a Local Server to Streamable HTTP
The clean migration is usually smaller than people expect.
Start by extracting your server definition from the current stdio entrypoint. The tool registration, resource templates, prompt definitions, validation, and downstream logic should move into a reusable module. Your transport-specific files then become wrappers.
The notes server is Python, and the next section takes it remote. First, the shape in TypeScript, because the Node SDK makes the split explicit:
// server.ts
import { McpServer } from "@modelcontextprotocol/server";
import * as z from "zod";
export function buildServer() {
const server = new McpServer({ name: "deployments", version: "1.0.0" });
server.registerTool(
"list_recent_deployments",
{
description: "List recent deployments for an environment",
inputSchema: z.object({
environment: z.enum(["prod", "staging"]),
limit: z.number().int().min(1).max(20).default(10),
}),
},
async ({ environment, limit }) => {
const rows = await fetchRecentDeployments(environment, limit);
return {
content: [{ type: "text", text: JSON.stringify(rows) }],
};
},
);
return server;
}
// stdio.ts
import { StdioServerTransport } from "@modelcontextprotocol/server/stdio";
import { buildServer } from "./server";
const server = buildServer();
await server.connect(new StdioServerTransport());
// http.ts
import express from "express";
import { createMcpExpressApp } from "@modelcontextprotocol/express";
import { NodeStreamableHTTPServerTransport } from "@modelcontextprotocol/node";
import { buildServer } from "./server";
const app = createMcpExpressApp();
app.post("/mcp", async (req, res) => {
const server = buildServer();
const transport = new NodeStreamableHTTPServerTransport({
enableJsonResponse: true, // no sessionIdGenerator: 2026-07-28 has no protocol sessions
});
await server.connect(transport);
await transport.handleRequest(req, res, req.body);
});
app.listen(3000, "127.0.0.1");
Note that http.ts builds a fresh server for every request. That is not waste: with no protocol session, nothing about a request depends on the one before it, so any instance can answer any call — which is exactly what lets you run three replicas behind a dumb load balancer.
The exact SDK surface evolves, but the design principle is durable: separate protocol behavior from transport wiring.
Taking the Notes Server Remote
In Python the migration is lighter still: MCPServer already separates the server from its transport, so Chapter 5’s server.py keeps every tool and resource unchanged. Add a second entry point beside it, remote.py:
import os
os.environ.setdefault("NOTES_DB_PATH", "/var/lib/notes/notes.db") # before the import reads it
from server import mcp
if __name__ == "__main__":
mcp.run(
transport="streamable-http",
host="127.0.0.1", # loopback only; a proxy or tunnel faces the network
port=3000,
json_response=True, # plain JSON replies; no stream until a tool needs one
stateless_http=True, # a fresh transport per request, like http.ts above
)
uv run remote.py serves the endpoint at http://127.0.0.1:3000/mcp. stdio still works: Claude Desktop keeps launching server.py, and nothing in the tools knows which door the request came through. Binding to 127.0.0.1 is deliberate; the server should be reachable only through whatever terminates TLS and checks tokens in front of it (Chapter 19). On a loopback bind the SDK also turns on Host and Origin checks: a request with Origin: https://evil.example gets 403, and one whose Host is your public name gets 421. If your proxy forwards the public Host, pass transport_security=TransportSecuritySettings(allowed_hosts=["notes.example.com"]) (from mcp.server.transport_security) rather than switching the check off.
Point the Inspector at the URL instead of a command:
npx @modelcontextprotocol/inspector --server-url http://127.0.0.1:3000/mcp
Run the same checks as in Chapter 5: both tools listed, an empty create_note returning isError: true, notes://summary resolving. If they pass here and fail through your proxy, the proxy is the bug.
Clients now connect by URL rather than by launching a process. In a client that reads the common mcpServers shape, such as Cursor’s mcp.json, that is:
{
"mcpServers": {
"notes": { "url": "https://notes.example.com/mcp" }
}
}
Claude Desktop adds remote servers through its connector settings rather than this file; the URL is the same either way. Once the endpoint needs a token, the client discovers the auth server from the 401 and handles sign-in itself; the config does not change.
What usually needs more work than the code is the edge behavior around it:
- request size limits,
- auth middleware,
- proxy buffering,
- timeout policy,
- state-handle storage (and session storage only if you still serve pre-2026-07-28 clients),
- and logs that are now part of service operations rather than local debugging.
What About Legacy SSE?
You will still encounter older clients, examples, and blog posts built around the old HTTP+SSE transport. The right move is not denial; it is containment.
SSE still exists inside Streamable HTTP as a per-request response stream, without resumability. The old HTTP+SSE transport is formally Deprecated under the spec’s lifecycle policy and should not define a new deployment unless a client you must support requires it. Build against Streamable HTTP first. If you need compatibility shims, isolate them at the edge and treat them as migration surface, not architectural truth.
Authorization Stops Being Optional
The authorization chapter later in the book covers OAuth in depth. For now, the important operational point is simpler: once an MCP server is reachable over a network, you need a clear answer to identity.
The MCP authorization specification defines transport-level authorization for HTTP-based transports and centers OAuth 2.1 patterns. It explicitly distinguishes two common modes:
- Authorization Code when the client acts on behalf of a human user,
- Client Credentials when the caller is an application rather than a person.
The spec also says something easy to overlook: if authorization is required and not yet established, the server must return HTTP 401 and the client begins the OAuth flow from there. Clients must send bearer tokens on every HTTP request, not merely once.
That means your remote design needs answers to four questions before rollout:
- Who is the caller?
- What tenant or account are they operating within?
- What downstream identity is used when a tool touches another system?
- Where is policy enforced when the model asks for something unsafe or out of scope?
A remote MCP server with fuzzy answers here is not “early.” It is insecure.
For a first production deployment, keep the identity model boring. One audience. One auth story. One policy boundary. Add complexity later.
Protect the Transport Layer, Not Just the Business Logic
The transport spec calls out security requirements that belong in your default implementation, not in a future hardening sprint.
For Streamable HTTP, servers must validate the Origin header on incoming connections to prevent DNS rebinding attacks. When running locally, they should bind only to localhost rather than all interfaces. Remote MCP correctness is partly HTTP correctness.
You also need your proxy and platform to preserve the protocol headers and behaviors your server relies on. Your load balancer and reverse proxy must pass through the Mcp-Method and Mcp-Name headers (and Mcp-Session-Id if you still serve pre-2026-07-28 clients that use sessions). If the server streams SSE, intermediate buffers cannot sit on the stream until the connection is effectively useless. If long-lived requests are expected, your idle timeout cannot be shorter than the work.
This is another reason to avoid premature statefulness. Every feature that depends on live streams or pinned state is also a feature that infrastructure can break.
Packaging and Probes
Package the server however your platform expects; a container with runtime dependencies only, a non-root user and configuration from the environment is the usual answer, and Chapter 22 and Appendix J carry working templates. Two points are specific to MCP. First, if any tool streams, the proxy in front must not buffer text/event-stream responses, and every idle timeout on the path must be longer than your slowest tool call. Second, expose /healthz (the process is alive) and /readyz (configuration loaded, the database answers a trivial query) on the same port 3000, and never let readiness call your slowest downstream: a probe that times out takes a healthy instance out of rotation. A container is packaging, not a sandbox; a server that executes code still needs its own threat model.
Test the Remote Shape, Not Just the Tool Logic
A local server can look healthy while the remote server is broken in three different ways. That is why remote rollout needs transport-aware testing.
The MCP Inspector remains one of the best developer tools here because it lets you test discovery, tools, prompts, resources, and notifications against the actual transport. For remote work, use it to validate more than the happy path:
- discovery (
server/discover) against the deployed endpoint, - auth behavior when tokens are missing or expired,
- state handles passed between calls, if your tools use them,
- tool execution through the real proxy path,
- error behavior under invalid inputs,
- and SSE behavior if the server uses streaming or notifications.
Remote MCP is mostly HTTP, so the fastest edge-path test is curl. Every 2026-07-28 request repeats its method in the Mcp-Method header and carries its protocol version and client capabilities in _meta:
curl -s https://notes.example.com/mcp \
-H 'Content-Type: application/json' \
-H 'Accept: application/json, text/event-stream' \
-H 'Mcp-Protocol-Version: 2026-07-28' \
-H 'Mcp-Method: tools/call' \
-H 'Mcp-Name: search_notes' \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/call",
"params":{"name":"search_notes","arguments":{"query":"plan"},
"_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28",
"io.modelcontextprotocol/clientCapabilities":{}}}}'
A healthy server answers with a JSON-RPC result whose isError is false. If the headers disagree with the body, the Python SDK rejects the request with error -32020 (“mcp-method header does not match the request body’s method”), which is also what you will see if a proxy rewrites one and not the other.
Also test with the exact reverse proxy and TLS termination setup you intend to ship. Many remote MCP problems are infrastructure mismatches, not application bugs.
Failure Modes You Should Expect
Remote MCP servers rarely fail in novel ways. They fail in familiar distributed-systems ways, just closer to the model loop.
1. Protocol headers vanish at the edge. A proxy or middleware strips Mcp-Method/Mcp-Name (or, for older clients, Mcp-Session-Id), and the client gets confusing 400 or 404 behavior.
2. Long-lived streams are buffered or timed out. SSE-based streaming appears flaky because a proxy buffers output or closes idle connections early.
3. OAuth is technically implemented but operationally broken. The token exists, but the scope does not map cleanly to tenant policy, refresh handling is incomplete, or discovery endpoints are mislocated.
4. Everything else is ordinary service engineering: local-only assumptions, probe churn, cold starts and oversized payloads; Chapter 23 covers them.
None of these are exotic. Going remote is mostly about respecting ordinary service engineering discipline. When one of them shows up, Appendix E has the literal error strings and the command that confirms each.
Implementation Checklist
Before calling a remote MCP server production-ready, verify the following:
- Domain logic is separated from transport wiring; stdio and HTTP entry points share one server.
- The server speaks Streamable HTTP with no protocol sessions, and any cross-call state is a handle it mints and stores outside process memory.
- The server binds to
127.0.0.1and validatesOrigin; only the proxy faces the network. - Auth answers all four identity questions above, even if the first answer is “one bearer token from our existing IdP, one audience, validated on every request.”
curlwithMcp-MethodandMcp-Namereturns a result through the real proxy and TLS path.- The Inspector, pointed with
--server-urlat the deployed endpoint, passes discovery, tools, resources, prompts and error handling. /healthzand/readyzexist, and readiness does not call a slow downstream.- Output sizes are constrained so the remote path stays fast enough for interactive use.
What Comes Next
The notes server you just took remote is the boring release this chapter opened with. Two tools and a resource. One audience. One auth path. One deployment target. Clear logs. Cheap health checks. Conservative timeouts. No heroics.
It teaches you the shape of remote MCP without making you debug every layer at once. Once the edges are understood, you can decide whether the server needs more cross-call state, more advanced auth flows, or broader capability coverage.
The next step is to move from transport and deployment mechanics into reusable design patterns. Once your MCP server can live reliably as a service, the question becomes how to shape that service so models can use it effectively across real workflows. That is where the cookbook begins. The finished notes server, in one listing, is Appendix C.