stdio MCP Server
Build a local stdio MCP server that runs as a subprocess. Best for local tooling, CLI scripts, and desktop clients like Claude Desktop.
The Runstack HTTP MCP server at /mcp is designed for remote access. A stdio MCP server is a different deployment model — it runs locally as a subprocess that your MCP client spawns on demand. Both speak the same MCP protocol; only the transport differs.
stdio vs HTTP
| stdio | HTTP (/mcp) | |
|---|---|---|
| Transport | stdin/stdout pipes | JSON over HTTPS |
| Runs on | User's local machine | Remote server |
| Auth | Process-level (no token needed) | API key via Authorization header |
| Best for | Local scripts, Claude Desktop | Shared access, remote agents |
| Startup | Spawned per session | Always-on endpoint |
Use stdio when you want a lightweight local server that only runs when needed. Use HTTP when you need a shared, always-on endpoint (e.g. for a team or for remote MCP clients).
Prerequisites
- Node.js 18+
@modelcontextprotocol/sdkpackage
npm init -y
npm install @modelcontextprotocol/sdk zod
npm install -D typescript tsx @types/nodeMinimal stdio server
// server.ts
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-local-server',
version: '1.0.0',
});
server.registerTool(
'say_hello',
{
description: 'Returns a greeting for the given name.',
inputSchema: {
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 now reads JSON-RPC from stdin and writes to stdout
}
main().catch(console.error);Run it directly:
npx tsx server.tsConnecting to Claude Desktop
Edit ~/Library/Application Support/Claude/claude_desktop_config.json:
{
"mcpServers": {
"my-local-server": {
"command": "npx",
"args": ["tsx", "/absolute/path/to/server.ts"]
}
}
}Claude Desktop will spawn the process when it starts and communicate via stdin/stdout. No API key needed — auth is handled by the fact that only local processes can talk to it.
Connecting to Cursor
In .cursor/mcp.json:
{
"my-local-server": {
"command": "node",
"args": ["/absolute/path/to/dist/server.js"]
}
}Accessing Runstack tools from a stdio server
You can call the Runstack HTTP MCP server from inside your stdio server using the MCP client SDK:
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
const runstack = new Client({ name: 'runstack-client', version: '1.0.0' });
await runstack.connect(
new StreamableHTTPClientTransport(
new URL('https://app.runstack.engineer/mcp'),
{
requestInit: {
headers: { Authorization: 'Bearer rsk_YOUR_API_KEY' },
},
},
),
);
// Now expose Runstack tools through your local stdio server
server.registerTool(
'runstack_search',
{
description: 'Search Runstack tools',
inputSchema: { pattern: z.string() },
},
async ({ pattern }) => {
const result = await runstack.callTool({
name: 'search_tools',
arguments: { pattern },
});
return { content: result.content };
},
);Building for production
Add to package.json:
{
"scripts": {
"build": "tsc",
"start": "node dist/server.js"
},
"bin": {
"my-server": "./dist/server.js"
}
}tsconfig.json:
{
"compilerOptions": {
"target": "ES2022",
"module": "Node16",
"moduleResolution": "Node16",
"outDir": "dist",
"strict": true
},
"include": ["src"]
}After npm run build, clients can use:
{
"command": "node",
"args": ["/path/to/dist/server.js"]
}Or if installed globally via npm:
{
"command": "my-server"
}Best practices for stdio servers
- Write logs to stderr, never stdout — stdout is reserved for the MCP protocol
- Handle SIGTERM/SIGINT gracefully to close the transport cleanly
- Keep startup fast — the client blocks until the server is ready
- Avoid global state — each client spawns its own process instance