AI 해결 노트 · 2026-09-17 · 실측 2026-09-17

Node로 최소 MCP 서버 만들기 — @modelcontextprotocol/sdk 도구 1개짜리 stdio 서버를 Claude Code에 붙이기까지

한 줄로

빈 폴더에 npm install @modelcontextprotocol/sdk zod를 하고 20줄짜리 server.mjs를 쓰면 두 수를 더하는 도구 하나를 가진 stdio MCP 서버가 됩니다. 저희 윈도우 환경에서는 JSON-RPC를 직접 보내 5를 돌려받았습니다. claude mcp add --scope project로 붙인 직후 claude mcp get⏸ Pending approval이었고, --settings '{"enableAllProjectMcpServers": true}'를 붙인 claude mcp get에서 ✔ Connected가 나왔습니다.

이런 분께

MCP 서버를 처음 직접 만들어 보려는 분. 서버가 제대로 도는지 Claude Code에 붙이기 전에 따로 확인하고 싶은 분. stdio로 도는 가장 작은 예제가 필요한 분.

실측 환경

항목
OSWindows 11 Home (10.0.26200)
Git Bash
Node.js / npmv24.19.0 / 11.17.0
Claude Code2.1.274 (Claude Code)
SDK@modelcontextprotocol/sdk 1.30.0 (npm latest 태그), zod 4.6.5
서버 폴더C:\Users\User\AppData\Local\Temp\longtail_D2\srv
프로젝트 폴더C:\Users\User\AppData\Local\Temp\longtail_D2\proj (깃 저장소 아님)

1단계 — 패키지 확인과 설치

먼저 npm에 올라온 이름과 버전을 봤습니다.

$ 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"
  }
}

설치는 전역(-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

zod를 같이 까는 이유는 SDK README(raw.githubusercontent.com/modelcontextprotocol/typescript-sdk/v1.x/README.md, 2026-09-17 확인)에 있습니다. 설치 명령이 npm install @modelcontextprotocol/sdk zod이고, "This SDK has a required peer dependency on zod for schema validation."이라고 적혀 있습니다. 이 README는 설치된 1.30.0 패키지 안의 README.md와 바이트 단위로 같았습니다. 설치된 SDK의 package.json에서도 zod^3.25 || ^4.0으로 적혀 있었습니다.

npm init -y가 만든 package.json"type": "commonjs"였습니다. 그래서 서버 파일 확장자를 .mjs로 했습니다. 그대로 import 문을 쓰려는 선택입니다.

2단계 — 서버 코드 전문

SDK 문서 docs/server.md(raw.githubusercontent.com/modelcontextprotocol/typescript-sdk/v1.x/docs/server.md, 2026-09-17 확인)의 stdio 예시와 registerTool 예시를 합쳐 줄였습니다. 같은 파일의 main 브랜치 주소는 404였고, v1.x 브랜치에서 받았습니다.

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);

문서는 stdio를 "For local integrations where the client spawns the server as a child process"에 쓰라고 설명합니다. 클라이언트가 이 파일을 자식 프로세스로 띄우고, 둘은 표준입력과 표준출력으로 JSON-RPC를 주고받습니다. 이 코드에는 console.log가 한 줄도 없습니다. stdout에 다른 글을 찍으면 어떻게 되는지는 MCP 서버 연결 오류 구분에 따로 재현했습니다.

3단계 — Claude Code 없이 JSON-RPC로 직접 불러 보기

붙이기 전에 서버만 따로 확인했습니다. 가장 짧은 확인은 initialize 한 줄을 파이프로 넣는 것입니다.

$ 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}

도구 목록과 호출까지 보려고 아래 스크립트를 썼습니다. 요청을 한 줄씩 보내고, 같은 id의 응답이 올 때까지(최대 10초) 기다립니다.

// 사용: node rpc_test.mjs <서버파일>
// 서버를 자식 프로세스로 띄우고 JSON-RPC 요청을 한 줄씩 보낸다.
// 요청마다 같은 id의 응답이 올 때까지(최대 10초) 기다리고, stdout 줄은 받은 그대로 찍는다.
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} 응답 없음(10초)`);
}
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

inputSchema에 적은 zod 스키마 { a: z.number(), b: z.number() }tools/list 응답에서 JSON Schema("required":["a","b"])로 바뀌어 나왔습니다. tools/call의 결과는 "text":"5"였고, 입력(stdin)을 닫자 서버가 종료 코드 0으로 스스로 끝났습니다.

4단계 — Claude Code에 붙이기

사용자 설정 파일(~/.claude.json)을 건드리지 않으려고 --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

프로젝트 폴더에 213바이트짜리 .mcp.json이 생겼습니다.

{
  "mcpServers": {
    "d2add": {
      "type": "stdio",
      "command": "node",
      "args": [
        "C:\\Users\\User\\AppData\\Local\\Temp\\longtail_D2\\srv\\server.mjs"
      ],
      "env": {}
    }
  }
}

바로 상태를 보면 승인 대기입니다.

$ 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)

(다른 범위의 서버 줄은 걸러 내고 d2add 줄만 남겼습니다.) 대화형 claude를 열지 않고 확인하려고 --settings '{"enableAllProjectMcpServers": true}'를 붙였습니다. 설정 파일은 고치지 않고 명령줄에만 넣은 값입니다.

$ 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

같은 --settings를 붙인 claude mcp list✔ Connected였습니다. 공식 문서(code.claude.com/docs/en/mcp, 2026-09-17 확인)는 이 목록의 상태를 "a health status next to each server it lists"라고 부릅니다. 저희가 이 단계에서 본 것은 이 상태 표시까지입니다.

5단계 — 지우기

$ 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은 남고 내용만 {"mcpServers": {}}(22바이트)가 됐습니다. 이후 claude mcp list에서 d2add가 들어간 줄은 0줄이었습니다. ~/.claude.json에서 longtail_D2d2add 문자열을 세어 보니 둘 다 0건이었습니다(건수만 셈).

결과

단계명령결과
설치npm install @modelcontextprotocol/sdk@1.30.0 zodnpm ls --depth=0sdk 1.30.0, zod 4.6.5
직접 호출node rpc_test.mjs server.mjsinitialize·tools/list·tools/call 세 응답 모두 JSON, add(2,3) = 5
추가claude mcp add d2add --scope project -- node ….mcp.json 213바이트
확인(승인 전)claude mcp list / get`⏸ Pending approval (run claude to approve)`
확인(--settings 승인)claude --settings '{…}' mcp list / get✔ Connected
제거claude mcp remove d2add --scope project.mcp.json 22바이트, listd2add 없음

실패한 것

rpc_test.mjs 첫 판은 요청을 300ms 간격으로 보내고 입력을 닫은 뒤 1.5초 뒤에 서버를 강제 종료하게 짰습니다. 이 판으로는 stdout을 한 줄도 받지 못했고 [child exit] code=null signal=SIGTERM만 남았습니다. 같은 서버에 initialize 한 줄을 파이프로 넣었을 때는 바로 응답이 왔으므로, 응답이 올 때까지 기다리는 지금 판으로 바꿨습니다. 첫 판이 왜 아무것도 받지 못했는지는 밝히지 못했습니다.

확인하지 않은 것

함께 보기