Your MCP server works on your laptop, and the first user who is not you cannot reach it. Their agent runs in a hosted environment and cannot spawn your binary. Or the reverse: you put it on a URL, and a stdio-only client wants a command to run. The tools are done, and the wire is the open question.
What you get
You will end up with one tools module wired to a stdio transport and to a Streamable HTTP endpoint, and the same client calls run over both. This is for you if the tools are done and the transport is not.
Short answer
Ship stdio if every client runs on the same machine as your server: the client spawns your process, and there is no TLS, auth, or uptime to own. Ship Streamable HTTP if any client is remote: one POST endpoint, reachable from anywhere, and identity, limits, and logs become yours. Keep the tool implementations in one module and wire either transport to it. On stdio, never write to stdout.
You will need
Node 22 or later, and a working MCP server with at least one tool. Verified 2026-09-24 against
Node 22.22.2, @modelcontextprotocol/sdk 1.30.1, and zod 4.4.2, with the Python variant
against mcp 2.2.0. The two transports are defined by
the stdio page
and
the Streamable HTTP page
of the specification. The SDK version here negotiates protocol version 2025-11-25, so the
Streamable HTTP it speaks still carries the optional session id that revision 2026-07-28
removed.1
Approaches compared
| Approach | When it fits | What it costs you | When to pick something else |
|---|---|---|---|
mcp-remote as a bridge for stdio-only clients | A client that speaks only stdio and a server that lives at a URL | One more process on the user’s machine, holding the token, and a bridge you do not maintain | The client speaks Streamable HTTP itself |
| stdio transport | Every client runs on the machine the server runs on | Reach stops at that machine, and each user installs and updates the binary | Anyone remote needs the tools |
| Streamable HTTP transport | Any client, anywhere, through one POST endpoint | TLS, authentication, rate limits, and logs are yours to run from the first day | Nothing outside the machine will ever call it |
| The legacy HTTP with SSE transport | A client that predates March 2025 and cannot be updated | Deprecated since protocol version 2025-03-26, and two endpoints to keep alive for it | Every client you have speaks Streamable HTTP |
stdio and Streamable HTTP are the two real options, and they trade reach for operating cost.
stdio costs nothing to run and reaches one machine. Streamable HTTP reaches every machine and
makes you the operator of a public endpoint, with everything that word implies. mcp-remote is
a bridge for a client that cannot make the second choice for itself, and the legacy SSE
transport is a compatibility shim with a removal date.
The decision is about your users, not your code. The same tool implementations serve both transports, which the rest of this page demonstrates, so choosing wrong costs a file, not a rewrite.
Keep the tools in one module
Register the tools on whatever server the transport hands you. Nothing in this file knows which transport is in use.
export function registerTools(server) {
server.registerTool(
'meter_search',
{
title: 'Search meters',
description: 'Find meters whose serial number starts with a prefix. Returns at most 20.',
inputSchema: { serial_prefix: z.string().min(1).describe('Start of the serial number, such as SN-40') },
},
async ({ serial_prefix }) => {
const hits = METERS.filter((m) => m.serial.startsWith(serial_prefix)).slice(0, 20)
log(`meter_search ${serial_prefix}: ${hits.length} hits`)
const text = hits.map((m) => `${m.id} ${m.serial} ${m.status}`).join('\n') || 'no meters match'
return { content: [{ type: 'text', text }] }
},
)
log is the one transport-aware line in the module, and it is aware in the only way that
matters:
export const log = (...args) => console.error('[meter-mcp]', ...args)
Wire stdio
The whole file. The client spawns this process and owns both ends of the pipe.
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
import { createMeterServer, log } from './tools.mjs'
const server = createMeterServer()
await server.connect(new StdioServerTransport())
log('listening on stdio')
There is no port, no certificate, and no login, because the client is a process on the same machine that already ran as the same user. That is the entire case for stdio, and it is also its limit: an agent on another machine has no pipe to your process.
Wire the endpoint for remote clients
One POST endpoint. The specification asks the server to validate the Origin header against DNS
rebinding, so the handler does that before the transport sees the request.
if (req.headers.origin && !ALLOWED_ORIGINS.has(req.headers.origin)) {
res.writeHead(403, { 'content-type': 'application/json' })
return res.end(JSON.stringify({ error: 'origin not allowed' }))
}
const server = createMeterServer()
const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined })
res.on('close', () => {
transport.close()
server.close()
})
await server.connect(transport)
await transport.handleRequest(req, res)
sessionIdGenerator: undefined makes the transport stateless, and a fresh server per request
means any instance behind a load balancer can answer any call. Revision 2026-07-28 of the
specification removed protocol-level sessions altogether, so a stateless server is the shape
the protocol is converging on, and the one that needs no sticky routing.
What this file does not do is the cost. There is no authentication, no rate limit, and no request log here, and a public endpoint needs all three. The stdio server needed none of them.
Check it worked
The same client, the same four calls, over each transport in turn.
node demo.mjs
stdio
server meter-mcp 1.4.0
tools meter_search, meter_read
meter_search SN-40 -> 2 meters
meter_read mtr_8f2 -> {"id":"mtr_8f2","serial":"SN-40199","status":"active","location":"Plant 3"}
streamable http
server meter-mcp 1.4.0
tools meter_search, meter_read
meter_search SN-40 -> 2 meters
meter_read mtr_8f2 -> {"id":"mtr_8f2","serial":"SN-40199","status":"active","location":"Plant 3"}
The two blocks are identical, which is the point. The test suite asserts that with a deep equality on the results, and adds the cases a transport can get wrong on its own.
node --test transport.test.mjs
1..6
# tests 6
# suites 0
# pass 6
# fail 0
# cancelled 0
# skipped 0
# todo 0
# duration_ms 1369.776189
Keep stdout clean
The stdio page of the specification says the server must not write anything to its stdout that
is not a valid MCP message, and may write anything it likes to stderr. A console.log for
“starting” breaks the first rule. This is the wire, as a client sees it, after one initialize
request on stdin.
node pitfall.mjs
stdio-noisy.mjs
line 1: not JSON: "meter-mcp starting"
line 2: JSON-RPC response id 1, serverInfo meter-mcp 1.4.0
stdio.mjs
line 1: JSON-RPC response id 1, serverInfo meter-mcp 1.4.0
The noisy server’s response is fine. It is the line before it that a client has to parse as JSON-RPC and cannot. The SDK’s own client drops the line, reports an error, and keeps going. Other clients fail the handshake. Their logs then show a JSON parse error and no trace of your log line, because the line was consumed by the attempt to parse it.
The fix is console.error, which
Node’s console sends to stderr. The same rule holds in
Python, where print goes to stdout and
the logging module can be pointed at stderr
once, at the top of the file, before anything else runs.
logging.basicConfig(stream=sys.stderr, level=logging.INFO)
log = logging.getLogger("meter-mcp")
The Python server chooses its transport with one argument to run, and the tool defined
before that line does not change:
if __name__ == "__main__":
# "stdio" or "streamable-http": the tool above does not change.
mcp.run(transport=sys.argv[1] if len(sys.argv) > 1 else "stdio")
The guide to building a server repeats the stdout warning once per language it covers,2 which is a measure of how often it has been needed.
When it goes wrong
A curl to the HTTP endpoint answers 406. The request had no Accept header, and the
specification requires the client to accept both application/json and text/event-stream.
Send Accept: application/json, text/event-stream and the same request answers.
A browser-based client gets 403. Its Origin is not in the allowed set. Add the origin you
serve the client from, and only that one. A wildcard here reopens the DNS rebinding hole the
check exists to close.
The stdio server works with the SDK client and fails in the desktop app. Something wrote
to stdout: a dependency’s banner, a debug print, a warning from a library that chose the wrong
stream. Run pitfall.mjs against your own server and read the first line.
The HTTP server works on one instance and fails behind a load balancer. The server is stateful and the second request landed on an instance that never saw the first. Use the stateless transport, as here, or pin sessions at the balancer and accept that as an operating cost.
When not to do this
Do not ship Streamable HTTP because it is the more general choice. It is the more expensive one. The moment your server has a URL, it has an identity problem, a rate limit problem, and a log retention problem. None of those existed while the client was a process on the same machine. If every user of the tools runs an agent on their own machine, stdio is not a compromise, it is the answer.
Do not ship stdio because it is simpler and hope to add HTTP later without thinking about it. The transports differ in what they do about identity. A tool written for a trusted local user often reads that user’s files or environment, and a remote caller must not. Decide which tools are safe for a remote caller before the endpoint exists.
Do not build the legacy HTTP with SSE transport for a new server. It is deprecated, and revision
2026-07-28 lists it for removal under the feature lifecycle policy. A client that needs it is a
client that needs updating, and mcp-remote covers the ones that cannot be.
Do not hand-roll the framing. Newline-delimited JSON-RPC sounds like an afternoon’s work, and the SDK’s transport also handles the version negotiation, the notifications, and the cancellation rules, which are not.
Related how-tos
Last verified
Verified 2026-09-24 against Node 22.22.2, @modelcontextprotocol/sdk 1.30.1, and zod 4.4.2.
Every output block is what the command preceding it printed. The Python variant in server.py
was run against mcp 2.2.0 on Python 3.11 and answered initialize on stdout with nothing else
on that stream. It is not part of the captured output, because the runner that re-runs these
pages carries no Python packages.
Footnotes
-
The transports have been revised three times in twenty months. The 2024-11-05 revision shipped HTTP with SSE, two endpoints and a stream. The 2025-03-26 revision replaced it with Streamable HTTP, one endpoint and an optional session id. The 2026-07-28 changelog removes the session id and the standalone GET stream that the replacement had added. stdio has moved least in that time. Every revision keeps JSON-RPC over a child process’s standard streams, one message per line, and rewords the text around it, which is what happens to a design with nothing left to remove. ↩︎ Back to text
-
The server-building guide warns against writing to stdout in Python, TypeScript, Java, Kotlin, C#, Ruby, Rust, and Go. The offending call is named in each:
print,console.log,System.out.println,println,Console.WriteLine,puts,println!, andfmt.Println. Eight languages, eight warnings, one rule. Underneath them all is JSON-RPC 2.0, which describes itself as transport agnostic and says nothing about what else may share the pipe. In 2010 it did not occur to anyone that something would. ↩︎ Back to text