Joseph19820124/mcp-stdio-server-guide
If you are the rightful owner of mcp-stdio-server-guide and would like to certify it and/or have it hosted online, please leave a comment on the right or send an email to henry@mcphub.com.
This guide provides a comprehensive walkthrough for building a Model Context Protocol (MCP) server using JSON-RPC 2.0 over STDIO, with a focus on message framing and lifecycle management.
Build a STDIO MCP Server
A concise, end-to-end guide to implement a Model Context Protocol (MCP) server over STDIO, focusing on JSON-RPC 2.0 message framing and robust lifecycle management. Includes runnable Node.js/TypeScript examples.
What you'll build
- A minimal MCP server speaking JSON-RPC 2.0 over STDIN/STDOUT
- Proper message framing using
Content-Length
headers - Capability and tool registration
- Request/response and notifications handling
- Graceful shutdown and health checks
Prerequisites
- Node.js ≥ 18
- npm or pnpm
Protocol overview (quick)
MCP servers commonly speak JSON-RPC 2.0 with HTTP-like headers for framing. A typical message looks like:
Content-Type: application/json
Content-Length: 123\r\n
\r\n
{"jsonrpc":"2.0","id":1,"method":"ping","params":{}}
Key rules:
- Each JSON message is preceded by headers (at least
Content-Length
) - A blank line (
\r\n\r\n
) separates headers from the JSON body - Body length matches
Content-Length
in bytes - Bidirectional: both sides can send requests and notifications
Project scaffold
mkdir mcp-stdio-server && cd $_
npm init -y
npm i -D typescript ts-node @types/node
npx tsc --init --rootDir src --outDir dist --esModuleInterop true
mkdir src
Minimal server (TypeScript)
Create src/server.ts
:
import { TextDecoder } from 'node:util';
interface JsonRpcRequest {
jsonrpc: '2.0';
id?: number | string;
method: string;
params?: unknown;
}
interface JsonRpcResponse {
jsonrpc: '2.0';
id: number | string | null;
result?: unknown;
error?: { code: number; message: string; data?: unknown };
}
const decoder = new TextDecoder('utf-8');
let buffer = Buffer.alloc(0);
process.stdin.on('data', (chunk) => {
buffer = Buffer.concat([buffer, chunk]);
parseFrames();
});
function parseFrames() {
while (true) {
const headerEnd = buffer.indexOf('\r\n\r\n');
if (headerEnd === -1) return;
const header = buffer.slice(0, headerEnd).toString('utf8');
const match = /Content-Length:\s*(\d+)/i.exec(header);
if (!match) {
// invalid frame, drop
buffer = buffer.slice(headerEnd + 4);
continue;
}
const length = Number(match[1]);
const total = headerEnd + 4 + length;
if (buffer.length < total) return; // wait for more data
const body = buffer.slice(headerEnd + 4, total);
buffer = buffer.slice(total);
handleMessage(JSON.parse(decoder.decode(body)) as JsonRpcRequest);
}
}
function send(message: JsonRpcRequest | JsonRpcResponse) {
const payload = Buffer.from(JSON.stringify(message), 'utf8');
const headers = `Content-Length: ${payload.byteLength}\r\n\r\n`;
process.stdout.write(headers);
process.stdout.write(payload);
}
function handleMessage(msg: JsonRpcRequest) {
if (msg.method === 'ping') {
if (msg.id !== undefined) send({ jsonrpc: '2.0', id: msg.id, result: { pong: true } });
return;
}
// Example: server advertises capabilities
if (msg.method === 'initialize') {
send({
jsonrpc: '2.0',
id: msg.id ?? null,
result: {
serverInfo: { name: 'example-stdio-mcp', version: '0.1.0' },
capabilities: {
tools: [
{ name: 'echo', description: 'Echo back your input' },
],
resources: [],
},
},
});
return;
}
if (msg.method === 'callTool') {
const { name, args } = (msg.params as any) ?? {};
if (name === 'echo') {
send({ jsonrpc: '2.0', id: msg.id ?? null, result: { output: args } });
} else {
send({ jsonrpc: '2.0', id: msg.id ?? null, error: { code: -32601, message: 'Tool not found' } });
}
return;
}
// Fallback for unknown methods
if (msg.id !== undefined) {
send({ jsonrpc: '2.0', id: msg.id, error: { code: -32601, message: `Unknown method: ${msg.method}` } });
}
}
process.on('SIGINT', () => graceful('SIGINT'));
process.on('SIGTERM', () => graceful('SIGTERM'));
function graceful(reason: string) {
// optional: send a shutdown notification before exiting
send({ jsonrpc: '2.0', id: null, method: 'shutdown' } as any);
setTimeout(() => process.exit(0), 50);
}
Add run scripts to package.json
:
{
"type": "module",
"scripts": {
"dev": "ts-node src/server.ts",
"build": "tsc",
"start": "node dist/server.js"
}
}
Quick test
In one terminal, run the server:
npm run dev
In another terminal, send a framed JSON-RPC request:
node -e "const m=JSON.stringify({jsonrpc:'2.0',id:1,method:'ping'});process.stdout.write('Content-Length: '+Buffer.byteLength(m)+'\r\n\r\n'+m)" | npm run dev
You should get a framed response with { pong: true }
.
Tips for production-readiness
- Validate headers and reject oversize payloads
- Add request timeouts and heartbeat (periodic
ping
) - Log framing/parse errors with hex dumps
- Use structured logging; avoid console noise on STDIO
- Consider MessagePack for body encoding if both sides support it
- Implement backpressure and concurrency limits for tool calls
Useful references
- JSON-RPC 2.0: https://www.jsonrpc.org/specification
- Language Server Protocol framing (similar headers): https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#headerPart
- MCP community examples: search for "MCP stdio server" on GitHub