How to deploy a remote MCP server
An MCP server over stdio is about as simple as software gets. Your client spawns it as a subprocess, they talk JSON-RPC over pipes, and there is no network, no auth, and no deployment. Making the same server remote sounds like adding an HTTP handler, and technically it is — but the three things that come with it are where the time goes.
This is the working order: transport, sessions, auth, isolation. Do them in that sequence and each one is small. Do them out of order and you will debug an auth problem that is actually a buffering problem.
Step 1: switch to Streamable HTTP
The current remote transport is Streamable HTTP. One endpoint — conventionally /mcp — accepts POSTed JSON-RPC messages and answers either with a JSON body or by upgrading the response into a Server-Sent Events stream. A separate GET on the same path can open a stream for server-initiated messages.
You will also see HTTP+SSE referenced, which is the earlier two-endpoint transport. It is deprecated but still widely deployed, and the official SDKs can serve both if you need to support older clients. Start with Streamable HTTP and add the legacy path only if a client you care about requires it.
import express from "express";
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StreamableHTTPServerTransport }
from "@modelcontextprotocol/sdk/server/streamableHttp.js";
const app = express();
app.use(express.json());
app.post("/mcp", async (req, res) => {
const transport = new StreamableHTTPServerTransport({
sessionIdGenerator: () => crypto.randomUUID(),
});
res.on("close", () => transport.close());
const server = new McpServer({ name: "acme-tools", version: "1.0.0" });
registerTools(server);
await server.connect(transport);
await transport.handleRequest(req, res, req.body);
});
// Bind 0.0.0.0, not localhost, or the platform's health check never connects.
app.listen(Number(process.env.PORT ?? 3000), "0.0.0.0");Step 2: verify streaming on the deployed URL, not locally
This is the check people skip, and it is the one that costs a day. Locally there is no proxy between your process and your terminal, so streaming always appears to work. In production there is at least one hop, and if any of them buffers the response body until it is complete, your stream becomes a single delayed reply. Nothing errors. The client just sits there.
curl -N -i \
-H 'Content-Type: application/json' \
-H 'Accept: application/json, text/event-stream' \
-d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{
"protocolVersion":"2025-06-18",
"capabilities":{},
"clientInfo":{"name":"curl","version":"1"}}}' \
https://your-server.example.com/mcp
# Look for two things:
# 1. an Mcp-Session-Id response header -> sessions are working
# 2. bytes arriving before the handler finishes -> nothing is buffering
#
# If the whole body lands at once after a long pause, find the buffer.
# Your own nginx: proxy_buffering off; and raise proxy_read_timeout.
# A managed platform that buffers usually has no setting -- that is a
# platform decision, not a config one.Step 3: decide where session state lives
If your server issues an Mcp-Session-Id, the client will send it back on every subsequent request, and something has to be able to find that session again. The quickstart keeps a Map of transports in module scope, which is correct on exactly one process and wrong everywhere else.
You have three options, and picking deliberately now is cheaper than discovering it under load.
- Be stateless. Return no session id and make every tool call self-contained. If you can do this, do — the whole problem disappears and you can host anywhere.
- One process. Keep sessions in memory and run a single instance. Perfectly reasonable for an internal server, and it means your scaling plan is "a bigger machine".
- External state. Put session data in Redis or Postgres so any instance can serve any request. Necessary above one process, and it forces you to be explicit about what a session actually contains — which is usually a healthy exercise.
Step 4: authorization, and the path that must stay open
MCP's authorization model is OAuth 2.1-based: your server is a protected resource, an unauthenticated request gets a 401 pointing at metadata, and the client discovers the authorization server and runs the flow itself. You do not implement OAuth — you implement the metadata and the token check.
The hosting consequence is specific and catches people out. Your platform's own authentication must not sit in front of the discovery endpoints or the 401 response, because the client needs to reach them before it has any credentials. If your host puts a login gate in front of every route, the flow cannot start. Verify you can exempt a path before you build on it.
For an internal server used by your own team, a static bearer token in the Authorization header is a legitimate stopgap. It is not the spec, some clients will not support it, and it is fine for a server behind your own network while you decide whether the full flow is worth it.
Step 5: if your tools execute anything, isolate them
Now the part specific to MCP rather than to HTTP servers generally. The arguments to your tools are generated by a model, and that model may have been reading a web page, a document, or an email that an attacker wrote. Prompt injection reaches your tool arguments directly. Authentication tells you which user is connected; it tells you nothing about whether the arguments are safe.
If your tools only call APIs with narrow parameters, ordinary validation is enough. If any tool runs code, executes a shell command, clones a repository, or fetches a user-supplied URL, the process serving your protocol should not be the process doing it.
import { Sandbox } from "@pandastack/sdk";
server.tool(
"run_python",
{ code: z.string().max(50_000) },
async ({ code }) => {
// A fresh microVM per call, destroyed when the block exits, with a
// server-side TTL as a backstop in case this process dies first.
await using sb = await Sandbox.create({
template: "code-interpreter",
ttlSeconds: 120,
});
const out = await sb.runCode(code, "python");
const text = (out.stdout || out.stderr || "").slice(0, 100_000);
return { content: [{ type: "text", text }] };
},
);Three details in that snippet are worth copying regardless of platform. A size cap on the input, because someone will send you a megabyte. A time-to-live on the environment, because your cleanup code will fail one day. And a cap on the output, because an accidental infinite print loop should not become your incident.
Deploying it
With those five decisions made, deployment is ordinary. You need a host that runs a long-lived process, does not buffer responses, gives you a stable HTTPS hostname, and lets you leave one path unauthenticated.
pandastack apps create --name acme-mcp \
--git-url https://github.com/acme/mcp-server \
--install-command 'npm ci' \
--build-command 'npm run build' \
--start-command 'node dist/server.js' \
--env MCP_PUBLIC_URL=https://acme-mcp.example.com
# Deploy and watch the build:
pandastack apps deploy <app-id> --follow
# Then check the deployed URL, not localhost:
curl -N -i -H 'Accept: text/event-stream' https://acme-mcp.example.com/mcpThe pre-launch checklist
- Server binds 0.0.0.0 and reads PORT from the environment.
- curl -N against the public URL shows bytes arriving before the handler finishes.
- The Mcp-Session-Id header appears on initialize, and a follow-up request carrying it succeeds.
- Session state survives a restart, or you have consciously chosen a single process.
- The discovery path returns its 401 or metadata without hitting a platform login page.
- Every tool has an input size cap and a timeout.
- Anything that executes runs in a disposable environment with a TTL, not in the server process.
- Logs record tool name, caller, and duration — you will want this the first time something behaves oddly.
Wrapping up
The work here is not the HTTP handler. It is deciding whether you are stateful, proving your platform streams, keeping one path unauthenticated, and refusing to execute anything in the process that holds your sessions.
Those four decisions are also what determine which platforms are viable, which is why it is worth making them before you pick one rather than after.
Frequently asked questions
Do I need to rewrite my stdio MCP server to make it remote?
No, and the SDKs are designed so you do not have to. Your tool implementations, resources, and prompts are transport-agnostic — the server object is the same, and only the transport you connect it to changes. In practice most projects keep a single codebase with two entrypoints: one that connects a stdio transport for local use, and one that starts an HTTP server and connects a Streamable HTTP transport. What does need attention is anything your tools assumed about running on the user's machine. A stdio server can read the local filesystem, use locally installed CLIs, and reach services on localhost; a remote one can do none of those, and tools that quietly depended on them will fail in ways the transport change did not obviously cause. Audit for those assumptions before you deploy rather than after.
Why does my remote MCP server work with curl but not with the client?
Start by comparing what the client sends with what you tested. The most common gap is the Accept header: clients typically send both application/json and text/event-stream, and a server or a proxy that mishandles content negotiation may behave differently from a curl command that sent only one. The second is session handling — if the client sends an Mcp-Session-Id your server does not recognise, because the session lived in a different instance's memory or was lost on a restart, you get failures that look like protocol errors. Third is protocol version negotiation, where a client and server that agreed on different revisions disagree about message shapes. Log the full request headers and the raw body for a failing client request and compare it line by line with your working curl; the difference is almost always visible immediately once you look at both.
How do I authenticate users on a remote MCP server?
The specified path is OAuth 2.1: your server acts as a protected resource, returns a 401 with metadata pointing at the authorization server when a request arrives without a valid token, and the client discovers the rest and runs the flow itself. You are implementing the metadata endpoints and the token validation, not an OAuth server — most teams point at an existing identity provider. The hosting detail that catches people is that the discovery endpoints and the 401 must be reachable without credentials, so a platform that puts its own login gate in front of every route breaks the flow before it starts. For an internal server on your own network, a static bearer token checked in a middleware is a reasonable interim step; it is not the spec and not every client supports it, but it is honest about being a stopgap rather than pretending to be authorization.
Can I run multiple instances of an MCP server behind a load balancer?
Only if session state is not in process memory. The protocol lets a server issue a session id that the client echoes on later requests, and if that session's transport and any associated state live in one instance's memory, a request routed elsewhere finds nothing. Sticky sessions at the load balancer appear to solve this and do not really, because instances restart on every deploy and clients pinned to a departed instance start failing in ways that look random. The two clean answers are to be genuinely stateless, returning no session id and making every tool call self-contained, or to move session state into Redis or a database so any instance can serve any request. The stateless option is worth trying first, because many servers turn out not to need sessions at all once you look at what they actually store.
What is the safest way to add a code-execution tool to an MCP server?
Execute in a disposable environment, never in the process that speaks the protocol. That process holds your credentials, your session state for every connected user, and your host's network access, and the arguments arriving at your tool were chosen by a model that may have been reading content an attacker controlled. The pattern that holds up is a fresh environment per invocation with its own kernel, only the credentials that specific tool needs, egress restricted to what it genuinely requires, and a server-side time-to-live so it disappears even if your cleanup path fails. Cap the input size and the returned output, set a hard timeout on the execution, and log which caller ran what. Language-level sandboxes are not a substitute — Node's vm module and Python's restricted-execution recipes were not built to contain hostile code and are routinely escaped.
Keep reading
- The best MCP server hosting platforms in 2026
- Secure MCP tool execution
- Remote MCP server isolation
- From prompt injection to RCE in an agent tool chain
- PandaStack Apps — git-driven deploys, stable HTTPS hostname
49ms p50 cold start. Fork, snapshot, and scale to zero.