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:
- Host writes a JSON-RPC request to the server's
stdin:{"jsonrpc":"2.0","id":"1","method":"tools/call","params":{...}}\n - Server reads the line, parses JSON, dispatches to the matching tool handler.
- Server writes a JSON-RPC response to
stdout:{"jsonrpc":"2.0","id":"1","result":{...}}\n - 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 this | Not this | Reason |
|---|---|---|
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.appendFileSync | Print a startup banner to stdout | Any stdout bytes break the protocol |
| Use a structured logger configured for stderr | Use pino/winston default stdout transport | Most 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:
| OS | Path |
|---|---|
| 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
| Limitation | Why it matters | Alternative |
|---|---|---|
| Local-only — no network access | Cannot be shared across machines or teams | SSE or Streamable HTTP |
| One host at a time | Two simultaneous host processes cannot share one stdio server | HTTP transport with session management |
| No authentication | The host is trusted implicitly — any process that can spawn the server can send it messages | HTTP transport with OAuth or API key auth |
| Not network-monitorable | External probes cannot reach a local process | Deploy with HTTP transport; add AliveMCP monitoring |
| Process lifetime = session lifetime | Server state resets when the host closes the connection | External database or cache for persistent state |
| No horizontal scaling | Cannot run behind a load balancer | HTTP 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:
- Local filesystem tools — servers that need to read/write the user's local files (the host grants path access at spawn time).
- Personal productivity tools — calendar, local notes, shell command wrappers — where the server running on one machine for one user is the design.
- Development-time tools — servers that wrap local dev commands: build, test, lint, git. The user's project path is available naturally via the process environment.
- Distribution via npm — stdio servers are easy to distribute:
npx your-mcp-serveris the entire install and run experience. No infra to operate. - Sensitive data processing — data never leaves the user's machine, which removes a class of privacy and compliance concerns.
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
- MCP server SSE transport — HTTP+SSE remote server setup
- MCP server Streamable HTTP transport — modern remote deployment
- MCP server transport comparison — stdio vs SSE vs Streamable HTTP
- MCP server JSON-RPC 2.0 — protocol messages and lifecycle
- MCP server testing — InMemoryTransport and unit test patterns
- MCP Inspector — debugging tool calls interactively
- MCP server graceful shutdown — cleanup on transport close
- MCP server secrets management — API keys in config
- AliveMCP — uptime monitoring for HTTP-deployed MCP servers