
MCP 서버를 프로덕션 환경에 구축하고 배포하는 방법 (2026-07-28 사양)
요약
2026-07-28 사양 업데이트를 통해 MCP 서버가 상태 비저장(Stateless) HTTP 서비스로 변화함에 따라, 이를 프로덕션 환경에 구축하고 Cloudflare Workers에 배포하는 방법을 다룹니다.
핵심 포인트
- MCP 서버가 Stateless HTTP 서비스로 전환되어 로드 밸런싱 및 오토스케일링 용이
- TypeScript SDK v2를 사용한 새로운 MCP 서버 구축 가이드 제공
- MRTR을 통한 도구의 사용자 확인 요청 및 폴백 방식 설명
- Cloudflare Workers를 활용한 무료 배포 프로세스 포함
MCP가 출시 이후 가장 큰 업데이트를 맞이했습니다.
7월 28일, 메인테이너(maintainers)들이 **2026-07-28 사양 (spec)**을 출시했으며, 이는 MCP 서버가 작동하는 방식을 상당히 근본적인 수준에서 변화시킵니다. 핸드셰이크(handshake)가 사라졌습니다. 세션(sessions)도 사라졌습니다. 오랫동안 유지되어 온 세 가지 기능이 지원 중단(deprecated)되었습니다.
메인테이너들은 이를 권한 부여(authorization) 기능이 추가된 이후 가장 실질적인 변화라고 불렀습니다. 제 말이 아니라 그들의 말입니다.
무섭게 들릴 수도 있습니다. 하지만 실제로는 MCP 서버를 훨씬 더 쉽게 배포할 수 있게 해줍니다. 제가 여기 있는 이유가 무엇일까요? 여러분이 MCP 서버를 구축하고 배포하는 것을 돕기 위해서입니다.
이제 여러분의 MCP 서버는 단순한 일반 상태 비저장(stateless) HTTP 서비스가 됩니다. 라운드 로빈(Round-robin) 로드 밸런싱(load balancing), 오토스케일링(autoscaling), 그리고 캐싱(caching)이 모두 작동합니다. 스티키 세션(sticky sessions)이나 공유 세션 상태(shared session state)가 필요 없습니다.
이 가이드에서는 새로운 사양을 기반으로 작은 MCP 서버를 구축하고, 클라이언트를 연결하며, 모든 주요 기능이 실제로 작동하는 것을 확인한 다음, Cloudflare Workers에 무료로 배포해 보겠습니다.
ℹ️ 여기에 사용된 모든 코드는 사양과 함께 출시된 새로운 TypeScript SDK v2를 사용합니다. 만약 기존의
@modelcontextprotocol/sdk패키지를 사용 중이라면, 그것은 현재 v1입니다.
다루는 내용
- 2026-07-28 사양에서 실제로 변경된 점 요약
- 새로운 SDK v2를 사용하여 MCP 서버 구축하기
- 상태 비저장(Stateless) 코어의 작동 원리
- MRTR: 스트림을 열어두지 않고 도구가 사용자 확인을 요청하는 방법
- 아직 MRTR을 지원하지 않는 클라이언트(많이 존재함)를 위한 우아한 폴백(fallback) 방식
ttlMs를 이용한 캐시 가능한 도구 목록- 클라이언트 및 raw curl을 이용한 테스트
- 무료 플랜의 Cloudflare Workers에 배포하기
새로운 MCP 사양의 변경 사항 (2026-07-28)
새로운 변경 사항에 대한 빠른 요약입니다. 전체 변경 로그(changelog)를 확인하려면 공식 사양 사이트를 참조하세요.
핸드셰이크(Handshake) 제거
initialize / initialized 교환 방식과 Mcp-Session-Id 헤더가 공식적으로 폐기되었습니다.
이제 모든 요청은 **자기 기술적(self-describing)**입니다. 각 요청은 _meta 필드에 자체적인 프로토콜 버전, 클라이언트 식별 정보 및 기능(capabilities)을 포함합니다. 이제 어떤 요청이라도 일반 로드 밸런서(load balancer) 뒤에 있는 어떤 서버 인스턴스로든 전달될 수 있습니다. 정말 다행입니다!!
클라이언트가 사전에 기능(capabilities)을 확인하고자 하는 경우를 위해 선택 사항인 server/discover RPC가 존재하지만, 이는 선택 사항입니다. 이제 단 한 번의 POST 요청만으로 완전한 대화가 가능합니다.
다중 왕복 요청 (Multi Round-Trip Requests, MRTR)
제가 가장 좋아하는 부분입니다.
이전에는 도구(tool)가 호출 중간에 사용자로부터 확인(confirmation)이나 누락된 매개변수(parameter)와 같은 무언가가 필요할 경우, 서버는 열려 있는 스트림(held-open stream)을 통해 elicitation/create 요청을 다시 보내야 했습니다. 이는 스트림을 계속 유지해야 함을 의미하며, 상태가 없는(stateless) 배포 환경에는 좋지 않았습니다.
MRTR은 이를 뒤집었습니다. 서버는 답변이 필요한 질문들과 함께 resultType: "input_required"를 반환하고 연결을 종료합니다. 클라이언트는 답변을 수집한 후, 답변과 함께 서버가 어디서 중단되었는지 알 수 있도록 불투명한(opaque) requestState 토큰을 포함하여 원래의 호출을 재시도합니다.
열려 있는 스트림도, 세션(session)도 필요 없습니다. 완전히 상태가 없는(stateless) 인프라 위에서도 대화형 도구(interactive tools)를 사용할 수 있습니다.
헤더 기반 라우팅 (Header-based routing)
이제 요청은 Mcp-Method 및 Mcp-Name HTTP 헤더를 포함합니다. 게이트웨이(gateway), 속도 제한기(rate limiter) 또는 WAF가 JSON 본문(body)을 파싱하지 않고도 헤더를 기반으로 라우팅 및 측정(metering)을 수행할 수 있습니다.
캐시 가능한 목록 결과 (Cacheable list results)
tools/list, prompts/list, resources/list 및 resources/read 응답에는 이제 HTTP의 Cache-Control을 모델로 한 ttlMs 및 cacheScope 필드가 포함됩니다. 클라이언트는 연결할 때마다 매번 다시 가져오는 대신 도구 카탈로그(tool catalog)를 캐시합니다.
확장 프레임워크 (Extensions framework) + 폐기 사항 (deprecations)
Tasks가 실험적 코어(experimental core)에서 공식 확장 기능(io.modelcontextprotocol/tasks)으로 이동되었습니다. MCP Apps와 Enterprise Managed Authorization(기업 관리형 권한 부여)도 그곳에 위치합니다. 여러분도 자신만의 확장 기능을 구축할 수 있습니다.
그리고 폐기 사항(deprecations)은 다음과 같습니다:
- Roots, Sampling, Logging은 폐기되었습니다. 최소 12개월 동안은 계속 작동하지만, 새로운 구현에서는 이를 사용하지 말아야 합니다.
- 레거시 HTTP+SSE 전송(transport) 방식은 1년의 유예 기간(offramp)과 함께 폐기되었습니다.
- **동적 클라이언트 등록(Dynamic Client Registration)**은 Client ID 메타데이터 문서(CIMD)를 위해 폐기되었습니다.
이제 공식적인 폐기 정책도 마련되었습니다. 폐기(deprecated)로 표시된 모든 항목에 대해 최소 12개월의 유예 기간을 제공합니다. 덕분에 업그레이드 계획을 세울 수 있으니 정말 좋네요(noicee)!
SDK 분리 (The SDK Split)
구축하기 전에 한 가지 더 말씀드리자면, TypeScript SDK는 더 이상 하나의 패키지가 아닙니다.
v2에서는 이를 @modelcontextprotocol/server, @modelcontextprotocol/client, 그리고 가벼운 프레임워크 어댑터(@modelcontextprotocol/hono, express, fastify, node)로 분리합니다.
MCP 서버 구축하기 (Building an MCP Server)
드디어 구축 단계입니다. MCP를 사용하여 빠르고 작은 배포 봇(deploy bot)을 만들어 보겠습니다.
이 봇은 세 가지 도구(tools)를 가집니다:
deploy: 배포 전 사용자에게 확인을 요청합니다 (MRTR 작동).list_deployments: 배포 이력을 읽어옵니다.server_stats: 새로운 서버 인스턴스가 모든 요청을 처리했음을 증명합니다.
배포 시점에 이점을 주는 비결은 다음과 같습니다: 모든 MCP 로직은 하나의 플랫폼 중립적인 파일(bot.ts)에 존재하며, 각 플랫폼은 아주 작은 엔트리 파일(entry file)을 가집니다. Node는 server.ts를, Cloudflare는 worker.ts를 가집니다. 둘 다 약 10줄 정도입니다. 새로운 사양의 MCP 서버는 단지 fetch 핸들러일 뿐이며, 플랫폼은 이를 서빙하는 심(shim) 역할을 합니다.
진행하면서 모든 것을 이해하게 될 것입니다.
1단계: SDK v2 설치하기
다음 명령어를 실행하세요:
mkdir updated-mcp-spec-bot && cd updated-mcp-spec-bot
npm init -y && npm pkg set type=module
npm install @modelcontextprotocol/server @modelcontextprotocol/client \
...
ℹ️ TypeScript 6+ 버전에서는 @types/node를 설치한 후, tsconfig의 compilerOptions에 "types": ["node"]를 추가해야 합니다. TS 6부터는 @types/*를 더 이상 자동으로 포함하지 않으므로, 이를 설정하지 않으면 Cannot find name 'process' 에러가 발생합니다. 제가 이걸 어떻게 아냐고요? 😴
2단계: 서버 로직 (The server logic)
bot.ts를 생성합니다. 이것이 플랫폼 관련 코드가 전혀 포함되지 않은 전체 MCP 서버입니다:
// 👇 bot.ts
import type {
...
여기서 설명할 가치가 있는 몇 가지 사항이 있습니다:
buildServer()는 시작 시 한 번만 실행되는 것이 아니라, 모든 개별 요청(request)마다 실행됩니다. 모든 요청은 완전히 새로운 McpServer 인스턴스를 할당받습니다.
이 점이 놀랍다면 충분히 이해합니다. 저도 놀랐으니까요. 하지만 이것은 말 그대로 SDK 자체 예제에서 제시하는 정석적인 패턴 (canonical pattern)이며, 이번 릴리스의 핵심 의도이기도 합니다.
생성 (Construction) 과정은 단순히 객체를 생성하고 핸들러 맵 (handler map)을 만드는 과정일 뿐이며, 수 마이크로초(microseconds) 내에 완료됩니다. 더 이상 보존해야 할 프로토콜 상태 (protocol state)가 없으므로, 유지해야 할 것도 없습니다.
요청당 서버 생성, 프로세스당 리소스 (Per-request server construction, per-process resources). 애플리케이션 상태 (App state, 예: 우리의 deployments 배열, 상태 코덱 (state codec), 실제 환경에서의 DB 풀 (DB pool))는 모듈 레벨 (module level)에 존재합니다. 서버 인스턴스는 일회용 (disposable)입니다.
deploy 도구는 절대 차단 (block)되지 않습니다. 확인이 필요한 경우 inputRequired(...)를 반환하며 해당 요청은 종료됩니다. 끝입니다. 연결은 닫힙니다. requestState 토큰은 라운드(round) 사이에 살아남는 유일한 것이며, 클라이언트를 통해 왕복 (round-trips)합니다.
이는 클라이언트가 해당 토큰을 조작할 수 있음을 의미합니다. 그렇기 때문에 우리는 createRequestStateCodec을 사용하여 이를 봉인(seal)합니다. 이를 통해 조작되었거나 만료된 상태는 핸들러가 실행되기도 전에 와이어 레벨 (wire-level) 에러와 함께 거부됩니다.
코덱이 모듈 레벨이 아닌 첫 사용 시점에 지연 생성 (lazily created) 된다는 점에 주목하세요. Node 환경에서는 불필요한 간접 참조 (indirection)처럼 보일 수 있습니다. 하지만 그렇지 않습니다. Cloudflare Workers는 전역 스코프 (global scope)에서 난수(random values)를 생성하는 것을 금지하며, 바로 이 코드 한 줄 덕분에 동일한 파일이 두 플랫폼 모두에서 실행될 수 있습니다. globalThis.process?. 가드 (guards)도 마찬가지입니다. Workers에는 기본적으로 process 전역 객체가 없습니다.
따라서 도구(tool)는 요청별 엔벨로프(envelope)에서 클라이언트가 선언한 기능(capabilities)을 읽어오며(이는 CLIENT_CAPABILITIES_META_KEY 조회이며, 레거시 연결(legacy-connection)을 위한 폴백(fallback)을 포함합니다), 만약:
- 클라이언트가 유도(elicitation)를 지원한다면 전체 MRTR 확인 흐름(confirmation flow)을 따릅니다.
- 클라이언트가 지원하지 않는다면 도구는 선택적인
confirm: true인자를 수락하며, 이 인자가 없으면 "사용자에게 질문한 뒤, confirm: true와 함께 다시 deploy를 호출하세요"라는 일반적인 지침을 반환합니다.
Step 3: Node 엔트리 (The Node entry)
server.ts를 생성합니다. 이것이 Node 전용 설정의 전부입니다:
// 👇 server.ts
import { serve } from "@hono/node-server";
...
이것으로 끝입니다. createMcpHandler는 표준 fetch 스타일의 핸들러(handler)를 제공하며, Hono는 단순히 라우팅(routing)을 담당합니다. createMcpHonoApp()은 Host/Origin 헤더를 검증하여 (DNS 리바인딩 공격 방지(DNS rebinding protection)) 기본적으로 localhost만 허용하므로, ALLOWED_HOSTS 환경 변수는 실제 도메인 뒤에서 실행될 때를 위해 존재합니다.
모든 것은 환경 변수 기반(PORT, HOST, ALLOWED_HOSTS, STATE_KEY)으로 동작하는데, 이는 VM이나 Railway와 같은 PaaS가 원하는 방식이기 때문입니다. Cloudflare 배포에는 이 파일을 사용하지 않겠지만, 어디에서든 Node에서 실행하고 싶다면 이 방법이 경로가 될 것입니다.
Step 4: 클라이언트 (The client)
client.ts를 생성합니다:
// 👇 client.ts
import {
...
⚠️
versionNegotiation: { mode: 'auto' }를 놓치지 마세요. 이것이 없으면 클라이언트는 레거시 2025-11-25 프로토콜로 협상(negotiate)하게 되어 MRTR 흐름이 실패합니다. 이 문제를 디버깅하는 데 30분이 걸렸습니다.
유도(elicitation) 핸들러는 이전 흐름을 위해 작성했을 것과 동일한, 완전히 일반적인 elicitation/create 핸들러라는 점에 주목하세요. SDK의 자동 충족 엔진(auto-fulfillment engine)이 내장된 MRTR 요청을 이 핸들러를 통해 라우팅하고 도구 호출(tool call)을 대신 재시도해 줍니다. 여러분의 코드는 왕복(round trip) 과정조차 인지하지 못합니다.
Step 5: 실행하기 (Run it)
두 개의 터미널(tmux를 사용하는 것이 좋습니다)에서 다음을 실행하세요:
첫 번째 터미널에서:
npx tsx server.ts
그리고 다른 터미널에서:
npx tsx client.ts
다음과 같은 출력을 얻게 될 것입니다:
connected, negotiated protocol: 2026-07-28
tools/list: list_deployments, server_stats, deploy
...
여기에 있는 모든 줄은 사양(spec)의 기능을 입증하며, 저는 의도적으로 그렇게 설계했습니다:
2026-07-28: 레거시 폴백(legacy fallback)이 아닌 새로운 프로토콜(protocol)을 사용 중입니다.ttlMs=30000+served from cache: 두 번째listTools()호출은 네트워크를 전혀 거치지 않았습니다.- 유도(elicitation) 라인, 그 다음 배포(deploy): 이는 두 번의
tools/callPOST 요청이었습니다. 첫 번째 요청은input_required를 반환하고 종료되었습니다. 두 번째 요청은 정답과 함께 봉인된requestState를 포함했습니다. 스트림(stream)은 한 번도 열린 상태로 유지되지 않았습니다. requestsServed=6: 6개의 요청, 6개의 새로운 서버 인스턴스, 하나의 프로세스. 로드 밸런서(load balancer) 환경이었다면 이 6개는 서로 다른 6대의 머신에 도달했을 수도 있습니다. 정말 멋지지 않나요?- 마지막
list_deployments: 프로토콜 상태(protocol state)는 유지되지 않았음에도 애플리케이션 상태(app state)는 생존했습니다.
계산식은 다음과 같습니다: 4번의 도구 호출(tool calls), 여기에 네트워크를 거친 것은 1번뿐인 2번의 listTools(), 그리고 MRTR 재시도를 위한 1번의 추가 라운드 = 총 6번의 서버 빌드.
6단계: 로우 와이어(raw wire) 살펴보기
"핸드셰이크 없음(no handshake)" 현상을 확인해 봅시다. 초기화 과정이 없는 순수한 curl 명령입니다:
다음 명령어를 실행하세요:
curl -s -X POST http://127.0.0.1:3000/mcp \
-H "Content-Type: application/json" \
-H "Accept: application/json, text/event-stream" \
...
참고로, 이 curl 명령어는 Claude가 제안했습니다.
반환되는 결과는 다음과 같습니다:
{
"result": {
"content": [{ "type": "text", "text": "prod @ 2026-08-01T08:02:45.601Z" }],
"resultType": "complete",
"_meta": {
"io.modelcontextprotocol/serverInfo": {
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기

