DaddyNote

UK, Irish and EU money and travel rules, worked through step by step, with the date each figure was checked.

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

ItemValue
OSWindows 11 Home (10.0.26200)
ShellGit Bash
Node.js / npmv24.19.0 / 11.17.0
Claude Code2.1.274 (Claude Code)
SDK@modelcontextprotocol/sdk 1.30.0 (the npm latest tag), zod 4.6.5
Server folderC:\Users\User\AppData\Local\Temp\longtail_D2\srv
Project folderC:\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=0
srv@1.0.0 C:\Users\User\AppData\Local\Temp\longtail_D2\srv
+-- @modelcontextprotocol/sdk@1.30.0
`-- zod@4.6.5

The 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=null

The 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.json

That 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 project

claude 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

StepCommandResult
Installnpm install @modelcontextprotocol/sdk@1.30.0 zod, then npm ls --depth=0sdk 1.30.0, zod 4.6.5
Direct callsnode rpc_test.mjs server.mjsall three responses (initialize, tools/list, tools/call) were JSON; add(2,3) = 5
Addclaude 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
Removeclaude 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

See also