두 MCP 요청 경로가 중앙 handler에서 stateless와 legacy 방향으로 갈라지는 추상 라우팅 도식

Cloudflare Agents SDK McpAgent createMcpHandler 마이그레이션: 레거시 요청 회귀 테스트하기

Cloudflare Agents SDK McpAgent createMcpHandler 마이그레이션은 McpAgent.serve()를 새 함수 이름으로 바꾸는 작업이 아니다. Cloudflare Workers changelog에 따르면 Agents SDK v0.20.0에서 McpAgent는 deprecated·feature-frozen 상태가 됐고, 새 경로는 요청마다 SDK v2 server를 만드는 factory를 createMcpHandler에 넘긴다. 제거 버전은 아직 발표되지 않았다.

문제는 기존 /mcp 요청 가운데 세션에 기대는 기능이 섞여 있을 때다. 이 글은 2026년 8월 8일 Agents SDK 0.20.0과 격리된 로컬 fixture로 확인한 modern·legacy 요청 결과를 바탕으로, 단일 stateless 경로와 dual route를 가르는 회귀 gate를 만든다. 실제 Cloudflare Worker, Durable Object, 운영 트래픽과 session drain은 실행하지 않았다.

20초 핵심 요약

  • 무엇: McpAgent의 세션 의존 기능을 분류한 뒤 SDK v2 factory 기반 createMcpHandler로 옮기는 절차다.
  • 왜: 기본 handler는 ordinary legacy 요청도 200으로 처리하므로, 프로토콜 버전만 보고 기존 세션 경로를 제거하면 GET·DELETE·replay·pushed request가 끊길 수 있다.
  • 어떻게: 동일한 modern·legacy fixture를 기본 handler와 legacy: "reject" handler에 넣고 HTTP 상태, JSON-RPC 본문, tool schema와 실제 선택된 lane을 함께 검사한다.

먼저 API 치환과 상태 이전을 분리한다

코드에서 가장 먼저 보이는 변화는 작다. SDK v1 compatibility overload는 생성된 server instance를 받지만, SDK v2 경로는 factory 자체를 받는다.

// SDK v1 compatibility overload: constructed server
return createMcpHandler(createServer())(request, env, ctx);

// SDK v2: factory 자체를 전달
return createMcpHandler(createServer)(request, env, ctx);

Cloudflare의 MCP SDK v2 migration guide에 설명된 SDK v2 factory는 요청마다 새 server와 transport를 만든다. 전역 SDK v2 server instance를 만들거나 생성된 instance를 넘기는 방식은 새 lifecycle과 맞지 않는다. Worker의 default export도 handler function 자체가 아니라 fetch()를 가진 object 형태로 유지해야 한다. Wrangler가 function default export를 WorkerEntrypoint class로 해석하기 때문이다.

이 변경만 끝내고 세션 상태를 그대로 두면 마이그레이션은 완료되지 않는다. McpAgent의 legacy lane은 계속 @modelcontextprotocol/sdk의 SDK v1 McpServer를 사용해야 하며, @modelcontextprotocol/server의 SDK v2 server를 그 안에서 실행할 수 없다. 따라서 import 교체와 session dependency 제거를 별도 작업으로 다뤄야 한다.

설치 버전도 문서의 한 줄을 그대로 고정하지 않는 편이 안전하다. v0.20.0의 peer dependency는 @modelcontextprotocol/[email protected]였지만, 조사일의 Cloudflare 문서 예시는 2.0.0으로 갱신돼 있었다. lockfile과 현재 설치한 agents release의 peer dependency를 먼저 확인하고 exact MCP version을 맞춘다.

legacy 요청보다 sessionful dependency를 먼저 찾는다

isLegacyRequest()true라는 사실과 해당 요청이 세션형 기능을 쓴다는 사실은 같지 않다. Cloudflare MCP handler API에서 확인되는 기본 createMcpHandlerlegacy 값은 "stateless"다. ordinary tools, prompts, resources처럼 MCP 세션에 기대지 않는 legacy 요청은 기본 handler가 같은 /mcp 경로에서 처리할 수 있다.

반면 같은 공식 handler API가 구분한 다음 세션 의존성이 하나라도 남아 있으면 임시 legacy lane이 필요하다.

  • MCP protocol session 또는 전달한 WorkerTransport
  • transport storage나 event replay
  • standalone HTTP GET stream
  • pushed elicitation, sampling, roots request
  • HTTP DELETE를 이용한 session deletion
  • McpAgent RPC 또는 MCP session ID에 묶인 application state

stateless 전환은 이 목록을 무조건 삭제하는 작업도 아니다. session-keyed data는 인증된 server-issued handle로 Durable Object, D1, KV, R2 같은 application storage를 찾게 만들 수 있다. multi-step interaction은 integrity-protected requestStateinput_required MRTR로 옮기고, list change는 subscriptions/listen으로 전환할 수 있다. 중요한 차이는 transport session이 아니라 각 요청이 명시적으로 상태를 가리키게 하는 데 있다.

회귀 fixture가 먼저 실패한 이유부터 고친다

직접 검증 환경은 Linux kernel 7.0.0-1009-aws 로컬 컨테이너, Node 22.23.2, Agents SDK 0.20.0, @modelcontextprotocol/server 2.0.0-beta.5, Zod 4.4.3이었다. 프로젝트 기본 Node 18.19.1에서는 MCP SDK v2 package의 node >=20 조건 때문에 EBADENGINE 경고가 발생해 test runtime을 Node 22로 바꿨다.

fixture는 /mcp POST 두 개로 제한했다. modern fixture는 MCP-Protocol-Version: 2026-07-28, Mcp-Method: tools/list와 per-request _meta envelope를 사용했다. legacy fixture는 protocol version 2025-11-25initialize handshake였다. 같은 입력을 isLegacyRequest(), 기본 handler, legacy: "reject" handler에 반복했다.

처음부터 handler 차이가 보인 것은 아니다. Host를 넣지 않은 두 fixture는 모두 HTTP 403 Missing Host header를 받았다. modern 요청에 _meta["io.modelcontextprotocol/protocolVersion"]를 빼면 HTTP 400과 필수 per-request envelope key 누락 오류가 발생했다. URL과 protocol header만 갖춘 요청은 유효한 2026-07-28 fixture가 아니다.

Zod 4.0.17을 사용했을 때는 더 까다로운 실패가 나왔다. tools/list가 HTTP 200을 반환했지만 JSON-RPC body 안에는 schema conversion error가 남아 있었다. Zod 4.4.3으로 맞춘 뒤 실제 tool schema가 포함된 complete result를 확인했다. 이 과정 때문에 회귀 테스트의 성공 기준을 상태 코드 하나로 둘 수 없다.

같은 입력으로 compatibility와 reject 경로를 대조한다

fixture를 바로잡은 뒤 아래 명령으로 세 가지 테스트를 실행했다. 출력은 식별 정보 없이 판단에 필요한 행만 남긴 로컬 테스트 기록이다.

$ npm_config_cache="$PWD/.npm-cache" npm exec --yes --package=node@22 -- node --test migration.test.mjs; test_status=$?; echo "[exit $test_status]"; exit "$test_status"
TAP version 13
# route modern tools/list -> stateless
# route legacy initialize -> legacy
# default modern tools/list -> 200
# default legacy initialize -> 200
# reject modern tools/list -> 200
# reject legacy initialize -> 400
# reject legacy body -> {"jsonrpc":"2.0","error":{"code":-32022,"message":"Unsupported protocol version: 2025-11-25","data":{"supported":["2026-07-28"],"requested":"2025-11-25"}},"id":2}
# Subtest: legacy classifier separates protocol eras
ok 1 - legacy classifier separates protocol eras
# Subtest: default compatibility handler accepts both fixtures
ok 2 - default compatibility handler accepts both fixtures
# Subtest: reject handler preserves modern and rejects legacy
ok 3 - reject handler preserves modern and rejects legacy
1..3
# tests 3
# suites 0
# pass 3
# fail 0
# cancelled 0
# skipped 0
# todo 0
[exit 0]

로컬 fixture에서 modern 200 유지와 legacy initialize의 기본 200·reject 400을 대조한 터미널 출력

관측 결과는 공식 handler 설명과 일치했다. isLegacyRequest()는 MCP 2026-07-28 tools/list를 modern, 2025-11-25 initialize를 legacy로 분류했다. 기본 compatibility handler는 두 요청을 모두 200으로 처리했고, reject handler는 modern 성공을 유지하면서 legacy protocol을 JSON-RPC error code -32022로 거절했다.

동일 입력 기본 legacy: "stateless" legacy: "reject" 라우팅 판단
MCP 2026-07-28 tools/list 200 200 stateless lane
MCP 2025-11-25 initialize 200 400 (-32022) 세션 의존성이 있으면 legacy lane

이 표의 전후 비교는 실제 운영 McpAgent Durable Object를 실행한 결과가 아니다. 기본 handler의 compatibility lane과 stateless-only control을 같은 로컬 입력으로 비교한 결과다. 회귀 suite에서는 HTTP status 외에도 JSON-RPC의 resulterror, 기대한 tool name과 input schema, reject error의 code와 message, 실제 선택된 handler를 나타내는 spy 또는 lane marker를 검사해야 한다.

dual route에서는 분기가 handler보다 먼저 와야 한다

세션 의존 요청이 남았다면 공식 migration guide의 dual-era 패턴처럼 isLegacyRequest()를 먼저 실행한다. stateless handler에는 legacy: "reject"를 명시해 요청 소유권을 분명하게 둔다.

import { isLegacyRequest } from "@modelcontextprotocol/server";
import { createMcpHandler } from "agents/mcp/server";

const stateless = createMcpHandler(createStatelessServer, {
  route: "/mcp",
  legacy: "reject",
});
const legacy = MyMcpAgent.serve("/mcp");

export default {
  async fetch(request, env, ctx) {
    if (await isLegacyRequest(request)) {
      return legacy.fetch(request, env, ctx);
    }
    return stateless(request, env, ctx);
  },
};

여기서 legacy: "reject"는 legacy client를 즉시 차단한다는 뜻이 아니다. 앞선 분기가 legacy 요청을 기존 lane으로 보낸 뒤, stateless lane에 잘못 들어온 legacy protocol을 명시적으로 거절하는 방어 설정이다. 기본값을 유지하면 ordinary legacy stateless 요청을 한 handler에서 받기에는 편하지만, sessionful legacy lane과 병행할 때 어느 handler가 요청을 소유하는지 흐려진다.

테스트에는 서비스가 실제 사용하는 tool call, prompt/resource list를 추가한다. GET stream, DELETE session 종료, replay, pushed request를 사용한다면 각각 별도 fixture와 lane assertion이 필요하다. 이번 로컬 검증은 이 세션형 동작과 createLegacyMcpHandler를 직접 실행하지 않았으므로, 운영 전 별도 integration test가 남는다.

단일 /mcp 경로로 줄이는 배포 gate

단일 기본 createMcpHandler 경로로 전환하려면 코드 치환 성공보다 아래 조건을 먼저 확인한다.

  • session ID, GET stream, DELETE, replay, pushed request, McpAgent RPC 의존성을 코드와 fixture에서 제거했다.
  • modern ordinary tools, prompts, resources가 정상 JSON-RPC result와 예상 schema를 반환한다.
  • 계속 지원할 legacy stateless fixture도 기본 handler에서 정상 result를 반환한다.
  • legacy lane traffic이 사라지고 기존 session이 drain됐음을 운영 telemetry에서 확인했다.
  • route 전환과 protocol-only Durable Object binding 제거를 서로 다른 배포 단계로 나눴다.
  • rollback 시 legacy route와 binding을 복원할 순서가 준비돼 있다.

legacy fixture가 남았다는 이유만으로 dual route가 영구적으로 필요하다고 단정할 수도 없다. isLegacyRequest()는 protocol era를 판별할 뿐 실제 session feature 사용 여부를 알려주지 않는다. route hit count와 feature-specific test를 함께 보고, ordinary legacy stateless 요청만 남았다면 기본 compatibility handler로 흡수할 수 있는지 판단한다.

되돌리기도 두 단계로 준비한다. 먼저 stateless route를 이전 dual route 코드로 복구하고, 별도 배포에서 제거했던 Durable Object binding을 복원한다. route와 binding을 한 번에 제거하면 어느 변경이 session failure를 만들었는지 분리하기 어렵고 롤백 경로도 좁아진다.

마이그레이션을 보류해야 하는 경계

이번 결과만으로 실제 McpAgent session drain, GET·DELETE, replay, pushed elicitation·sampling·roots, OAuth와 production traffic을 검증했다고 볼 수 없다. 이 기능이 남은 서비스는 로컬 handler 3개가 통과했다는 이유로 legacy route를 제거하면 안 된다.

반대로 ordinary tools, prompts, resources만 사용하고 modern·legacy fixture의 status와 body가 모두 통과하며 운영 telemetry에서도 기존 session이 소진됐다면 단일 경로를 검토할 수 있다. McpAgent 제거 일정이 없다는 점은 마이그레이션을 미룰 근거가 아니라, dual route와 rollback을 두고 단계적으로 옮길 수 있는 여유다.

SDK 업그레이드 전반의 gate 설계가 더 필요하면 HuntLab의 Cloudflare Agents AI SDK v7 회귀 테스트를 이어서 확인한다.

참고 링크

비슷한 글

답글 남기기

이메일 주소는 공개되지 않습니다. 필수 필드는 *로 표시됩니다