본문 바로가기
삵
AI

MCP 서버를 stateless 스펙에 맞게 고치기

포포스트맨·2026년 10월 28일·조회 0

사내 도구용으로 MCP 서버를 몇 개 띄워 둔 곳이 많습니다. 7월 28일 새 스펙이 나온 뒤로는 "지금 서버를 그대로 둬도 되느냐"는 질문을 자주 받습니다. 1년 전에 만든 SSE 엔드포인트 설정이 아직 남아 있는 곳도 적지 않습니다.

결론부터 말하자면, 2026-07-28 스펙에서 서버가 바꿀 부분은 크게 네 가지입니다. initialize 핸드셰이크와 Mcp-Session-Id 처리를 걷어내고, 요청마다 _meta에 담긴 프로토콜 버전과 HTTP 헤더를 검증하고, server/discover를 구현하고, GET과 DELETE 요청에는 405를 돌려줍니다. 오래 걸리는 도구는 Tasks 확장으로 task handle을 돌려주고 클라이언트가 tasks/get으로 폴링하게 바꿉니다. 2025-11-25 이하 버전을 쓰는 클라이언트가 남아 있다면 신구 버전을 함께 받는 서버(스펙 용어로 dual-era)로 운영해야 합니다.

옛 서버와 새 클라이언트가 어긋나는 지점

이전 스펙(2025-11-25까지)의 MCP는 세션 기반이었습니다. 클라이언트가 initialize를 보내 버전과 capability를 협상하고, 서버는 응답 헤더에 Mcp-Session-Id를 실어 세션을 발급했습니다. 이후 요청은 모두 그 세션 ID를 달고 와야 했습니다. 그래서 서버 인스턴스를 여러 대로 늘리면 로드밸런서에 sticky 세션을 걸어야 했습니다.

2026-07-28 스펙은 이 세션 개념을 프로토콜에서 없앴습니다. 핸드셰이크가 없으니 클라이언트는 모든 요청의 params._meta에 프로토콜 버전(io.modelcontextprotocol/protocolVersion)과 자신의 capability(io.modelcontextprotocol/clientCapabilities)를 직접 넣어 보냅니다. 서버는 요청을 하나씩 독립적으로 받거나 거절합니다. 같은 요청이 어느 인스턴스로 가도 결과가 같아야 한다는 뜻입니다.

공식 호환성 매트릭스를 보면 새 클라이언트가 옛 서버에 붙는 경우와 옛 클라이언트가 새 전용 서버에 붙는 경우 모두 실패합니다. Streamable HTTP에서 옛 클라이언트는 새 스펙이 요구하는 헤더를 보내지 않으므로 400 Bad Request를 받습니다. 옛 클라이언트는 새 버전으로 넘어가는 장치가 없어서 서버 쪽 에러 메시지가 사용자가 볼 수 있는 유일한 단서가 됩니다.

TypeScript SDK v1으로 만든 서버라면 아래 grep에 걸리는 줄부터 고치면 됩니다.

$ grep -rnE "sessionIdGenerator|mcp-session-id|onsessioninitialized|SSEServerTransport" src/
src/server.ts:21:    sessionIdGenerator: () => randomUUID(),
src/server.ts:22:    onsessioninitialized: (id) => { transports[id] = transport; },
src/server.ts:48:  const sid = req.headers["mcp-session-id"];

v1 SDK에서 sessionIdGenerator: undefined로 설정한 이른바 stateless 모드를 이미 쓰고 있어서 안심하는 경우도 있습니다. 그 모드는 세션 ID만 발급하지 않을 뿐이고, 클라이언트는 여전히 initialize부터 보냅니다. 요청마다 _meta를 검사하는 새 스펙과는 동작이 다릅니다. 실제로 옮겨 보면 이 부분을 같은 것으로 착각해서 손대지 않고 넘어가는 경우가 가장 많습니다.

Python 쪽은 SDK v2.0.0부터 2026-07-28을 지원하고, 같은 서버에서 이전 스펙 클라이언트도 별도 설정 없이 받습니다. FastMCP가 MCPServer로 이름이 바뀌는 등 깨지는 변경이 있으니 릴리스 노트의 마이그레이션 가이드를 먼저 확인하십시오. 아래 예제는 SDK 버전과 상관없이 프로토콜이 실제로 무엇을 요구하는지 확인하려고 Node 기본 http 모듈로 직접 짰습니다.

initialize 대신 요청마다 _meta와 헤더를 검사한다

새 스펙의 Streamable HTTP 서버는 POST 하나만 받는 엔드포인트입니다. 요청에는 MCP-Protocol-Version과 Mcp-Method 헤더가 반드시 있어야 합니다. tools/call, resources/read, prompts/get에는 Mcp-Name 헤더도 붙습니다. 이 헤더들은 본문의 값을 그대로 복사한 것입니다. 로드밸런서나 게이트웨이가 본문을 파싱하지 않고도 라우팅할 수 있게 하려는 것이라, 헤더와 본문이 다르면 서버는 반드시 400과 에러 코드 -32020(HeaderMismatch)으로 거절해야 합니다.

// server.mjs (Node 20+)
import http from "node:http";
import { randomUUID } from "node:crypto";

const SUPPORTED = ["2026-07-28"];
const SERVER_INFO = { name: "sarc-demo", version: "2.0.0" };
const TASKS_EXT = "io.modelcontextprotocol/tasks";
const tasks = new Map(); // 데모용. 운영에서는 Redis나 DB에 둔다

const tools = [
  { name: "get_weather", description: "도시 날씨 조회",
    inputSchema: { type: "object", properties: { city: { type: "string" } }, required: ["city"] } },
  { name: "build_report", description: "월간 리포트 생성(오래 걸림)",
    inputSchema: { type: "object", properties: { month: { type: "string" } }, required: ["month"] } },
];

const reply = (res, status, body) => {
  res.writeHead(status, { "Content-Type": "application/json" });
  res.end(JSON.stringify(body));
};
const err = (id, code, message, data) =>
  ({ jsonrpc: "2.0", id, error: data ? { code, message, data } : { code, message } });
const ok = (id, result) => ({
  jsonrpc: "2.0", id,
  result: { resultType: "complete", ...result,
            _meta: { "io.modelcontextprotocol/serverInfo": SERVER_INFO } },
});

function callTool({ name, arguments: args }, caps) {
  if (name === "build_report" && caps.extensions?.[TASKS_EXT]) {
    const now = new Date().toISOString();
    const task = { taskId: randomUUID(), status: "working", createdAt: now,
                   lastUpdatedAt: now, ttlMs: 3600000, pollIntervalMs: 2000 };
    tasks.set(task.taskId, task);
    setTimeout(() => Object.assign(task, {
      status: "completed", lastUpdatedAt: new Date().toISOString(),
      result: { content: [{ type: "text", text: `${args.month} 리포트 완료` }] },
    }), 10000);
    return { resultType: "task", ...task };
  }
  return { content: [{ type: "text", text: `${args.city ?? args.month}: 처리 완료` }] };
}

http.createServer(async (req, res) => {
  if (req.url !== "/mcp") return reply(res, 404, {});
  // 구 클라이언트의 GET(SSE 스트림)과 DELETE(세션 종료)는 405
  if (req.method !== "POST") { res.writeHead(405, { Allow: "POST" }); return res.end(); }
  // Mcp-Session-Id 헤더는 읽지도, 발급하지도 않는다

  let raw = "";
  for await (const chunk of req) raw += chunk;
  const { id, method, params = {} } = JSON.parse(raw);
  if (id === undefined) { res.writeHead(202); return res.end(); } // notification

  // 옛 클라이언트가 볼 수 있는 유일한 단서이므로 지원 버전을 꼭 적어 준다
  if (method === "initialize")
    return reply(res, 400, err(id, -32022, "Unsupported protocol version",
      { supported: SUPPORTED, requested: params.protocolVersion }));

  const meta = params._meta ?? {};
  const version = meta["io.modelcontextprotocol/protocolVersion"];
  const caps = meta["io.modelcontextprotocol/clientCapabilities"] ?? {};
  const h = req.headers;

  if (!h["mcp-protocol-version"] || !h["mcp-method"])
    return reply(res, 400, err(id, -32020, "Header mismatch: required MCP headers missing"));
  if (h["mcp-protocol-version"] !== version || h["mcp-method"] !== method)
    return reply(res, 400, err(id, -32020, "Header mismatch: headers do not match body"));
  // Base64 센티널(=?base64?...?=) 디코딩은 생략
  if (method === "tools/call" && h["mcp-name"] !== params.name)
    return reply(res, 400, err(id, -32020, "Header mismatch: Mcp-Name does not match params.name"));
  if (!SUPPORTED.includes(version))
    return reply(res, 400, err(id, -32022, "Unsupported protocol version",
      { supported: SUPPORTED, requested: version }));

  switch (method) {
    case "server/discover":
      return reply(res, 200, ok(id, {
        supportedVersions: SUPPORTED,
        capabilities: { tools: {}, extensions: { [TASKS_EXT]: {} } },
        instructions: "날씨 조회와 월간 리포트 생성 도구를 제공한다.",
        ttlMs: 3600000, cacheScope: "public",
      }));
    case "tools/list":
      return reply(res, 200, ok(id, { tools, ttlMs: 300000, cacheScope: "public" }));
    case "tools/call":
      return reply(res, 200, ok(id, callTool(params, caps)));
    case "tasks/get": {
      const t = tasks.get(params.taskId);
      if (!t) return reply(res, 200, err(id, -32602, "Unknown taskId"));
      return reply(res, 200, ok(id, t));
    }
    default:
      return reply(res, 404, err(id, -32601, "Method not found"));
  }
}).listen(8080, "127.0.0.1");

코드에서 짚을 곳은 세 군데입니다. 모든 결과에 resultType이 붙습니다. 새 스펙에서 필수 필드라서 일반 결과에는 "complete"를 넣습니다. tools/list 결과의 ttlMs와 cacheScope도 새로 필수가 된 캐시 힌트입니다. 서버 정보는 이제 핸드셰이크가 아니라 결과의 _meta에 io.modelcontextprotocol/serverInfo로 실립니다.

매번 긴 curl을 치기 번거로우니 셸 함수를 하나 만들어 둡니다. 두 번째 인자로 params JSON을 받고, 그 뒤 인자는 curl에 그대로 넘깁니다.

$ node server.mjs &
$ URL=http://127.0.0.1:8080/mcp
$ META='{"io.modelcontextprotocol/protocolVersion":"2026-07-28","io.modelcontextprotocol/clientInfo":{"name":"curl","version":"1.0"},"io.modelcontextprotocol/clientCapabilities":{"extensions":{"io.modelcontextprotocol/tasks":{}}}}'
$ mcp() {  # mcp <method> <params-json> [curl 추가 인자...]
    local m=$1 p=$2; shift 2
    jq -nc --arg m "$m" --argjson p "$p" --argjson meta "$META" \
      '{jsonrpc:"2.0",id:1,method:$m,params:($p + {_meta:$meta})}' |
    curl -s "$URL" -H 'Content-Type: application/json' \
      -H 'Accept: application/json, text/event-stream' \
      -H 'MCP-Protocol-Version: 2026-07-28' -H "Mcp-Method: $m" "$@" -d @-
  }

server/discover는 서버가 반드시 구현해야 하는 RPC입니다. 지원 버전과 capability, 서버 정보를 한 번에 돌려줍니다. 클라이언트 입장에서는 호출이 선택 사항이지만, stdio 클라이언트는 상대가 신버전인지 구버전인지 판별하는 첫 요청으로 이 RPC를 씁니다.

$ mcp server/discover '{}' | jq
{
  "jsonrpc": "2.0",
  "id": 1,
  "result": {
    "resultType": "complete",
    "supportedVersions": [
      "2026-07-28"
    ],
    "capabilities": {
      "tools": {},
      "extensions": {
        "io.modelcontextprotocol/tasks": {}
      }
    },
    "instructions": "날씨 조회와 월간 리포트 생성 도구를 제공한다.",
    "ttlMs": 3600000,
    "cacheScope": "public",
    "_meta": {
      "io.modelcontextprotocol/serverInfo": {
        "name": "sarc-demo",
        "version": "2.0.0"
      }
    }
  }
}

이제 옛 클라이언트처럼 요청을 보내 봅니다. 헤더 없이 initialize를 보내면 지원 버전 목록이 담긴 -32022를 받습니다.

$ curl -s -w '\nHTTP %{http_code}\n' "$URL" -H 'Content-Type: application/json' \
    -H 'Accept: application/json, text/event-stream' \
    -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-11-25","capabilities":{},"clientInfo":{"name":"old","version":"1.0"}}}'
{"jsonrpc":"2.0","id":1,"error":{"code":-32022,"message":"Unsupported protocol version","data":{"supported":["2026-07-28"],"requested":"2025-11-25"}}}
HTTP 400

$ curl -s -o /dev/null -w 'HTTP %{http_code}\n' "$URL" -H 'Accept: text/event-stream'
HTTP 405

$ mcp tools/list '{}' -i -H 'Mcp-Session-Id: abc' | grep -ci mcp-session-id
0

마지막 명령은 세션 헤더를 보내도 서버가 무시하고 응답에 되돌려 주지 않는지 확인합니다. 스펙은 새 버전 전용 서버가 Mcp-Session-Id를 무시하고, 새로 발급하거나 되돌려 보내지 말라고 권고합니다.

헤더와 본문이 어긋나는 경우도 한 번 확인합니다. 게이트웨이를 앞에 두는 구성에서 실제로 문제가 되는 부분이라 이 테스트는 꼭 넣어 두십시오.

$ curl -s -w '\nHTTP %{http_code}\n' "$URL" -H 'Content-Type: application/json' \
    -H 'Accept: application/json, text/event-stream' \
    -H 'MCP-Protocol-Version: 2026-07-28' -H 'Mcp-Method: tools/list' \
    -d '{"jsonrpc":"2.0","id":2,"method":"server/discover","params":{"_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28"}}}'
{"jsonrpc":"2.0","id":2,"error":{"code":-32020,"message":"Header mismatch: headers do not match body"}}
HTTP 400

에러 코드는 직접 짠 서버에서 자주 틀립니다. 초안 시절에 쓰던 HeaderMismatch -32001, UnsupportedProtocolVersion -32004는 정식판에서 각각 -32020, -32022로 바뀌었습니다. 리소스를 찾지 못했을 때의 코드도 -32002에서 -32602로 바뀌었습니다. RC 문서를 보고 미리 구현해 둔 서버라면 상수부터 확인하십시오.

세션에 상태를 묻어 두던 서버는 고칠 것이 하나 더 있습니다. 예를 들어 "세션별로 선택한 DB"를 메모리에 들고 있었다면 이제 그 상태를 붙잡을 세션이 없습니다. 스펙이 권하는 방법은 서버가 발급한 handle을 일반 도구 인자로 주고받는 것입니다. open_db 도구가 db_handle을 돌려주고, 다음 도구 호출이 그 값을 인자로 받게 만듭니다. 상태 자체는 Redis 같은 공유 저장소에 둡니다.

오래 걸리는 도구는 task handle을 돌려준다

몇 분씩 걸리는 도구를 SSE 스트림 하나로 붙잡고 있으면 중간 프록시의 타임아웃에 걸리기 쉽습니다. 새 스펙은 SSE 재개(Last-Event-ID) 기능까지 없앴으므로 스트림이 끊기면 진행 중이던 요청은 사라지고, 클라이언트가 새 요청 ID로 다시 보내야 합니다. 이런 도구는 Tasks로 바꾸는 것이 맞습니다.

Tasks는 이번 스펙에서 코어 프로토콜에서 빠져 공식 확장 io.modelcontextprotocol/tasks로 옮겨졌습니다. 서버는 일반 결과 대신 resultType: "task"와 taskId를 담은 CreateTaskResult를 돌려주고, 클라이언트는 tasks/get으로 상태를 폴링합니다. 예전에 결과가 나올 때까지 블로킹하던 tasks/result와 tasks/list는 없어졌고, 진행 중에 클라이언트가 입력을 넘기는 tasks/update가 생겼습니다. 서버는 클라이언트가 그 요청의 capability에 확장을 선언했을 때만 task를 돌려줘야 합니다. 위 코드의 caps.extensions?.[TASKS_EXT] 검사가 그 부분입니다.

$ mcp tools/call '{"name":"build_report","arguments":{"month":"2026-09"}}' \
    -H 'Mcp-Name: build_report' | tee /tmp/task.json \
  | jq '.result | {resultType, status, ttlMs, pollIntervalMs}'
{
  "resultType": "task",
  "status": "working",
  "ttlMs": 3600000,
  "pollIntervalMs": 2000
}

$ TASK=$(jq -r '.result.taskId' /tmp/task.json)
$ mcp tasks/get "$(jq -nc --arg t "$TASK" '{taskId:$t}')" | jq '.result | {resultType, status}'
{
  "resultType": "complete",
  "status": "working"
}

$ sleep 10; mcp tasks/get "$(jq -nc --arg t "$TASK" '{taskId:$t}')" | jq '.result | {status, result}'
{
  "status": "completed",
  "result": {
    "content": [
      {
        "type": "text",
        "text": "2026-09 리포트 완료"
      }
    ]
  }
}

tasks/get 응답 자체의 resultType은 "complete"입니다. 폴링 요청이 정상적으로 끝났다는 뜻이고, 작업이 끝났는지는 status로 판단합니다. 상태값은 working, input_required, completed, failed, cancelled 다섯 가지이고 뒤의 세 개가 종료 상태입니다. failed일 때는 result 대신 error 필드에 JSON-RPC 에러가 들어갑니다.

처음 Tasks를 붙일 때 가장 흔히 걸리는 부분은 저장소입니다. 위 데모처럼 task를 프로세스 메모리의 Map에 넣으면 인스턴스가 두 대만 되어도 tasks/get이 다른 인스턴스로 가서 Unknown taskId가 납니다. 스펙도 task를 응답 전에 영속적으로 만들어 두라고 요구합니다. 세션을 없앤 의미를 살리려면 task 상태는 처음부터 Redis나 DB에 두십시오. 클라이언트에게는 pollIntervalMs보다 촘촘하게 폴링하지 않도록 안내합니다.

SSE 엔드포인트와 sticky 세션을 걷어낸다

/sse로 스트림을 열고 /messages로 POST하던 HTTP+SSE 트랜스포트는 2025-03-26부터 이미 deprecated였습니다. 이번 스펙에서 새로 생긴 기능 수명주기 정책에 따라 정식으로 Deprecated 단계가 되었고, 이후 개정에서 제거될 수 있습니다. 정책상 deprecation 기간은 최소 12개월입니다. 새로 만드는 서버는 쓰지 말고, 기존 서버는 Streamable HTTP로 옮기라는 것이 스펙의 지시입니다.

제 권장은 단순합니다. 클라이언트를 직접 통제할 수 있는 사내 서버라면 SSE 엔드포인트와 세션 코드를 한 번에 지우고 2026-07-28 전용으로 갑니다. 버전을 알 수 없는 외부 클라이언트가 붙는 공개 서버라면 Python SDK v2처럼 신구 버전을 함께 받는 구현을 쓰고, 옛 SSE 엔드포인트는 새 /mcp와 경로를 분리해서 남겨 둡니다. 스펙도 옛 POST 엔드포인트와 새 MCP 엔드포인트를 합치는 방법은 불필요하게 복잡해질 수 있다고 적고 있습니다.

어느 쪽인지 판단이 안 서면 프록시 로그에 버전 헤더를 남겨 며칠 지켜보면 됩니다. 헤더가 비어 있는 요청이 옛 클라이언트입니다.

log_format mcp '$remote_addr [$time_local] "$http_mcp_protocol_version" "$http_mcp_method" $status';

$ awk -F'"' '{print $2}' /var/log/nginx/mcp_access.log | sort | uniq -c

세션이 없어졌으니 nginx 설정에서 ip_hash나 쿠키 기반 sticky 설정도 뺄 수 있습니다. 응답 SSE 스트림은 여전히 쓰이므로 버퍼링은 끕니다. 스펙은 서버가 SSE 응답에 X-Accel-Buffering: no를 넣으라고 권하지만, 프록시에서도 꺼 두면 확실합니다.

upstream mcp_backend {
    server 10.0.1.11:8080;
    server 10.0.1.12:8080;   # ip_hash 불필요
}

location = /mcp {
    limit_except POST { deny all; }
    proxy_pass http://mcp_backend;
    proxy_http_version 1.1;
    proxy_buffering off;
    proxy_read_timeout 300s;
}

$ sudo nginx -t && sudo systemctl reload nginx
nginx: the configuration file /etc/nginx/nginx.conf syntax is ok
nginx: configuration file /etc/nginx/nginx.conf test is successful

limit_except로 GET을 막으면 nginx가 405가 아니라 403을 돌려줍니다. 옛 클라이언트의 SSE 대체 판별 로직은 400, 404, 405를 기준으로 하므로, 신구 버전을 함께 받는 서버 앞에서는 이 줄을 빼고 405 처리를 애플리케이션에 맡기십시오.

코드 정리 범위도 생각보다 넓습니다. ping과 logging/setLevel 핸들러는 메서드 자체가 없어졌고, 로그 레벨은 요청 _meta의 io.modelcontextprotocol/logLevel로 옵니다. 서버가 클라이언트에 sampling/createMessage나 elicitation/create를 직접 보내던 코드는 InputRequiredResult를 돌려주고 클라이언트가 재요청하는 MRTR(Multi Round-Trip Requests) 방식으로 다시 짜야 합니다. Roots, Sampling, Logging 기능 자체도 deprecated라 새로 붙이지 않는 것이 맞습니다. 이 MRTR 전환이 세션 제거보다 손이 많이 가므로, 서버에 elicitation이 들어가 있다면 그 부분부터 견적을 내 보십시오.

자주 묻는 질문

v1 SDK의 stateless 모드(sessionIdGenerator: undefined)를 쓰고 있으면 따로 고칠 것이 없나요?

고쳐야 합니다. v1의 stateless 모드는 세션 ID만 발급하지 않을 뿐이고 클라이언트는 여전히 initialize 핸드셰이크를 보냅니다. 2026-07-28 스펙은 initialize 자체를 없애고 요청마다 _meta의 protocolVersion과 MCP-Protocol-Version, Mcp-Method 헤더를 검증하도록 요구하므로 v2 SDK로 올리거나 검증 로직을 직접 추가해야 합니다.

server/discover는 꼭 구현해야 하나요?

서버는 반드시 구현해야 합니다. 클라이언트 쪽 호출은 선택 사항이지만 stdio 클라이언트는 신구 버전 판별에 server/discover를 첫 요청으로 쓰고, 응답에는 supportedVersions, capabilities, _meta의 serverInfo가 들어갑니다.

2025-11-25 클라이언트가 2026-07-28 전용 서버에 붙으면 어떻게 되나요?

실패합니다. HTTP에서는 필수 헤더가 없어 400 Bad Request를 받고, 옛 클라이언트에는 새 버전으로 넘어가는 장치가 없습니다. 이런 클라이언트가 남아 있다면 Python SDK v2처럼 이전 버전도 함께 받는 서버로 운영하고, initialize에 대한 에러에 지원 버전을 명시하십시오.

Tasks에서 tasks/result는 어디로 갔나요?

Tasks가 코어에서 io.modelcontextprotocol/tasks 확장으로 옮겨지면서 블로킹 방식의 tasks/result와 tasks/list가 제거되었습니다. 클라이언트는 tasks/get을 pollIntervalMs 간격으로 호출해 status가 completed가 되면 result 필드에서 결과를 읽고, 입력이 필요하면 tasks/update로 응답합니다.

HTTP+SSE 트랜스포트는 언제 없어지나요?

2026-07-28 스펙에서 기능 수명주기 정책상 Deprecated 단계로 분류되었고 이후 개정에서 제거될 수 있습니다. 정책상 deprecation 기간은 최소 12개월이며, 구체적인 제거 시점은 deprecated 기능 목록에서 확인해야 합니다.

관련 글

댓글 0

로그인 후 댓글을 남길 수 있습니다.

아직 댓글이 없습니다.