Home / AI / LLM / MCP / SDK 없이 stdio MCP 서버를 직접 만들며 배운 것

SDK 없이 stdio MCP 서버를 직접 만들며 배운 것

September 22, 2026 · 13 min read AI/MCP

YouTube 댓글을 가져오는 MCP 서버를 만들어 Claude Desktop에 붙이려고 했을 때, 처음에는 SDK를 쓰지 않고 process.stdin 으로 직접 구현했다. 결과는 연결하자마자 끊기기의 반복이었다.

원인은 전부 프로토콜을 제대로 모르고 있었던 것이었다. 이 글에서는 SDK 없이 최소한의 stdio MCP 서버를 만들어 보면서, 그때 부딪힌 함정을 하나씩 정리한다.

  • 글의 코드는 모두 공식 TypeScript SDK(@modelcontextprotocol/sdk 1.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의 규칙은 세 줄이다

사양에 적힌 핵심은 이것뿐이다.

  1. 메시지는 한 줄에 JSON 하나. 줄바꿈으로 구분하고, 메시지 안에 줄바꿈이 있으면 안 된다
  2. 서버는 stdout에 MCP 메시지 말고는 아무것도 쓰면 안 된다
  3. 로그는 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"}]}}
  1. initialize: 클라이언트가 “이 버전으로 말할게, 나는 이런 기능이 있어” 라고 보낸다
  2. 서버는 사용할 프로토콜 버전, 자기가 제공하는 기능(capabilities), 서버 정보를 돌려준다
  3. 클라이언트가 notifications/initialized 를 보내면 준비 완료. id 가 없는 메시지는 notification이라서 응답하지 않는다
  4. 이후 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 를 넣는다. 사양상 도구를 제공하는 서버는 capabilitiestools 를 선언해야 한다(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로 만든다

참고