AI how-to notes · 2026-09-17 · last run 2026-09-17 · 한국어 원문
Build a minimal MCP server in Node — a one-tool stdio server with @modelcontextprotocol/sdk, attached to Claude Code
The short version
npm install @modelcontextprotocol/sdk zod in an empty folder plus a 20-line server.mjs gives you a stdio MCP server with one tool that adds two numbers. On our Windows setup, sending JSON-RPC by hand got 5 back. Right after attaching it with claude mcp add --scope project, claude mcp get said ⏸ Pending approval; with --settings '{"enableAllProjectMcpServers": true}' added, claude mcp get said ✔ Connected.
Who this is for
You are writing your first MCP server. You want to confirm the server works on its own before wiring it into Claude Code. You need the smallest example that runs over stdio.
What we tested on
| Item | Value |
|---|---|
| OS | Windows 11 Home (10.0.26200) |
| Shell | Git Bash |
| Node.js / npm | v24.19.0 / 11.17.0 |
| Claude Code | 2.1.274 (Claude Code) |
| SDK | @modelcontextprotocol/sdk 1.30.0 (the npm latest tag), zod 4.6.5 |
| Server folder | C:\Users\User\AppData\Local\Temp\longtail_D2\srv |
| Project folder | C:\Users\User\AppData\Local\Temp\longtail_D2\proj (not a git repo) |
Step 1 — check the package, then install
First, what is actually published under that name and version.
$ npm view @modelcontextprotocol/sdk name version description license repository.url dist-tags --json
{
"name": "@modelcontextprotocol/sdk",
"version": "1.30.0",
"description": "Model Context Protocol implementation for TypeScript",
"license": "MIT",
"repository.url": "git+https://github.com/modelcontextprotocol/typescript-sdk.git",
"dist-tags": {
"latest": "1.30.0"
}
}We installed into the server folder only, not globally (-g).
npm init -y
npm install @modelcontextprotocol/sdk@1.30.0 zod
npm ls --depth=0srv@1.0.0 C:\Users\User\AppData\Local\Temp\longtail_D2\srv
+-- @modelcontextprotocol/sdk@1.30.0
`-- zod@4.6.5The reason zod comes along is in the SDK README (raw.githubusercontent.com/modelcontextprotocol/typescript-sdk/v1.x/README.md, checked 2026-09-17). Its install command is npm install @modelcontextprotocol/sdk zod, and it states "This SDK has a required peer dependency on zod for schema validation." That README was byte-for-byte identical to the README.md inside the installed 1.30.0 package. The installed SDK's package.json also lists zod as ^3.25 || ^4.0.
npm init -y produced a package.json with "type": "commonjs", so we gave the server file an .mjs extension — a deliberate choice, so we could use import as-is.
Step 2 — the server, in full
We condensed the stdio example and the registerTool example from the SDK's docs/server.md (raw.githubusercontent.com/modelcontextprotocol/typescript-sdk/v1.x/docs/server.md, checked 2026-09-17). The main branch URL for the same file 404'd; we fetched from the v1.x branch.
The whole of server.mjs:
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: 'add-server', version: '1.0.0' });
server.registerTool(
'add',
{
title: 'Add two numbers',
description: 'Return a + b',
inputSchema: { a: z.number(), b: z.number() }
},
async ({ a, b }) => ({
content: [{ type: 'text', text: String(a + b) }]
})
);
const transport = new StdioServerTransport();
await server.connect(transport);The docs say to use stdio "For local integrations where the client spawns the server as a child process". The client launches this file as a child process, and the two exchange JSON-RPC over stdin and stdout. There is not a single console.log in this code. What happens when you print other text to stdout is reproduced separately in Telling MCP server connection errors apart.
Step 3 — calling it over JSON-RPC, without Claude Code
Before attaching it, we exercised the server on its own. The shortest check is piping in a single initialize line.
$ printf '%s\n' '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"t","version":"0"}}}' | timeout 5 node server.mjs
{"result":{"protocolVersion":"2025-06-18","capabilities":{"tools":{"listChanged":true}},"serverInfo":{"name":"add-server","version":"1.0.0"}},"jsonrpc":"2.0","id":1}To get all the way through the tool list and a call, we used this script. It sends one request per line and waits for a response with the matching id (up to 10 seconds).
// usage: node rpc_test.mjs <server file>
// spawns the server as a child process and sends JSON-RPC requests one line at a time.
// for each request it waits for the response with the same id (up to 10s), and prints stdout lines verbatim.
import { spawn } from 'node:child_process';
const file = process.argv[2] ?? 'server.mjs';
const t0 = Date.now();
const ms = () => `${Date.now() - t0}ms`;
const child = spawn(process.execPath, [file], { stdio: ['pipe', 'pipe', 'pipe'] });
const msgs = [
{ jsonrpc: '2.0', id: 1, method: 'initialize',
params: { protocolVersion: '2025-06-18', capabilities: {}, clientInfo: { name: 'rpc-test', version: '0.0.1' } } },
{ jsonrpc: '2.0', method: 'notifications/initialized' },
{ jsonrpc: '2.0', id: 2, method: 'tools/list' },
{ jsonrpc: '2.0', id: 3, method: 'tools/call', params: { name: 'add', arguments: { a: 2, b: 3 } } }
];
const waiters = new Map();
let buf = '';
let n = 0;
child.stdout.on('data', (d) => {
buf += d.toString('utf8');
let i;
while ((i = buf.indexOf('\n')) >= 0) {
const line = buf.slice(0, i); buf = buf.slice(i + 1); n++;
let obj = null;
try { obj = JSON.parse(line); } catch {}
console.log(`[stdout ${n}] ${obj ? '(JSON)' : '(NOT-JSON)'} ${line}`);
if (obj && waiters.has(obj.id)) { waiters.get(obj.id)(); waiters.delete(obj.id); }
}
});
child.stderr.on('data', (d) => process.stdout.write(`[stderr] ${d}`));
child.on('exit', (code, sig) => console.log(`[child exit ${ms()}] code=${code} signal=${sig}`));
for (const m of msgs) {
console.log(`[send ${ms()}] ${m.method}`);
child.stdin.write(JSON.stringify(m) + '\n');
if (m.id === undefined) continue;
const ok = await new Promise((resolve) => {
waiters.set(m.id, () => resolve(true));
setTimeout(() => resolve(false), 10000);
});
if (!ok) console.log(`[timeout ${ms()}] id=${m.id} no response (10s)`);
}
child.stdin.end();
setTimeout(() => { if (child.exitCode === null) child.kill(); }, 2000);$ node rpc_test.mjs server.mjs
[send 11ms] initialize
[stdout 1] (JSON) {"result":{"protocolVersion":"2025-06-18","capabilities":{"tools":{"listChanged":true}},"serverInfo":{"name":"add-server","version":"1.0.0"}},"jsonrpc":"2.0","id":1}
[send 449ms] notifications/initialized
[send 449ms] tools/list
[stdout 2] (JSON) {"result":{"tools":[{"name":"add","title":"Add two numbers","description":"Return a + b","inputSchema":{"$schema":"http://json-schema.org/draft-07/schema#","type":"object","properties":{"a":{"type":"number"},"b":{"type":"number"}},"required":["a","b"]},"execution":{"taskSupport":"forbidden"}}]},"jsonrpc":"2.0","id":2}
[send 454ms] tools/call
[stdout 3] (JSON) {"result":{"content":[{"type":"text","text":"5"}]},"jsonrpc":"2.0","id":3}
[child exit 475ms] code=0 signal=nullThe zod schema we wrote as inputSchema, { a: z.number(), b: z.number() }, came back as JSON Schema in the tools/list response ("required":["a","b"]). The tools/call result was "text":"5", and closing stdin made the server exit on its own with code 0.
Step 4 — attaching it to Claude Code
To leave the personal settings file (~/.claude.json) alone, we added it at --scope project.
$ claude mcp add d2add --scope project -- node 'C:\Users\User\AppData\Local\Temp\longtail_D2\srv\server.mjs'
Added stdio MCP server d2add with command: node C:\Users\User\AppData\Local\Temp\longtail_D2\srv\server.mjs to project config
File modified: C:\Users\User\AppData\Local\Temp\longtail_D2\proj\.mcp.jsonThat produced a 213-byte .mcp.json in the project folder.
{
"mcpServers": {
"d2add": {
"type": "stdio",
"command": "node",
"args": [
"C:\\Users\\User\\AppData\\Local\\Temp\\longtail_D2\\srv\\server.mjs"
],
"env": {}
}
}
}Check the status immediately and it is awaiting approval.
$ claude mcp list
Checking MCP server health…
d2add: node C:\Users\User\AppData\Local\Temp\longtail_D2\srv\server.mjs - ⏸ Pending approval (run `claude` to approve)(Lines for servers in other scopes are filtered out; only the d2add line remains.) To check without opening an interactive claude, we added --settings '{"enableAllProjectMcpServers": true}' — a value passed on the command line only, with no settings file edited.
$ claude --settings '{"enableAllProjectMcpServers": true}' mcp get d2add
d2add:
Scope: Project config (shared via .mcp.json)
Status: ✔ Connected
Type: stdio
Command: node
Args: C:\Users\User\AppData\Local\Temp\longtail_D2\srv\server.mjs
Environment:
To remove this server, run: claude mcp remove d2add -s projectclaude mcp list with the same --settings also read ✔ Connected. The official docs (code.claude.com/docs/en/mcp, checked 2026-09-17) call this "a health status next to each server it lists". The health status is as far as we took this step.
Step 5 — remove it
$ claude mcp remove d2add --scope project
Removed MCP server d2add from project config
File modified: C:\Users\User\AppData\Local\Temp\longtail_D2\proj\.mcp.json.mcp.json stayed, with its contents reduced to {"mcpServers": {}} (22 bytes). Afterwards claude mcp list had zero lines containing d2add. Counting the strings longtail_D2 and d2add in ~/.claude.json gave zero for both (counts only).
Results
| Step | Command | Result |
|---|---|---|
| Install | npm install @modelcontextprotocol/sdk@1.30.0 zod, then npm ls --depth=0 | sdk 1.30.0, zod 4.6.5 |
| Direct calls | node rpc_test.mjs server.mjs | all three responses (initialize, tools/list, tools/call) were JSON; add(2,3) = 5 |
| Add | claude mcp add d2add --scope project -- node … | .mcp.json, 213 bytes |
| Check (before approval) | claude mcp list / get | `⏸ Pending approval (run claude to approve)` |
Check (approved via --settings) | claude --settings '{…}' mcp list / get | ✔ Connected |
| Remove | claude mcp remove d2add --scope project | .mcp.json 22 bytes, no d2add in list |
What didn't work
The first version of rpc_test.mjs sent requests 300ms apart, closed stdin, and force-killed the server 1.5 seconds later. That version never received a single line of stdout, leaving only [child exit] code=null signal=SIGTERM. Piping one initialize line into the same server got an immediate response, so we rewrote it into the wait-for-response version above. We never established why the first version received nothing.
What we did not check
- Claude actually calling the
addtool in an interactive Claude Code session. We stopped at the health status. - The
/mcpscreen and the interactive approval flow. - Writing this in TypeScript and building it, or using the zod 3 API (
zod/v3). - SDK versions other than 1.30.0. We also did not investigate why the
mainbranch doc 404'd. - Running
claude mcp addfrom Command Prompt (cmd.exe) or PowerShell, and macOS or Linux.