Guide · Transport

MCP server stdio transport

The stdio transport is the simplest MCP transport — and the one that trips up the most authors. Your server reads JSON-RPC messages from stdin and writes responses to stdout. One wrong console.log to stdout corrupts the message stream and breaks every call silently. This guide covers how StdioServerTransport works, the stdout hygiene rules you must follow, Claude Desktop integration, and how to test a stdio server without needing a host process.

TL;DR

Create a StdioServerTransport, connect your server to it, and never write anything to stdout except MCP protocol messages. Route all logging to stderr or a file. Register Claude Desktop integration by adding an entry to claude_desktop_config.json. Test without a host by using InMemoryTransport — it replaces the pipe pair in tests. Stdio servers are local processes: they cannot be monitored externally, and they cannot serve multiple simultaneous host connections.

How stdio transport works

When a host application (Claude Desktop, Cursor, Windsurf, or any MCP client) uses your server, it spawns the server as a child process and communicates via OS-level pipes connected to stdin and stdout. The protocol layer is JSON-RPC 2.0 with newline-delimited framing: each message is a single line of JSON terminated by \n.

The message flow for a single tool call:

  1. Host writes a JSON-RPC request to the server's stdin: {"jsonrpc":"2.0","id":"1","method":"tools/call","params":{...}}\n
  2. Server reads the line, parses JSON, dispatches to the matching tool handler.
  3. Server writes a JSON-RPC response to stdout: {"jsonrpc":"2.0","id":"1","result":{...}}\n
  4. Host reads the line and delivers the result to the LLM.

Before any tool calls, the host and server perform an initialize handshake to exchange protocol versions and capability lists. The MCP SDK handles this automatically once you connect the transport.

StdioServerTransport setup

The minimal stdio server — TypeScript with the official MCP SDK:

import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import { z } from 'zod';

const server = new McpServer({
  name: 'my-server',
  version: '1.0.0',
});

server.tool(
  'greet',
  'Return a greeting for the given name',
  { name: z.string().describe('The name to greet') },
  async ({ name }) => ({
    content: [{ type: 'text', text: `Hello, ${name}!` }],
  })
);

async function main() {
  const transport = new StdioServerTransport();
  await server.connect(transport);
  // server.connect() does not return until the transport closes
}

main().catch((err) => {
  // Write errors to stderr, never stdout
  process.stderr.write(`Fatal: ${err.message}\n`);
  process.exit(1);
});

Key points: server.connect(transport) is asynchronous and resolves only when the host closes the stdin pipe (i.e., when the host process terminates or explicitly closes the connection). The process stays alive because stdin is open. Do not add a manual process.exit() after connect() — let the transport close naturally.

Stdout hygiene — the rule every author breaks

The MCP protocol assumes that stdout contains only well-formed JSON-RPC messages, one per line. Any other bytes written to stdout — a console.log debug line, a startup banner, a progress indicator — corrupt the message stream. The host will try to parse the garbage line as JSON-RPC, fail, and either crash or silently drop the connection.

This is the single most common cause of "my MCP server doesn't work in Claude Desktop but works fine in my tests."

Do thisNot thisReason
process.stderr.write('Starting...\n')console.log('Starting...')console.log writes to stdout by default
console.error('debug:', value)console.log('debug:', value)console.error writes to stderr
Log to a file with fs.appendFileSyncPrint a startup banner to stdoutAny stdout bytes break the protocol
Use a structured logger configured for stderrUse pino/winston default stdout transportMost loggers default to stdout

Reconfigure any logger you use to write to stderr or a log file at startup, before the transport connects:

import pino from 'pino';

// Redirect pino to stderr — NOT stdout
const log = pino({ level: 'info' }, process.stderr);

// Now safe to log before and after server.connect()
log.info('Server starting');

const transport = new StdioServerTransport();
await server.connect(transport);
log.info('Transport closed, exiting');

Claude Desktop integration

Claude Desktop reads its MCP server list from claude_desktop_config.json. The file location depends on your OS:

OSPath
macOS~/Library/Application Support/Claude/claude_desktop_config.json
Windows%APPDATA%\Claude\claude_desktop_config.json
Linux~/.config/Claude/claude_desktop_config.json

Add your server under the mcpServers key:

{
  "mcpServers": {
    "my-server": {
      "command": "node",
      "args": ["/absolute/path/to/dist/server.js"],
      "env": {
        "API_KEY": "your-key-here",
        "LOG_LEVEL": "info"
      }
    }
  }
}

For TypeScript projects that are not compiled to JavaScript first, use tsx as the command:

{
  "mcpServers": {
    "my-server": {
      "command": "npx",
      "args": ["tsx", "/absolute/path/to/src/server.ts"]
    }
  }
}

After editing the config, restart Claude Desktop (not just reload — fully quit and reopen). Claude spawns the server process fresh for each conversation session. The server process terminates when the conversation ends or when Claude Desktop exits.

Cursor, Windsurf, and other MCP hosts follow a similar pattern but use different config file paths and formats — consult each host's documentation for the exact key names.

Environment variables and secrets

The env object in claude_desktop_config.json passes environment variables to the server process. This is the standard way to inject API keys and configuration without hardcoding them in the server binary. The host process's own environment variables are not inherited by the spawned server unless explicitly listed.

// server.ts — read from process.env, fail fast if missing
const apiKey = process.env.API_KEY;
if (!apiKey) {
  process.stderr.write('Missing required API_KEY environment variable\n');
  process.exit(1);
}

For more complex secrets management patterns — rotating keys, loading from a secrets store — see the dedicated guide.

Testing stdio servers without a host process

You do not need Claude Desktop or any other host to test a stdio MCP server. Use InMemoryTransport from the MCP SDK — it creates a linked pair of transports that communicate in memory, with no actual pipes.

import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js';
import { createServer } from './server.js'; // your server factory

test('greet tool returns hello', async () => {
  const [serverTransport, clientTransport] = InMemoryTransport.createLinkedPair();
  const server = createServer();
  await server.connect(serverTransport);

  const client = new Client(
    { name: 'test-client', version: '1.0.0' },
    { capabilities: {} }
  );
  await client.connect(clientTransport);

  const result = await client.callTool({
    name: 'greet',
    arguments: { name: 'World' },
  });

  expect(result.content[0].text).toBe('Hello, World!');
  await client.close();
});

The factory pattern — createServer() returning a fresh McpServer instance — is important for test isolation. Each test gets its own server instance with its own state. See MCP server testing for a full test suite pattern and mocking external dependencies within tool handlers.

Graceful shutdown

The stdio transport closes when the host process closes the stdin pipe (EOF). The MCP SDK emits a close event on the transport, which server.connect() resolves after. If your server holds resources (database connections, timers, file handles), clean them up in a server.server.onclose handler:

import { db } from './db.js';

async function main() {
  const transport = new StdioServerTransport();

  // Register cleanup before connecting
  transport.onclose = async () => {
    await db.destroy();
  };

  await server.connect(transport);
  // Control reaches here after stdin closes and cleanup runs
  process.exit(0);
}

// Also handle SIGTERM for hosts that send signals instead of closing stdin
process.on('SIGTERM', async () => {
  await db.destroy();
  process.exit(0);
});

For servers with active timers or async loops, pair shutdown with graceful shutdown patterns to avoid hanging the process after the transport closes.

Limitations of stdio transport

LimitationWhy it mattersAlternative
Local-only — no network accessCannot be shared across machines or teamsSSE or Streamable HTTP
One host at a timeTwo simultaneous host processes cannot share one stdio serverHTTP transport with session management
No authenticationThe host is trusted implicitly — any process that can spawn the server can send it messagesHTTP transport with OAuth or API key auth
Not network-monitorableExternal probes cannot reach a local processDeploy with HTTP transport; add AliveMCP monitoring
Process lifetime = session lifetimeServer state resets when the host closes the connectionExternal database or cache for persistent state
No horizontal scalingCannot run behind a load balancerHTTP transport with load balancing

When stdio is the right choice

Despite its limitations, stdio is the right transport for a large class of MCP servers:

If you are building a shared API wrapper, a multi-user tool, or anything that should be monitored for uptime, choose Streamable HTTP transport instead.

What AliveMCP monitors — and why stdio is out of scope

AliveMCP probes MCP endpoints over the network every 60 seconds to check whether the server responds to the initialize handshake and tool calls correctly. This requires a network-reachable HTTP endpoint. A stdio server is a local child process — there is no URL to probe, no port to connect to, no way to reach it from outside the host machine.

If you start with a stdio server and later deploy it as an HTTP service (adding SSE or Streamable HTTP transport), that deployed version can be monitored by AliveMCP. The pattern is common: build and test locally with stdio, deploy as an HTTP API for shared access and monitoring. Both transports use the same McpServer core — only the transport layer changes.

Related questions

How do I debug a stdio server when I can't see its output?

Write logs to stderr — Claude Desktop and most hosts capture stderr separately from stdout. You can tail the stderr output in a separate terminal if you spawn the server manually. For structured debugging, write logs to a file: const logFile = fs.createWriteStream('/tmp/mcp-debug.log', { flags: 'a' }); process.stderr.pipe(logFile);. The MCP Inspector is also useful — it acts as a proxy host, shows raw protocol messages, and lets you send tool calls interactively.

Can a stdio server restart itself?

No — the server process terminates when the host closes stdin. If the server crashes, the host may or may not restart it (Claude Desktop does restart crashed servers, but this behavior is host-specific). Do not rely on restart behavior for state recovery. If your server needs to survive restarts, store state in a local SQLite database or a file, and reload it at startup.

Can I use stdio and HTTP transport in the same server?

Yes — the transport is selected at connect time, not at server creation. Create one McpServer instance and connect it to whichever transport is appropriate for the current run. A common pattern: check a CLI flag or environment variable at startup to decide which transport to use. This lets you run the same server locally via stdio and deploy it as an HTTP service for shared access.

Does the server process run as the user or as a different account?

The host spawns the server as the current user (the user running Claude Desktop, Cursor, etc.). The server inherits only the environment variables explicitly listed in the env block of the config — not the host process's full environment. This means you cannot rely on $HOME, $PATH, or shell aliases being set; specify full absolute paths for commands and files.

What happens if stdin closes unexpectedly?

The transport emits a close event and server.connect() resolves (or rejects if the close was due to an error). Your onclose handler runs. The process then exits naturally — or you can call process.exit() explicitly in the handler. Pending tool calls that haven't returned a response are abandoned; the host side will time out on those calls.

Further reading