
MCP 2026-07-28 대응 MCP 서버를 TypeScript SDK v2로 만들어 보았다 (구현편)
요약
MCP 2026-07-28 사양 변경에 대응하여 TypeScript SDK v2를 이용해 MCP 서버를 구현하는 과정을 다룹니다. 배포 승인 시뮬레이션 서버를 예시로 하며, 세션 관리와 보안 검증, 핸들러 재실행 로직을 상세히 설명합니다.
핵심 포인트
- TypeScript SDK v2를 활용한 MCP 서버 구현 방법 제시
- HTTP 요청마다 새로운 서버 인스턴스가 생성되는 팩토리 함수 구조 이해
- 세션 부재 문제를 해결하기 위한 HMAC 서명 토큰 활용법
- timingSafeEqual 및 Zod를 이용한 보안 검증 실무
- inputRequired()와 acceptedContent()를 활용한 사용자 승인 로직 처리
이전 기사에서 MCP 2026-07-28의 사양 변경 사항을 정리했습니다. 이번에는 그 후편으로, TypeScript SDK v2를 사용하여 실제로 동작하는 MCP 서버를 한 개 만들어 보겠습니다.
사양을 읽기만 해서는 "그래서 코드는 어떻게 작성하는 거지?"라는 점을 파악하기 어려웠기에, 직접 손을 움직여 확인한 기록입니다.
배포 승인 서버입니다. 두 가지 도구(Tool)를 준비합니다.
prepare_deployment : 배포 계획을 생성하고 서명된 planToken을 반환함
execute_deployment : planToken을 검증하고 사용자에게 승인을 요청한 뒤 실행함
실제 AWS나 Kubernetes에는 접속하지 않습니다. 배포 처리 자체는 시뮬레이션이며, 목적은 새로운 사양의 통신 모델을 직접 재현하는 것입니다.
TypeScript SDK v2는 세 가지로 나뉘어 있습니다.
npm install \
@modelcontextprotocol/server \
@modelcontextprotocol/client \
...
@modelcontextprotocol/server가 Web 표준인 Request를 다루는 코어 부분이고, @modelcontextprotocol/node가 Node.js의 node:http로 가교 역할을 하는 부분입니다. 이러한 분할 덕분에 Cloudflare Workers나 Deno로 옮기기도 쉬워졌습니다.
npm pkg set type=module을 잊으면 ESM의 import가 통하지 않으므로, 처음에 해두는 것이 좋습니다.
createMcpHandler에 전달하는 것은 서버 인스턴스가 아니라 **팩토리 함수(Factory Function)**입니다.
const mcpHandler = createMcpHandler(() => {
const server = new McpServer({
name: "deployment-approval-server",
...
이 함수는 HTTP 요청마다 실행되며, 매번 새로운 McpServer가 생성됩니다. 즉, 다음과 같은 코드를 작성해서는 안 됩니다.
// 이것은 동작하지 않습니다
const inMemoryApprovals = new Map<string, boolean>();
까다로운 점은, 로컬의 단일 프로세스에서 테스트하는 동안에는 동작하는 경우가 있다는 것입니다. ECS 태스크를 여러 개 띄우는 순간 망가집니다.
세션이 사라졌기 때문에, prepare_deployment에서 만든 계획을 execute_deployment로 전달할 수단이 필요합니다. 이번에는 HMAC 서명 토큰으로 했습니다.
function mintPlanToken(plan: DeploymentPlan): string {
const payload = Buffer
.from(JSON.stringify(plan), "utf8")
...
검증 측에서 주의한 점은 세 가지입니다.
- 서명 비교에
timingSafeEqual을 사용한다 (단, 길이가 다르면 예외를 던지므로 먼저 길이를 비교한다) - 서명을 검증한 후에 JSON을 파싱한다 (순서가 바뀌면 확인 전에 공격자의 데이터를 해석하게 된다)
- 파싱 후에 Zod 스키마를 통과시킨다 (서명이 올바르더라도 형식이 예상과 다를 가능성이 있다)
이 부분이 가장 막혔던 지점입니다.
MRTR에서는 승인을 요청할 때 inputRequired()를 반환하며, 사용자의 답변이 붙은 상태로 동일한 핸들러가 재실행됩니다. 답변을 추출하는 것은 acceptedContent()이지만, 이는 다음 세 가지 경우 모두 undefined를 반환합니다.
- 첫 실행 시 답변이 없는 경우
- 사용자가 거부한 경우
- 사용자가 취소한 경우
따라서 "undefined라면 확인을 띄운다"라고 솔직하게 작성하면, 거부 → 다시 확인 → 거부 → 다시 확인의 무한 루프에 빠지게 됩니다.
먼저 inputResponse()로 상태를 판정하는 것이 정답이었습니다.
const response = inputResponse(ctx.mcpReq.inputResponses, "approval");
if (response.kind === "elicit" && response.action !== "accept") {
return toolError("취소되었습니다");
...
처음에는 kind가 missing이 되므로 위의 조건에 해당하지 않습니다. 거부(Reject) 또는 취소(Cancel) 시에만 빠져나갑니다.
SDK를 사용하면 callTool() 한 줄로 MRTR(Model-Request-Tool-Response)의 왕복이 완결되므로 내부 내용을 볼 수 없습니다. 본문에서는 실제 요청(Request)과 응답(Response)도 함께 실었습니다.
첫 번째 응답은 다음과 같습니다.
{
"jsonrpc": "2.0",
"id": 100,
...
이 시점에서 서버의 HTTP 처리는 완료되었습니다. 연결은 닫혀 있으며, 사용자의 입력을 기다리고 있지 않습니다. 이전 사양(Old specification)이었다면, 여기서 핸들러(Handler) 내부에서 대기했을 부분입니다.
두 번째는 새로운 JSON-RPC ID와 함께, arguments를 통째로 재전송하면서 inputResponses를 붙여서 날아옵니다. 서버는 아무것도 기억하지 못하므로, 클라이언트가 전부 가지고 올 수밖에 없다는 점을 체감할 수 있었습니다.
Mcp-Session-Id 헤더가 어디에도 없다는 것도 확인할 수 있습니다.
기사의 후반부에서는 단순히 동작시켜 보는 것에 그치지 않기 위한 내용도 다루고 있습니다.
- 서명된 토큰(Signed token)만으로 충분한지, DB에 저장해야 하는지 (감사(Audit) 및 무효화(Invalidation) 요건이 있다면 DB 사용)
- 멱등성 키(Idempotency key)를 통한 중복 실행 방지 (응답 절단 시 재시도(Retry)된다는 전제로 설계)
clientInfo.name을 인가(Authorization)에 사용해서는 안 되는 이유 (자기 신고 방식이므로 위조 가능)- ALB의 스티키 세션(Sticky session)을 비활성화하는 구성
코드는 전문을 올려두었으므로, 복사해서 붙여넣고 npm run server를 실행하면 동작합니다. 직접 input_required가 반환되는 것을 확인해 보시면, 사양(Specification)에 대한 이해가 크게 달라질 것입니다.
▼ 전편(사양 해설)을 먼저 읽으시면, 이 기사의 코드가 무엇을 하고 있는지 파악하기 쉽습니다.
AI 자동 생성 콘텐츠
본 콘텐츠는 Qiita AI의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기