How-to › Expose your API to agents

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.

Audience
API producer
Level
beginner
Topic
Host, transport and secure an MCP server
Languages
TypeScript and Python
Verified

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

ApproachWhen it fitsWhat it costs youWhen to pick something else
mcp-remote as a bridge for stdio-only clientsA client that speaks only stdio and a server that lives at a URLOne more process on the user’s machine, holding the token, and a bridge you do not maintainThe client speaks Streamable HTTP itself
stdio transportEvery client runs on the machine the server runs onReach stops at that machine, and each user installs and updates the binaryAnyone remote needs the tools
Streamable HTTP transportAny client, anywhere, through one POST endpointTLS, authentication, rate limits, and logs are yours to run from the first dayNothing outside the machine will ever call it
The legacy HTTP with SSE transportA client that predates March 2025 and cannot be updatedDeprecated since protocol version 2025-03-26, and two endpoints to keep alive for itEvery 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.

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

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

  2. 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!, and fmt.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

Read this page as markdown · All how-to guides

Generate the client instead of writing it#

Retries, timeouts, pagination and auth are the same problems in every client. Voxgig generates them from your OpenAPI description, in 23 languages, from one model.

Get the Voxgig dispatch

Short notes on building SDKs, CLIs, REPLs, and MCPs for API-first teams, plus the occasional Fireside episode pick.

By signing up you agree to our Terms and Conditions.