# How to choose between stdio and Streamable HTTP for an MCP server

> Ship stdio when the agent runs beside your binary and Streamable HTTP when it does not, keep the tools in one module, and keep stdout clean.

Source: https://voxgig.com/howto/choose-an-mcp-transport

- Audience: api-producer
- Level: beginner
- Languages: typescript, python
- Verified: 2026-09-24
- Published: 2026-09-24

## 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](https://modelcontextprotocol.io/specification/2026-07-28/basic/transports/stdio)
and
[the Streamable HTTP page](https://modelcontextprotocol.io/specification/2026-07-28/basic/transports/streamable-http)
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](https://developers.cloudflare.com/agents/model-context-protocol/guides/test-remote-mcp-server/) | 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](https://modelcontextprotocol.io/specification/2026-07-28/basic/transports/stdio) | 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](https://modelcontextprotocol.io/specification/2026-07-28/basic/transports/streamable-http) | 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](https://modelcontextprotocol.io/specification/2024-11-05/basic/transports) | 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.

```ts title="tools.mjs"
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:

```ts title="tools.mjs"
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.

```ts title="stdio.mjs"
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.

```ts title="http.mjs"
    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.

```bash
node demo.mjs
```

```text output
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.

```bash
node --test transport.test.mjs
```

```text output
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.

```bash
node pitfall.mjs
```

```text output
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](https://nodejs.org/api/console.html) sends to stderr. The same rule holds in
Python, where `print` goes to stdout and
[the logging module](https://docs.python.org/3/library/logging.html) can be pointed at stderr
once, at the top of the file, before anything else runs.

```python title="server.py"
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:

```python title="server.py"
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

- [Run an MCP server against a mock API while you build tools](/howto/run-an-mcp-server-against-a-mock-api)

- [Issue and rotate per-agent API keys for an MCP server](/howto/per-agent-api-keys-for-mcp)

## 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.

[^1]: The transports have been revised three times in twenty months. The
[2024-11-05 revision](https://modelcontextprotocol.io/specification/2024-11-05/basic/transports)
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](https://modelcontextprotocol.io/specification/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.

[^2]: [The server-building guide](https://modelcontextprotocol.io/docs/2026-07-28/develop/build-server)
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!`, and `fmt.Println`. Eight languages, eight warnings, one
rule. Underneath them all is
[JSON-RPC 2.0](https://www.jsonrpc.org/specification), 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.