운송, 표면, 피부: 명세(Spec)를 견뎌내는 MCP 플러그인 구축하기
요약
Model Context Protocol(MCP)의 기술적 진화와 2026년 예정된 대규모 명세(Spec) 변경 사항을 다룹니다. MCP가 단순한 도구를 넘어 AI와 인간, 애플리케이션이 공유하는 인프라로 발전하는 과정과 무상태(stateless) 프로토콜로의 전환을 설명합니다.
핵심 포인트
- MCP는 AI와 사용자가 동일한 워크스페이스를 공유하게 하는 인프라임
- 2026년 7월, 무상태(stateless) 프로토콜로의 대대적인 개정 예정
- 핸드셰이크 및 세션 개념이 사라지고 모든 요청에 메타데이터 포함
- 서버가 클라이언트에 요청을 보내는 방식에서 클라이언트 재시도 방식으로 변경
소프트웨어 엔지니어링은 죽었다. 사람들이 그렇게 말한다.
부분적으로는 사실이다. 이 기술의 일부 전통적인 요소들은 이제 정말 찾아보기 어려워졌다. 만약 모델이 함수를 작성할 수 있다면, 왜 사람이 앉아서 직접 타이핑을 하겠는가? 솔직한 대답은 이렇다. 점점 더, 사람들은 그렇게 하지 않을 것이다.
하지만 타이핑을 잃는 것이 엔지니어링을 잃는 것과 같지는 않다. 실제로 일어난 일은 흥미로운 질문의 차원이 한 단계 높아졌다는 것이다. 과거에는 "이 애플리케이션을 어떻게 구축할 것인가"가 질문이었다면, 이제는 "인간, 애플리케이션, 그리고 모델이 어떻게 하나의 워크스페이스(workspace)를 공유할 것인가"가 질문이다.
그 질문이야말로 Model Context Protocol (MCP)이 단순한 호기심의 대상에서 인프라로 빠르게 발전한 이유이다. MCP는 하나 이상의 애플리케이션을 AI와 함께 동시에, 그리고 동기적으로 사용하는 것에 대한 해답이다. 모델이 사용자의 뒤에서 몰래 당신의 API를 호출하는 것이 아니라, 모델과 사용자, 그리고 당신의 앱이 모두 동일한 라이브 표면(live surface) 위에서 작동하는 것이다.
그렇다면: 실제로 이런 것들을 어떻게 구축하는가?
프로토콜은 움직이는 타겟이며, 그것이 핵심이다
2025년에 MCP 서버를 출시한 사람이라면 누구나 최소 두 번은 코드를 다시 작성했을 것이다. 운송(transport) 이야기만 해도 stdio를 거쳐 SSE, 그리고 Streamable HTTP로 이어졌다. 결국 Streamable HTTP가 승리했으며, 현재는 이를 구축하는 가장 지배적인 방식이 되었다.
그리고 2026년 7월 28일, 다음 개정판이 출시된다. 이는 MCP를 무상태(stateless)로 만들기 때문에 역대 가장 큰 변화가 될 것이다:
initialize/initialized핸드셰이크 (handshake)가 사라집니다 (SEP-2575). 프로토콜 버전 (Protocol version), 클라이언트 정보, 그리고 기능 (capabilities)은 이제 모든 요청의_meta내io.modelcontextprotocol/protocolVersion과 같은 키 아래에 포함됩니다.- 프로토콜 수준의 세션 (sessions)이 사라집니다 (SEP-2567). 더 이상
Mcp-Session-Id는 존재하지 않습니다. 어떤 요청이든 어떤 인스턴스(instance)로든 도달할 수 있습니다. - 독립적인 GET 스트림 엔드포인트 (GET stream endpoint)가 제거됩니다 (SEP-2575). 수명이 긴 스트림 (Long lived streams)이 사라진 것은 아니며, 위치가 이동했습니다. 변경 알림 (change notifications)은 이제
subscriptions/listenPOST 요청의 응답 스트림 (response stream)을 통해 전달되며, 이 스트림은 열린 상태를 유지하며 사용자가 선택한 알림 유형만 전달합니다. 사라진 것은 재개 가능성 (resumability)입니다.Last-Event-ID와 SSE 이벤트 ID가 완전히 없어졌기 때문입니다. - 서버는 더 이상 클라이언트에 요청을 보내지 않습니다. 샘플링 (Sampling), 유도 (elicitation), 그리고 루트 (roots)는
InputRequiredResult에 내장되며, 클라이언트는 일치하는inputResponses와 함께 원래의 호출을 재시도함으로써 이에 응답합니다 (SEP-2322, "다중 왕복 요청 (multi round trip requests)"). 명세 (spec)는 단호합니다: 서버는 응답 스트림에서 "독립적인 JSON-RPC 요청을 보내서는 안 되며 (MUST NOT)", 클라이언트는 "JSON-RPC 응답을 전혀 보내서는 안 됩니다 (MUST NOT)". - 두 개의 라우팅 헤더 (routing headers)가 필수 사항이 됩니다 (SEP-2243): 모든 요청에 대한
Mcp-Method, 그리고tools/call,resources/read,prompts/get에만 적용되는Mcp-Name입니다. 이제 중간 매개체 (Intermediaries)는 본문 (body)을 파싱하지 않고도 작업 (operation)에 대해 라우팅 및 속도 제한 (rate limit)을 수행할 수 있습니다.MCP-Protocol-Version은 여기서 새로 도입된 것이 아니며 2025-06-18부터 존재해 왔습니다. 작성 시 대소문자 비대칭에 주의하십시오: 해당 헤더는 모두 대문자이며, 새로운 쌍은Mcp-형식을 따릅니다. - 스트리밍 가능한 HTTP (Streamable HTTP)에서의 취소 (Cancellation)는 이제 단순히 응답 스트림을 닫는 것입니다. 이 전송 방식 (transport)에서는
notifications/cancelled를 사용하지 않습니다. - 기능 (Capabilities)에
extensions필드가 추가됩니다 (SEP-2133). 이를 통해 확장 기능 (extensions)이 진정한 일급 객체 (first class)가 됩니다: 기능 맵 (capability maps)을 통해 협상되는 역 DNS 식별자 (reverse DNS identifiers)가 각자의 고유한 버전 주기(version cadence)를 가진 자체 저장소에 존재하게 됩니다.
그 목록을 다시 읽어보며 그것이 무엇을 의미하는지 주목해 보십시오. 당신의 MCP 서버는 계속 살려두어야 하는 상태 유지 데몬 (stateful daemon)이 아니라, 스티키 세션 (sticky sessions)으로 변모합니다. 그것은 다음과 같은 순수 함수 (pure function)가 됩니다:
(Request, Token) -> Response
이는 Cloudflare Worker, Deno Deploy 핸들러, Pages Function, 또는 Bun.serve fetch의 형태와 정확히 일치합니다. 라운드 로빈 로드 밸런싱 (Round robin load balancing), 공유 상태 없음 (zero shared state), 메모리 누수를 일으킬 세션 저장소 없음 (no session store).
이것은 구축하기 훨씬 더 간단한 것입니다. 또한 엔지니어링의 초점이 더 이상 배관 작업 (plumbing)에 있지 않음을 의미합니다. 그것은 계약 (contracts)에 있습니다.
세 가지 레이어, 세 가지 계약
서버가 순수 함수가 되면, 잘 구축된 MCP 플러그인은 서로 아무런 관련이 없는 세 가지 관심사 (concerns)로 깔끔하게 분해됩니다:
| 레이어 (Layer) | 관심사 (Concern) | 패키지 (Package) |
|---|---|---|
| 운송 (Transport) | HTTP, OAuth, CORS, 라이프사이클 (lifecycle) | @maxhealth.tech/mcp-http |
| ... |
각각은 프레임워크 (framework)가 아니라 계약 (contract)입니다. 세 가지 중 어떤 것이든 버리고 나머지 두 가지를 유지할 수 있습니다. 하나씩 살펴보겠습니다.
레이어 1: 운송 (transport)
@maxhealth.tech/mcp-http는 Web Fetch API를 기반으로 구축된 프레임워크 불가지론적 (framework agnostic) MCP HTTP 운송 (transport)입니다. 이는 Workers, Pages Functions, Deno Deploy, Bun, Node 18+, 그리고 Hono가 배포하는 모든 환경에서 실행됩니다. 첫날부터 상태 비저장 (stateless)을 우선시했기 때문에, 7월의 수정 사항에서도 재설계가 필요하지 않았습니다. 기능 추가는 있었지만, 재설계는 없었습니다.
Cloudflare Workers에서 실행되는 완전한 인증된 MCP 서버:
import { createWorkerFetch, forwardBearer } from '@maxhealth.tech/mcp-http'
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
...
그것이 서버의 전부이며, 이를 통해 단순한 전송(transport) 이상의 기능을 얻을 수 있습니다. RFC 9728 /.well-known/oauth-protected-resource가 자동으로 제공되며, 선택적인 RFC 8414 권한 부여 서버 메타데이터(authorization server metadata), 적절한 401 응답과 WWW-Authenticate 리소스 메타데이터 포인터를 포함한 Bearer 추출, 30초의 시계 왜곡(clock skew) 버퍼를 고려한 JWT exp 조기 거부, 설정 가능한 CORS, 그리고 결과, 상태 및 지속 시간을 보고하는 onRequest 관찰 가능성(observability) 훅을 제공합니다.
그 안에는 여러분이 무엇을 사용하든 상관없이 가져다 쓸 만한 가치가 있는 두 가지 설계 결정이 있습니다.
createServer는 요청당 팩토리(per request factory)입니다. 이는 호출자의 원시 토큰(raw token)을 수신합니다. 이것이 상태가 없는 인증(stateless auth)의 핵심 비결입니다. 서버 인스턴스는 해당 인증을 수행한 요청이 유지되는 동안에만 존재하므로, 토큰이 다른 사용자의 호출로 유출되는 일이 절대 발생하지 않습니다. 내부 구현은 놀라울 정도로 단순합니다:
// POST당 하나의 전송(transport), 세션 ID 생성기 없음
const transport = new WebStandardStreamableHTTPServerTransport()
await server.connect(transport)
...
forwardBearer(token)는 대리 호출(on behalf of calls)을 위한 이음매(seam)입니다. 이는 호출자의 신원을 상위 단계로 전달하는 fetch를 제공합니다. 여러분의 도구는 자격 증명(credential)을 직접 보는 것이 아니라, 함수를 보게 됩니다. 모든 FHIR 읽기 작업이 실제 권한 범위(scopes)를 가진 실제 인간에게 귀속되어야 하는 의료 환경에서, 이 이음매는 데모 수준의 결과물과 감사관(auditor) 앞에 내놓을 수 있는 결과물을 가르는 차이점이 됩니다.
또한 handleMcpPostStateful 경로가 존재하는데, 이는 서버가 샘플링 (sampling) 및 createMessage 호출을 수행할 수 있도록 요청 전반에 걸쳐 전송 (transport)을 유지합니다. 현재 이 기능이 무엇인지 명확히 인지해야 합니다. 이는 Mcp-Session-Id를 기반으로 라우팅하고 initialize 시점에 세션을 생성(mint)하는데, 새로운 개정판(revision)에서는 이 두 가지가 모두 사라집니다. 샘플링 (sampling), 루트 (roots), 로깅 (logging) 자체는 12개월의 제거 유예 기간과 함께 지원 중단 (deprecated)될 예정이며, ping, logging/setLevel, notifications/roots/list_changed는 즉시 제거됩니다. 따라서 이것은 "무상태 (stateless) 모드에서 할 수 없는 기능"이 아니라, 곧 사라질 기능들을 위한 레거시 클라이언트 경로입니다. 이 기능이 필요하지 않도록 설계하십시오.
새로운 개정판이 전송 (transport)에 실제로 요구하는 비용
차이가 없다고 주장하는 것보다 격차에 대해 솔직하게 말하는 것이 더 유용합니다. 초기에는 무상태 (stateless) 방식이 올바른 형태였으며, 그 형태는 여전히 유효합니다. 남아있는 작업은 실질적이지만 범위가 제한적입니다:
- 헤더 검증 (Header validation)은 이제 전송 (transport)의 역할입니다. 본문 (body)을 읽는 서버는 필수 헤더 누락을 포함하여, 헤더와 본문의 불일치를
400및 JSON-RPC-32020(HeaderMismatch)으로 반드시 거부 (reject)해야 합니다. 이는 명백히 전송 계층 (transport layer)의 작업입니다. - MCP 엔드포인트에 대한
GET및DELETE요청은404가 아닌405를 반환해야 합니다. 그래야 오래된 클라이언트가 "잘못된 URL"과 "잘못된 시대 (wrong era)"를 구분할 수 있습니다. POST가 아닌 모든 요청에 대해 404를 반환하는 것은 작은 변경이 필요합니다. - 기본 CORS 설정의 다이어트가 필요합니다.
Mcp-Session-Id와Last-Event-ID를 노출하는 것은 이제 더 이상 존재하지 않는 두 개의 헤더를 노출하는 것과 같습니다. - SDK 하한선이 이동합니다. 프로토콜 의미론 (
_meta버전 관리, MRTR,subscriptions/listen)은@modelcontextprotocol/sdk에서 제공되므로,>= 1.29.0버전 범위의 피어 (peer)를 위해서는 베타 (beta) 업데이트가 필요합니다. Python, TypeScript, Go, C#용 베타 SDK는 이미 출시되었습니다.
이 중 어느 것도 여러분의 도구 (tools)에는 영향을 미치지 않습니다. 그것이 바로 이 이음매 (seam)의 핵심입니다.
레이어 2: 표면 (surface)
이제 흥미로운 부분입니다. 도구가 실행되었고, 데이터가 확보되었습니다. 무엇이 돌아올까요?
기본적인 답변은 모델이 요약해야 할 텍스트의 벽입니다. 이는 단순 조회(lookup)에는 괜찮지만, 사람이 직접 다뤄야 하는 작업에는 최악입니다. 만약 MCP의 목적이 AI와
함께(together with) 애플리케이션을 사용하는 것이라면, 도구 결과(tool result)는 그 자체로 하나의 애플리케이션이 될 수 있어야 합니다.
그것이 바로 @maxhealth.tech/prefab이 하는 일입니다. 서버에서 컴포넌트 트리(component tree)를 구축하여 display()에 전달하면, 다음과 같은 엔벨로프(envelope)를 반환합니다.
import { display, Column, H1, autoTable } from '@maxhealth.tech/prefab'
async function listPatients() {
...
display()는 트리를 $prefab 와이어 포맷(wire format)으로 직렬화(serialize)하고 이를 MCP 도구 결과로 접어 넣습니다. 이때 JSON을 모델의 폴백(fallback) 용도로 content[]에 넣고, 호스트가 렌더링할 수 있도록 structuredContent에 넣습니다. ui:// 리소스의 단일 스크립트 태그로 로드되는 의존성 없는 바닐라 DOM 렌더러(vanilla DOM renderer)가 호스트의 샌드박스된 iframe 내부에 이를 그려냅니다. 115개 이상의 컴포넌트, 반응형 템플릿 표현식(reactive template expressions), 그리고 원시 행(raw rows)을 단 한 번의 호출로 실제 UI로 변환하는 자동 렌더러(autoTable, autoChart, autoForm, autoMetrics)를 제공합니다.
그 밑바탕이 되는 메커니즘은 MCP Apps이며, 이에 대한 상태를 정확히 짚고 넘어갈 가치가 있습니다. 많은 글이 이 부분을 잘못 설명하고 있기 때문입니다. MCP Apps는 핵심 명세(core specification)의 일부가 아닙니다. 이는 두 가지 공식
확장(extensions) 중 하나로, 프로토콜 버전 2026-01-26과 함께 도입되었습니다. 이는 자체 리포지토리(modelcontextprotocol/ext-apps)에 존재하며 독립적으로 버전을 관리합니다. 2026-07-28 핵심 개정판(core revision)에서는 Apps에 대해 전혀 언급하지 않습니다. 대신, 이러한 분리를 공식화하는 기능(capabilities) 상의 extensions 필드(SEP-2133)를 추가했습니다.
이는
import { registerViewerResource, PREFAB_RESOURCE_URI } from '@maxhealth.tech/prefab/mcp'
registerViewerResource(server)
...
tool이 아니라 registerTool임을 유의하세요. SDK의 모든 tool() 오버로드(overload)는 이를 위해 Deprecated(사용 중단)되었으며, 오직 registerTool의 설정 객체(config object)만이 inputSchema와 _meta를 허용합니다. tool() 시그니처는 Args | ToolAnnotations를 받으므로, 설정 리터럴(config literal)을 전달하면 초과 속성 검사(excess property check)에서 실패합니다. 또한 _meta.ui.resourceUri는 **도구 정의(tool definition)**에 포함되어야 한다는 점에 유의하세요. 그래야만 호스트가 tools/list에서 이를 발견하고 템플릿을 프리페치(prefetch)할 수 있으며, 결과(result)에 포함되지 않습니다.
헬퍼(helper)가 존재하는 이유인, 실제 작업 시간을 많이 잡아먹는 세 가지 버그는 다음과 같습니다:
- MIME 타입은 정확히
text/html;profile=mcp-app이어야 합니다. 세미콜론 뒤에 공백이 없어야 합니다. 단순한text/html은 일반 리소스로 조용히 취급되어 iframe에서 절대 로드되지 않습니다. - CSP(Content Security Policy)는 리소스 목록(resource listing)과 콘텐츠 항목(content item) 모두에 적용됩니다. Apps 명세(spec)에 따르면 호스트는 두 곳을 모두 확인하며, 콘텐츠 항목을 우선시하고 목록으로 폴백(fallback)합니다. 따라서 두 곳 모두 설정된 경우 콘텐츠 항목이 우선하지만, 목록에만 CSP를 설정하면 목록을 무시하는 호스트에서는 검은색 iframe만 나타나게 됩니다.
registerViewerResource가 바로 이 이유로 두 곳 모두에_meta를 설정합니다. structuredContent는 필수입니다.content[]만 반환하면 UI 경로가 실행되지 않기 때문에 호스트가 가공되지 않은 JSON을 렌더링하게 됩니다.
이러한 구성(compose)을 가능하게 하는 패턴은 모든 핸들러가 자기 완결적인 UI를 반환한다는 것입니다. 목록 뷰(list view)는 클릭 시 상세 도구(detail tool)를 호출하는 버튼을 포함합니다. 상세 뷰(detail view)는 편집 양식(edit form)을 여는 도구를 포함합니다. 제출(submitting)은 저장 도구(save tool)를 호출합니다. 멀티 스크린 흐름은 클라이언트 측 라우터(client side router)나 공유 상태(shared state) 없이 독립적인 핸들러들을 통해 자연스럽게 구현되며, 이는 상태가 없는 프로토콜(stateless protocol)이 정확히 원하는 방식입니다. 그리고 레이아웃이 아닌 숫자만 변경될 때, display_update()는 트리를 다시 빌드하는 대신 렌더러가 라이브 스토어(live store)에 병합할 수 있는 상태 패치(state patch)를 전송합니다.
레이어 3: 스킨 (skin)
두 개의 레이어를 지나면, 조용히 잠복해 있는 중복 문제(duplication problem)가 나타납니다. 마케팅 사이트에는 팔레트(palette)가 있습니다. 웹 앱(web app)의 Tailwind 설정에도 동일한 팔레트가 있습니다. 이제 MCP UI에는 이를 세 번째로, 프리팹 와이어 JSON(prefab wire JSON) 형태로 필요로 합니다. 세 가지 어휘(vocabularies)로 표현된 동일한 브랜드의 세 가지 복사본은, 데이터가 어긋날(drift) 수 있는 세 번의 기회를 의미합니다.
brandc는 다음 두 축을 분리함으로써 이 문제를 해결하는 브랜드 컴파일러(brand compiler)입니다:
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기