MCP의 2026-08-26 변경 사항을 위한 Skill 및 Checker를 구축했습니다. 그런데 Checker가 틀렸더군요.
요약
MCP(Model Context Protocol)의 2.0.0 버전 업데이트에 따른 주요 변경 사항과 패키지 명칭 변경 이슈를 다룹니다. 특히 전송 방식의 무상태(stateless) 전환과 세션 ID 제거로 인한 마이그레이션 주의사항을 설명합니다.
핵심 포인트
- SDK 패키지명이 @modelcontextprotocol/server 등으로 세분화되어 변경됨
- 프로토콜이 무상태(stateless) 방식으로 전환되어 세션 ID 기반 관리가 불가능해짐
- server/discover 호출이 필수 사항으로 변경됨
- 기존 logging, sampling, roots 기능은 12개월의 유예 기간을 가진 deprecated 상태임
만약 당신이 지금 MCP 서버를 마이그레이션(migrating)하고 있다면, 이 포스트의 다른 무엇보다도 당신의 오후 시간을 앗아갈 사실이 하나 있습니다:
@modelcontextprotocol/sdk에는 2.x 버전이 없습니다. 앞으로도 없을 것입니다.
이 패키지는 1.30.0에서 멈춥니다. 만약 당신이 @modelcontextprotocol/sdk@^2를 찾으러 다닌다면 아무것도 찾지 못할 것이고, v2가 아직 출시되지 않았다고 결론 내릴 것입니다. 하지만 이미 출시되었습니다 — 2026-07-27에, 다른 이름들로 말이죠:
@modelcontextprotocol/server 2.0.0
@modelcontextprotocol/client 2.0.0
@modelcontextprotocol/core 2.0.0
...
제가 이 사실을 아는 이유는 제 도구가 사람들에게 완전히 확신에 찬 어조로 정반대의 이야기를 말했기 때문입니다.
무엇이 깨졌는가
2026-07-28 자 MCP 개정(revision)은 이 프로토콜이 겪은 가장 큰 변화입니다. 요약하자면 다음과 같습니다:
- 전송(transport) 방식이 **무상태(stateless)**가 되었습니다.
initialize/notifications/initialized핸드셰이크(handshake)가 사라졌습니다. 이제 모든 요청은_meta에 프로토콜 버전과 클라이언트 기능(capabilities)을 포함합니다. - Streamable HTTP에서
Mcp-Session-Id가 사라졌습니다. 더 이상 프로토콜 레벨의 세션(session)은 존재하지 않습니다. server/discover는 필수(MUST)입니다. 서버는 이를 통해 지원하는 프로토콜 버전과 기능(capabilities)을 광고해야 합니다.- 서버 주도 요청(
roots/list,sampling/createMessage,elicitation/create)은 **다중 왕복 요청(Multi Round-Trip Requests)**으로 대체됩니다. 즉, 서버가InputRequiredResult를 반환하면 클라이언트가 답변과 함께 재시도하는 방식입니다. logging,sampling,roots는 제거된 것이 아니라 사용 중단(deprecated)되었습니다 - 최소 12개월의 유예 기간이 있습니다.
두 번째 포인트가 가장 뼈아픈 부분입니다. 만약 세션 ID(session id)를 키로 하는 Map에 무언가를 계속 저장해 둔다면, 단일 프로세스로 실행되는 당신의 노트북에서는 완벽하게 작동하겠지만, 두 개의 인스턴스가 로드 밸런서(load balancer) 뒤에 배치되는 순간 간헐적으로 실패하게 됩니다. 이는 조용히 실패하며, 이는 가장 최악의 실패 방식입니다.
그래서 저는 체커(checker)를 구축했습니다. 7개의 규칙, 결정론적 엔진(deterministic engine), 모델이 개입하지 않는(no model in the loop) 방식입니다. 실행 중인 엔드포인트(endpoint)나 리포지토리(repository)를 지정하면 무엇이 깨지는지 알려주며, 각 발견 사항에 대한 명세(spec) 페이지를 함께 제공하여 잘못되었음을 증명할 수 있게 합니다. 이 체커는 마이그레이션(migration)까지 수행하는 에이전트 스킬(agent skill)로 배포되는데, 이 부분은 나중에 다시 다루겠습니다. 왜냐하면 스캐너(scanner) 자체는 전체 작업의 절반에 불과하다는 것이 밝혀졌기 때문입니다.
그러고 나서 제 규칙도 틀렸다는 것을 알게 되었습니다
한 가지 규칙인 MCP007은 @modelcontextprotocol/sdk가 2.0.0 미만으로 해결(resolve)될 때 실행되며 다음과 같이 말했습니다.
@modelcontextprotocol/sdk ^2로 업그레이드하세요. 기계적인 이름 변경을 위해 공식 v1→v2 코드모드(codemod)를 실행한 다음, 다시 확인하세요.
이 문장의 두 부분 모두 틀렸으며, 서로 다른 방식으로 틀렸습니다.
@modelcontextprotocol/sdk@^2는 해결되지 않습니다. 해당 패키지의 2.x 버전은 존재한 적이 없습니다. 따라서 이 도구는 존재하지도 않는 버전을 확신을 가지고 추천했고, 사용자들은 무엇을 잘못하고 있는지 고민하며 20분을 허비하게 만들었습니다.
저는 npm을 확인하여 최신 버전이 1.30.0임을 확인했고 버전 목록 어디에도 2.x가 없음을 발견했습니다. SDK 공지 사항을 읽었으나 명시된 메이저 버전(major version)도 없었기에, 이 규칙이 통째로 조작되었다고 결론지었습니다. 그래서 저는 해당 규칙을 삭제하고, 2.x 라인이 존재하지 않으며 코드모드(codemod)도 존재하지 않는다는 설명 주석을 작성했습니다. 그리고 — 이 부분이 뼈아픈 부분인데 — 그 "수정 사항"을 README, 스킬(skill), 그리고 복구 가이드(remediation guide)에 전파했습니다.
저는 틀린 진술을 또 다른 방식의 틀린 진술로 대체한 것이었습니다.
두 번째 실수가 왜 그렇게 쉬웠는가
제가 실제로 무엇을 확인했는지 보십시오. 저는 _규칙이 명시한 바로 그 패키지_를 확인했습니다. 그것은 1.30.0에서 끝납니다. 해당 패키지 내부에서 보면, 다음 두 상황은 구분이 불가능합니다.
아직 v2가 존재하지 않음
v2가 다른 이름으로 존재함
이름을 변경하는 것은 부재(absence) 상황에서 예상할 수 있는 것과 정확히 일치하는 증거를 만들어냅니다. 같은 장소에서 더 엄격하게 검사하는 것은 도움이 되지 않습니다. 정답이 그곳에 없기 때문입니다. 저는 오직 _codemod_를 찾아 나섰을 때에만 그것을 발견할 수 있었는데, 알고 보니 그것은 별도의 패키지로 존재하고 있었으며, 그 설명에는 "v1에서 v2로 MCP TypeScript SDK 코드를 마이그레이션하기 위한 Codemod"라고 적혀 있었습니다. 존재하지도 않는 v2를 위한 codemod를 배포한다는 것은 이상한 일일 것입니다.
이제 규칙은 버전 임계값(version threshold) 대신 v1 패키지의 존재 여부를 기준으로 삼습니다. 패키지 이름이 실제 신호(signal)이기 때문입니다:
// 패키지 이름 자체가 v1 라인입니다. 1.30.0에서 멈추며
// 2026-07-28 이전의 프로토콜을 따릅니다. v2는 완전히 다른 이름으로 출시되었으므로,
// 여기서 버전 비교를 하는 것은 의미가 없습니다.
...
그리고 이제 테스트가 첫 번째 실수를 영구적으로 고정합니다:
test("MCP007은 실제 교체 패키지와 실제 codemod의 이름을 지정합니다", () => {
const f = evaluate({ source: withSdk("^1.17.0") })
.find((x) => x.ruleId === "MCP007");
...
긍정적 단언(positive assertions)은 부정적 단언만큼이나 중요합니다. 잘못된 패키지 이름만을 금지하는 테스트는 아무것도 지정하지 않는 규칙에서도 통과될 것이며, 이는 대략 제가 삭제했을 때 발생했던 결과와 비슷합니다.
그 과정에서 발견한 또 다른 문제
모든 규칙은 명세(spec) 섹션을 인용하여, 사용자가 도구를 신뢰하는 대신 직접 확인할 수 있도록 합니다. 하지만 그 일곱 개의 링크는 모두 다음과 같은 모습이었습니다:
명세는 여러 하위 페이지로 나뉘어 있습니다. 그러한 앵커(anchor)가 없습니다. 그 링크들은 모두 조용히 개요(overview) 페이지로 연결되었습니다. 404 오류도, 깨진 링크 경고도 없이, 단지 인용된 페이지가 아닌 페이지가 나타날 뿐이었습니다. 조용히 검증할 수 없는 "명세와 대조하여 확인하세요" 기능은 차라리 기능이 없는 것보다 나쁩니다. 얻지도 못한 신뢰성을 빌려오기 때문입니다.
이제 그 문제에 대해서도 테스트가 마련되었습니다:
test("어떠한 specRef도 페이지 앵커에 의존하지 않습니다", () => {
for (const rule of rules) {
assert.ok(rule.specRef.startsWith("https://"));
...
두 번째 규칙은 스펙(spec) 사이트로 연결되는 모든 링크가 리비전 루트(revision root)가 아닌 하위 페이지(subpage)를 가리키도록 단언(assert)합니다. 이를 통해 향후의 규칙이 다시 메인 페이지를 조용히 인용하는 일을 방지할 수 있습니다.
일반화할 수 있는 부분
MCP 여부와 상관없이, 어떤 프로젝트에서든 적용할 수 있는 두 가지 사항이 있습니다.
결정론적(Deterministic)인 것이 반드시 올바른(correct) 것은 아닙니다. 이 엔진의 핵심 판매 포인트는 재현 가능하다는 점입니다. 즉, 동일한 입력에 대해 동일한 출력을 내놓으며, 화요일이라고 해서 모델이 다르게 판단하지 않는다는 것입니다. 이는 실제적인 속성이며 가질 만한 가치가 있습니다. 하지만 이 속성은 저에게 신뢰할 수 있고, 반복 가능하며, 검증 가능한 방식으로 '틀린' 규칙을 만들어 주기도 했습니다. 결정론은 출력값에 대해 논쟁할 수 있는 능력을 제공할 뿐, 진실을 보장해주지는 않습니다.
"결과 없음"과 "잘못된 곳을 찾아봄"은 동일한 증거를 생성합니다. 제가 계속해서 생각하게 되는 지점입니다. 확인한 주소에 무언가가 없다는 것이 곧 그 대상의 부재를 의미하지는 않습니다. 이름 변경(rename)이 깔끔한 예시이지만, 이러한 양상은 어디에서나 나타납니다. 이동된 설정 키(config key), 버전이 변경된 엔드포인트(endpoint), 다른 모듈로 추출된 함수 등이 그러합니다. 체크 결과가 부정적으로 나왔고 그 부정적인 결과가 의외라면, "그러므로 존재하지 않는다"라고 결론 내리기 전에 "내가 올바른 곳을 보고 있는가"를 다음 질문으로 던져야 합니다.
저는 예전보다 에이전트 주도형(agent-driven) 코드를 더 많이 작성하고 있으며, 그에 따라 실패 모드(failure mode)도 변화했습니다. 이제는 대부분 구문(syntax)의 문제가 아닙니다. 생태계에 대해 자신감 있고, 그럴듯하며, 내부적으로 일관된 진술을 하지만, 결과적으로 사실이 아닌 경우가 많습니다. 이에 대한 대응책은 신뢰를 줄이는 것이 아니라, 주장을 검증 가능하게 만들고 실제로 검증하는 것입니다. 이것이 모든 규칙이 특정 페이지를 인용하는 이유이며, 링크가 깨지는 것이 처음 생각했던 것보다 더 중요했던 이유입니다.
스캐너가 더 작은 부분인 이유
이것은 제가 SDK를 잘못 파악하기 전에, 제 도구 자체에 대해 잘못 알고 있었던 부분입니다.
소스 스캔은 정규 표현식(regex) 기반입니다. 이는 증거가 아닌 _신호(signals)_를 보고합니다. 에이전트에게 출력값을 가리키며 발견한 것을 수정하라고 지시하면, 조만간 다음과 같은 상황에 직면하게 될 것입니다:
app.use(session({ secret: process.env.ADMIN_SESSION_SECRET }));
app.get("/admin/whoami", (req, res) => {
...
이것은 이미 MCP 전송(transport)이 상태가 없는(stateless) 서버에서 관리자 패널를 위한 Express 세션(session)입니다. 해당 문자열이 존재한다는 규칙 자체는 맞습니다. 하지만 그 규칙에 따라 조치를 취한다는 것은 결코 고장 난 적 없는 무언가를 리팩터링(refactoring)한다는 의미이며, 아무도 필요로 하지 않는 변경은 그 발견의 가치보다 더 많은 비용을 발생시킵니다.
따라서 실제로 배포되는 것은 스캐너(scanner)가 아니라 하나의 절차(procedure)입니다:
- 진단 (Diagnose) — 번들로 제공되는 결정론적 체커(deterministic checker)를 실행합니다. 소스 스캔(source scan)과 서버가 실행 중인 환경에서의 라이브 프로브(live probe)를 모두 수행합니다. 이 둘은 서로 다른 것을 보며, 어느 하나가 다른 하나의 상위 집합(superset)도 아닙니다.
- 분류 (Triage) — 무언가에 손을 대기 전에 모든 발견 사항에 대해
file:line단위로 정당성을 검토하고, 어떤 것이 노이즈(noise)인지 명확히 말합니다. 이 단계가 존재하는 이유는 바로 위의 코드 때문입니다. - 교정 (Remediate) — 의존성 순서에 따라 진행합니다: SDK를 가장 먼저(유지할 API를 대상으로 리팩터링하기 위해), 그다음 세션 상태(session state), 핸드셰이크(handshake), 지원 중단(deprecations), 그리고 OAuth 태세(posture) 순입니다. 각 단계 이후에는 다시 실행하여 어떤 변경 사항이 무엇을 해결했는지 확인할 수 있어야 합니다.
- 검증 (Verify) — 정적 검사(static check)로는 답할 수 없는 사항을 포함합니다: 두 개의 연속된 요청이 *서로 다른 인스턴스(different instances)*에 도달했을 때도 서버가 여전히 정상적으로 동작하는가?
규칙 ID(rule ids)가 이 모든 것을 하나로 묶어줍니다. 체커는 MCP002를 출력하고, 교정 가이드에는 실제 세션 의존성과 Express 의존성을 구분하는 방법을 설명하는 MCP002 키가 지정된 섹션이 있습니다. 진단과 수정은 서로 어긋나는 두 개의 문서가 아닙니다.
이것은 Claude skill입니다. 페이로드(payload)는 Claude 전용이 아닙니다.
정확히 짚고 넘어갈 가치가 있습니다. 왜냐하면 "모든 에이전트와 작동한다"라는 식의 자신만만한 주장은 이 포스트 전체가 경계하고자 하는 내용이기 때문입니다.
.skill 형식 — 주제가 언급될 때 에이전트가 자동으로 호출하는 프론트매터(frontmatter)가 포함된 SKILL.md — 은 Claude의 것입니다. Claude Code나 Claude.ai에서 파일을 설치하고 서버 마이그레이션을 요청하면, 설명(description) 필드가 나머지 작업을 수행합니다.
내용물은 Claude의 것이 아닙니다. .skill 파일은 zip 파일입니다:
mcp-migration/
├── SKILL.md 절차, 일반 마크다운 (plain markdown)
├── references/remediation.md ID별 규칙 가이드 (per-rule guidance)
...
mcpcheck.mjs는 Node만 있으면 작동하는 룰 엔진 (rule engine)의 esbuild 번들 (bundle)입니다. 따라서 Codex, Cursor, 또는 쉘 명령 (shell command)을 실행할 수 있는 무엇이든 다음과 같이 사용할 수 있습니다:
unzip mcp-migration.skill
node mcp-migration/scripts/mcpcheck.mjs --source ./my-server --json
node mcp-migration/scripts/mcpcheck.mjs https://example.com/mcp
종료 코드 (Exit codes)는 CI 친화적입니다: 0은 심각한 발견 사항 없음, 1은 최소 하나 이상 발견, 2는 결론을 내릴 수 없음 (inconclusive)을 의미합니다. references/remediation.md는 단독으로 읽어도 괜찮지만, AGENTS.md 옆에 두면 사용 중인 어떤 에이전트 (agent)라도 Claude가 받는 것과 동일한 분류 규칙 (triage rules)을 갖게 됩니다.
의존성 (dependency)으로 배포하지 않고 번들로 만든 이유는 바로 이것 때문입니다: 이 스킬 (skill)은 npm install이 한 번도 실행된 적 없는 머신에 설치될 것이며, 그곳에서 임포트 (import)를 시도하면 실패할 것이기 때문입니다. 그 대가로 리포지토리 (repo)에 생성된 파일이 커밋되어 내용이 오래될 (stale) 수 있다는 점이 있지만, 이를 위해 CI가 매 푸시 (push)마다 파일을 다시 빌드하며, 내용이 다를 경우 실패하도록 처리합니다.
마이그레이션 (migrating)을 진행 중이라면 실질적인 부분
코드모드 (codemod)는 실제로 작동하며 기계적인 절반의 작업을 처리합니다:
npx @modelcontextprotocol/codemod@latest v1-to-v2 .
깨끗한 트리 (clean tree)에서 실행하세요. 이 도구는 임포트 경로 (import paths)를 다시 쓰고, 심볼 이름 변경 (symbol renames) (McpError → ProtocolError, StreamableHTTPError → SdkHttpError), setRequestHandler(Schema, …) → setRequestHandler('method/string', …), .tool() → registerTool, 그리고 extra.* → ctx.mcpReq.*를 수행합니다. 안전하게 결정할 수 없는 경우에는 추측하는 대신 마커 (marker)를 남깁니다:
grep -rn '@mcp-codemod-error' .
v2는 Zod 4를 요구하므로, Zod 3를 사용하는 프로젝트는 앞에 두 번째 업그레이드 단계를 마주하게 된다는 점에 유의하세요.
그리고 코드모드의 표현을 빌리자면, 기계적이지 않은 부분은 다음과 같습니다:
코드모드는 v1→v2 SDK 표면 (surface) 업그레이드만을 처리합니다. 2026-07-28 프로토콜 개정안 (
createMcpHandler, 다중 왕복 요청 (multi-round-trip requests),versionNegotiation)을 채택하는 것은 아키텍처 (architectural)적인 문제이며 코드모드로 자동화할 수 없습니다.
그것은 정확하며 진지하게 받아들일 가치가 있는 문제입니다. 세션 상태 (session state)를 제거하는 것은 설계 결정 (design decision)입니다. 이를 삭제하거나, 명시적인 도구 인자 (tool argument)로 전달하거나, 양쪽 인스턴스가 모두 접근할 수 있는 저장소 (store)로 옮겨야 합니다. 어떤 도구도 당신을 대신해 그런 결정을 내려주지 않으며, 당신이 그 작업을 수행하는 동안 지원 중단 예정 (deprecated) 기능들은 12개월 동안 기다려 줄 수 있습니다.
이 모든 것은 오픈 소스이며, MIT 라이선스입니다:
- Hosted checker — 엔드포인트 (endpoint)를 붙여넣으면 등급이 매겨진 보고서를 받습니다. 설치할 것도, 저장되는 것도 없습니다.
- Download the skill — Claude에 설치하거나, 압축을 풀어 어떤 에이전트 (agent)에서든 체커 (checker)를 실행할 수 있습니다.
- Source — 86개의 테스트가 포함되어 있으며, 대부분은 SSRF 가드 (SSRF guard)를 겨냥하고 있습니다. 호스팅된 버전이 낯선 사람이 입력한 URL을 가져오기 때문입니다.
README에는 _A rule that was wrong (틀렸던 규칙)_이라는 섹션이 있습니다. 거기에는 이 포스트의 내용을 더 적은 단어로 대략적으로 설명하고 있으며, 그 상태로 유지됩니다.
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기