YouTube 댓글을 가져오는 MCP 서버를 만들어 Claude Desktop에 붙이려고 했을 때, 처음에는 SDK를 쓰지 않고 process.stdin 으로 직접 구현했다. 결과는 연결하자마자 끊기기의 반복이었다.
원인은 전부 프로토콜을 제대로 모르고 있었던 것이었다. 이 글에서는 SDK 없이 최소한의 stdio MCP 서버를 만들어 보면서, 그때 부딪힌 함정을 하나씩 정리한다.
- 글의 코드는 모두 공식 TypeScript SDK(
@modelcontextprotocol/sdk1.30.0)의 클라이언트로 실제 연결해서 확인했다 - 프로토콜 버전은
2025-11-25기준이다. 2026-07-28판에서 크게 바뀐 점은 마지막에 따로 정리했다
MCP를 한 줄로
MCP(Model Context Protocol)는 LLM 애플리케이션이 외부 도구·데이터에 접근하는 방법을 정한 프로토콜이다.
- 호스트: Claude Desktop, Cursor 같은 LLM 애플리케이션
- 클라이언트: 호스트 안에서 서버 하나와 연결을 담당하는 부분
- 서버: 도구(tool), 리소스, 프롬프트를 제공하는 프로그램. 우리가 만드는 쪽
메시지는 전부 JSON-RPC 2.0 이고, 이를 실어 나르는 방식(transport)은 두 가지가 표준이다.
| transport | 방식 |
|---|---|
| stdio | 클라이언트가 서버를 자식 프로세스로 실행하고 stdin/stdout으로 주고받는다 |
| Streamable HTTP | 하나의 HTTP 엔드포인트에 POST로 주고받는다 (예전 HTTP+SSE 방식을 대체) |
로컬에서 도는 서버는 대부분 stdio다. 이 글도 stdio만 다룬다.
stdio의 규칙은 세 줄이다
사양에 적힌 핵심은 이것뿐이다.
- 메시지는 한 줄에 JSON 하나. 줄바꿈으로 구분하고, 메시지 안에 줄바꿈이 있으면 안 된다
- 서버는 stdout에 MCP 메시지 말고는 아무것도 쓰면 안 된다
- 로그는 stderr에 쓴다
2번이 생각보다 자주 발목을 잡는다. 뒤에서 다시 보자.
연결부터 도구 실행까지의 흐름
클라이언트가 서버를 실행하면 아래 순서로 메시지가 오간다. 실제로 만든 서버에 손으로 메시지를 흘려 넣은 결과다(→ 는 클라이언트가 보낸 것, ← 는 서버 응답).
→ {"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-11-25","capabilities":{},"clientInfo":{"name":"cli","version":"0"}}}
← {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":"2025-11-25","capabilities":{"tools":{}},"serverInfo":{"name":"hand-made-server","version":"0.1.0"}}}
→ {"jsonrpc":"2.0","method":"notifications/initialized"}
→ {"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"add","arguments":{"a":2,"b":3}}}
← {"jsonrpc":"2.0","id":2,"result":{"content":[{"type":"text","text":"5"}]}}
- initialize: 클라이언트가 “이 버전으로 말할게, 나는 이런 기능이 있어” 라고 보낸다
- 서버는 사용할 프로토콜 버전, 자기가 제공하는 기능(
capabilities), 서버 정보를 돌려준다 - 클라이언트가 notifications/initialized 를 보내면 준비 완료.
id가 없는 메시지는 notification이라서 응답하지 않는다 - 이후
tools/list로 도구 목록을 받고,tools/call로 실행한다
최소 서버 코드
의존성 없이 Node.js만으로 만든 서버다. 두 숫자를 더하는 add 도구 하나를 제공한다.
// server.mjs
import readline from "node:readline";
const SUPPORTED = ["2025-11-25", "2025-06-18", "2025-03-26"];
const log = (...args) => console.error("[server]", ...args); // 로그는 stderr 로
const tools = [
{
name: "add",
description: "두 숫자를 더한다",
inputSchema: {
type: "object",
properties: { a: { type: "number" }, b: { type: "number" } },
required: ["a", "b"],
},
},
];
function handle(method, params) {
switch (method) {
case "initialize": {
const requested = params.protocolVersion;
return {
protocolVersion: SUPPORTED.includes(requested) ? requested : SUPPORTED[0],
capabilities: { tools: {} },
serverInfo: { name: "hand-made-server", version: "0.1.0" },
};
}
case "ping":
return {};
case "tools/list":
return { tools };
case "tools/call": {
const { a, b } = params.arguments;
return { content: [{ type: "text", text: String(a + b) }] };
}
default:
throw { code: -32601, message: `Method not found: ${method}` };
}
}
const send = (msg) => process.stdout.write(JSON.stringify(msg) + "\n");
readline.createInterface({ input: process.stdin }).on("line", (line) => {
const msg = JSON.parse(line);
if (msg.id === undefined) {
log("notification:", msg.method); // notification 에는 응답하지 않는다
return;
}
try {
send({ jsonrpc: "2.0", id: msg.id, result: handle(msg.method, msg.params ?? {}) });
} catch (e) {
send({ jsonrpc: "2.0", id: msg.id, error: { code: e.code ?? -32603, message: e.message ?? String(e) } });
}
});
process.stdin.on("end", () => process.exit(0)); // stdin 이 닫히면 종료
공식 SDK의 클라이언트로 붙여 보면 정상적으로 동작한다.
// client.mjs
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
const transport = new StdioClientTransport({ command: "node", args: ["server.mjs"] });
const client = new Client({ name: "test-client", version: "0.0.1" });
client.onerror = (e) => console.log("ONERROR:", e.message);
await client.connect(transport);
console.log("tools:", (await client.listTools()).tools.map((t) => t.name));
console.log("call:", JSON.stringify((await client.callTool({ name: "add", arguments: { a: 2, b: 3 } })).content));
await client.close();
[server] notification: notifications/initialized
tools: [ 'add' ]
call: [{"type":"text","text":"5"}]
GUI로 확인하고 싶다면 공식 MCP Inspector(npx @modelcontextprotocol/inspector node server.mjs)를 써도 된다.
내가 밟은 함정들
위 코드에서 한 줄씩 빼거나 바꿔서 그때의 에러를 재현해 봤다.
1. initialize를 처리하지 않았다
처음 코드는 tools/call 만 처리하고 있었다. 클라이언트는 연결하자마자 initialize 를 보내는데, 서버가 모르는 메소드라고 답하니 그대로 연결이 끝난다.
ERROR: MCP error -32601: Method not found: initialize
서버가 가장 먼저 받는 메시지는 항상 initialize 다. 응답에는 protocolVersion, capabilities, serverInfo 를 넣는다. 사양상 도구를 제공하는 서버는 capabilities 에 tools 를 선언해야 한다(tools: {}).
protocolVersion 은 클라이언트가 요청한 버전을 지원하면 그대로, 지원하지 않으면 서버가 지원하는 최신 버전을 돌려준다. 클라이언트가 그 버전을 모르면 클라이언트 쪽에서 연결을 끊는다.
2. console.log로 디버그 로그를 찍었다
stdout은 MCP 메시지 전용 통로다. 디버그용으로 console.log("Starting ...") 를 한 줄 넣는 순간 클라이언트는 그 줄을 JSON으로 읽으려다 실패한다.
ONERROR: Unexpected token 's', "[server] no"... is not valid JSON
Claude Desktop 로그에서 Unexpected token 'S', "Starting Y"... is not valid JSON 을 봤던 게 바로 이것이었다. 로그는 전부 console.error (stderr)로 보낸다. 사용하는 라이브러리가 stdout에 무언가를 출력하지 않는지도 확인해야 한다.
3. notification에도 응답했다
“받은 메시지에는 다 답해야지” 하고 notifications/initialized 에도 응답을 보냈더니, 클라이언트 쪽에서 메시지 형식 검증 에러가 났다.
ONERROR: [ { "code": "invalid_union", ... } ]
JSON-RPC에서 id 가 없는 메시지는 notification이고, 응답을 보내면 안 된다. 요청(id 있음)과 notification(id 없음)을 구분하는 게 서버의 첫 번째 분기다. SDK 클라이언트는 에러만 내고 넘어갔지만, 클라이언트에 따라서는 이런 이상한 메시지를 받으면 연결을 끊는다.
4. 프로세스가 금방 끝나 버렸다
stdin을 한 번만 읽고 끝나는 구조로 만들면, 첫 요청을 처리한 뒤 프로세스가 종료되어 다음 요청을 받지 못한다. 서버는 stdin이 열려 있는 동안 계속 살아 있어야 하고, 반대로 stdin이 닫히면 스스로 종료하는 게 좋다. 위 코드는 readline 으로 계속 읽다가 end 이벤트에서 종료한다.
결국은 SDK를 쓰자
여기까지 알고 나면 SDK가 무엇을 대신해 주는지 보인다. 같은 서버를 SDK로 쓰면 이렇게 된다.
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: "sdk-server", version: "0.1.0" });
server.registerTool(
"add",
{
description: "두 숫자를 더한다",
inputSchema: { a: z.number(), b: z.number() },
},
async ({ a, b }) => ({ content: [{ type: "text", text: String(a + b) }] })
);
await server.connect(new StdioServerTransport());
initialize와 버전 협상, notification 구분, ping, 입력값 검증까지 SDK가 처리한다. 실제 서비스용 서버는 SDK로 만드는 게 맞다.
그래도 한 번 직접 만들어 본 의미는 있었다. 연결이 끊겼을 때 로그의 -32601 이나 is not valid JSON 을 보고 원인을 바로 짐작할 수 있게 됐다.
2026-07-28판 사양에서 바뀌는 것
이 글을 쓰는 시점에 MCP 사양의 최신판은 2026-07-28이다. 이 판에서는 위에서 설명한 연결 방식이 크게 바뀌었다.
- initialize 핸드셰이크가 없어졌다. 연결 단위의 세션 대신, 모든 요청의
_meta에 프로토콜 버전(io.modelcontextprotocol/protocolVersion)과 클라이언트 기능(io.modelcontextprotocol/clientCapabilities)을 싣는 stateless 방식이 됐다 - 서버는 지원 버전을 알려주는
server/discover를 반드시 구현해야 한다 - 결과(
result)에resultType필드가 붙는다 - 서버가 클라이언트에게 요청을 보내는 방식이 없어지고, 응답 안에서 추가 입력을 요구하는 방식(MRTR)으로 바뀌었다
사양에서는 initialize를 쓰는 2025-11-25 이전 버전을 legacy, 새 방식을 modern 이라고 부른다. 둘 다 지원하는 서버도 만들 수 있다.
다만 2026년 9월 현재 공식 TypeScript SDK 최신판(1.30.0)도 아직 2025-11-25 까지만 지원한다. 당분간 실제 클라이언트와 연결할 때는 이 글의 initialize 흐름이 그대로 쓰인다. stdio의 세 가지 규칙(한 줄에 JSON 하나, stdout은 MCP 메시지 전용, 로그는 stderr)은 새 판에서도 똑같다.
정리
- stdio MCP는 한 줄에 JSON-RPC 메시지 하나를 stdin/stdout으로 주고받는 구조다
- 서버가 처음 받는 메시지는
initialize. 버전·기능·서버 정보를 돌려줘야 연결이 시작된다 id없는 메시지(notification)에는 응답하지 않는다- stdout에는 MCP 메시지만. 로그는 반드시 stderr로
- 원리를 한 번 이해했다면 실제 서버는 SDK로 만든다