Skip to content
Chapter 09 · 20 minutes

Transports: stdio and Streamable HTTP

Run the same server over stdio for local clients and over Streamable HTTP for remote ones.

Goal

Know which transport to use, why stdout is sacred in stdio mode, and how to serve HTTP safely.

Two transports, one server

The transport only moves messages. It never changes what the server can do. Jotter picks one from a flag:

// server/src/main.ts
if (process.argv.includes('--http')) {
  const http = createHttpServer(store)
  http.listen(config.httpPort, config.httpHost, () => {
    log('info', 'jotter_ready', { transport: 'http', host: config.httpHost, port: config.httpPort })
  })
} else {
  await createJotterServer(store).connect(new StdioServerTransport())
  log('info', 'jotter_ready', { transport: 'stdio' })
}
stdioStreamable HTTP
Who starts itThe client spawns your processYou run it, the client connects to a URL
UsersOneOne or many
Best forLocal tools, files on this machineShared or hosted servers
AuthThe OS userYou must add it (OAuth, tokens)

stdio: never write to stdout

In stdio mode, stdout carries the JSON-RPC messages. One stray console.log corrupts the stream and the client disconnects with a confusing error. Log to stderr:

// server/src/log.ts
export function log(level: Level, event: string, fields: Record<string, Field> = {}): void {
  process.stderr.write(`${JSON.stringify({ level, event, ...fields, at: new Date().toISOString() })}\n`)
}

You can see a raw session with no client at all. Pipe in an initialize request:

npm run build
echo '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"me","version":"0"}}}' | node dist/main.js

The reply on stdout lists the server's capabilities. The log line goes to stderr.

Streamable HTTP, stateless

npm run start:http

This serves http://127.0.0.1:3333/mcp. Jotter runs in stateless mode: each POST gets a fresh server and transport, and the notes file is the only shared state.

// server/src/http.ts
    const server = createJotterServer(store)
    const transport = new StreamableHTTPServerTransport({
      sessionIdGenerator: undefined,
      enableJsonResponse: true,
    })

sessionIdGenerator: undefined turns sessions off. enableJsonResponse answers with plain JSON instead of an event stream, which is easier to debug. If you need server-to-client notifications or long-running progress, switch to stateful mode with a session id generator.

Bind to localhost and check the Host

A local HTTP server can be reached by any web page you visit, through a trick called DNS rebinding. Jotter listens on 127.0.0.1 by default and refuses requests whose Host or Origin is not local:

// server/src/http.ts
export function isLocalRequest(req: IncomingMessage): boolean {
  const host = hostname(req.headers.host)
  if (!host || !LOCAL_HOSTS.has(host)) return false
  const origin = req.headers.origin
  if (!origin) return true
  try {
    return LOCAL_HOSTS.has(hostname(new URL(origin).host) ?? '')
  } catch {
    return false
  }
}

Before you expose a server beyond your machine, add real authentication. The MCP spec uses OAuth for remote servers, and the SDK has helpers for it.

Check it works

npm test -- -t "Streamable HTTP"

Then run npm run start:http and, in another terminal, point the Inspector at it: choose the Streamable HTTP transport and the URL http://127.0.0.1:3333/mcp.

Exercise

Add a GET /health route to server/src/http.ts that returns { "ok": true } without going through MCP. Keep the Host check in front of it. Add a test.