TypeScript로 프로덕션용 MCP 서버를 작성하기 전에 알았으면 좋았을 5가지 (2026)
요약
실제 프로덕션 환경에서 안정적인 MCP(Model Context Protocol) 서버를 구축하기 위해 고려해야 할 5가지 핵심 설계 원칙을 다룹니다. 단순한 튜토리얼을 넘어 재시도 로직, 경로 보안, 구조화된 에러 처리 및 진행 상황 알림 구현 방법을 제시합니다.
핵심 포인트
- 에러 유형과 요청 성격에 따른 선별적 재시도(Retry) 전략 구현
- LLM이 생성하는 상대 경로의 보안 취약점 방지 및 검증
- LLM이 이해하기 쉬운 구조화된 에러 메시지 설계
- 사용자 경험을 위한 MCP 진행 상황 알림(Progress Notifications) 연결
"@modelcontextprotocol/sdk를 API에 연결하고, Zod 스키마로 몇 가지 도구(tools)를 등록한 뒤 배포하세요." 제가 첫 MCP 서버를 작성하기 전에 읽었던 모든 MCP 서버 튜토리얼의 형태가 이랬습니다. StemSplit의 오디오 분리 API를 위해 제가 직접 만든 오픈 소스 MCP 서버인 stemsplit-mcp를 3주 동안 직접 사용(dogfooding)해 본 결과, 그 요약은 단지 서문에 불과했다는 사실을 알게 되었습니다. 아래는 튜토리얼에서만 작동하는 MCP 서버와 실제로 의존할 수 있는 MCP 서버 사이의 차이를 만드는 다섯 가지 요소입니다. 이 모든 내용은 현재 stemsplit-mcp(소스, MIT 라이선스)에 하드코딩되어 있으며, 여러분이 작성할 다른 어떤 MCP 서버에도 100% 이식 가능합니다. 만약 첫 MCP 서버를 작성하려는 참이라면: 이 글을 읽으세요. 이미 하나를 배포했다면: 적어도 이 중 하나 이상의 버그를 가지고 있을 확률이 0이 아닙니다.
이 포스트를 통해 얻게 될 것들
✅ 사용자에게 이중 과금을 하지 않고 일시적인 실패를 처리하는 withRetry 헬퍼
✅ 어떤 요청이 재시도(retry)하기에 안전한지 결정하는 간단한 규칙
✅ LLM으로부터 오는 상대 경로(relative paths)가 왜 버그인지, 그리고 어떻게 유용한 메시지와 함께 이를 거부하는지
✅ LLM에게 기계가 읽을 수 있는 문맥(machine-readable context)을 제공하는 구조화된 에러 형태
✅ 긴 작업이 멈춘 것처럼 보이지 않도록 MCP 진행 상황 알림(progress notifications)을 연결하는 방법
- 일시적인 실패를 재시도하되 — 오직 올바른 것만 재시도할 것
여러분이 작성하는 모든 MCP 서버의 첫 번째 버전은 다음과 같은 모습일 것입니다:
async function callApi < T > ( path : string ): Promise < T > {
const res = await fetch ( ${ baseUrl }${ path } , { headers });
if ( ! res . ok ) throw new Error ( ${ res . status } ${ res . statusText } );
return res . json ();
}
상위 게이트웨이의 문제 하나, 일시적인 502 에러 하나, 혹은 TLS 핸드셰이크(handshake)의 작은 문제 하나만 발생해도 전체 MCP 도구 호출(tool call)이 실패합니다. LLM은 에러를 보고, 사용자는 에러를 보며, 사용자는 여러분의 도구가 고장 났다고 결론 내립니다. 해결책은 "모든 것을 재시도하는 것"이 아닙니다. 서버가 이미 처리한 POST /jobs를 재시도하면 사용자에게 이중 과금을 하게 됩니다. 올바른 해결책은 에러를 분류(classify the error)하고, 요청을 분류(classify the request)하는 것입니다.
제가 이제 모든 곳에서 사용하는 헬퍼 함수를 소개합니다: export type RetryDecision = boolean | { retryAfterMs : number }; export interface RetryOptions { maxAttempts : number ; initialDelayMs : number ; maxDelayMs : number ; shouldRetry : ( err : unknown ) => RetryDecision ; onRetry ?: ( err : unknown , attempt : number , delayMs : number ) => void ; } export async function withRetry < T > ( fn : () => Promise < T > , options : RetryOptions , ): Promise < T > { let attempt = 0 ; while ( true ) { attempt ++ ; try { return await fn (); } catch ( err ) { const decision = options . shouldRetry ( err ); if ( ! decision || attempt >= options . maxAttempts ) throw err ; const baseDelay = Math . min ( options . initialDelayMs * 2 ** ( attempt - 1 ), options . maxDelayMs , ); const jitter = Math . random () * baseDelay * 0.25 ; const explicit = typeof decision === " object " ? decision . retryAfterMs : null ; const delayMs = explicit ?? baseDelay + jitter ; options . onRetry ?.( err , attempt , delayMs ); await new Promise (( r ) => setTimeout ( r , delayMs )); } } } 흥미로운 부분은 RetryDecision유니온 타입입니다.shouldRetry는 true/false를 반환하거나, 명시적인 retryAfterMs가 포함된 객체를 반환할 수 있습니다. 이 마지막 기능 덕분에 별도의 코드 경로 없이 429 응답에서 Retry-After헤더를 준수할 수 있게 됩니다. 그런 다음 정책은 요청 형태에 따라 분리됩니다:function shouldRetryApiError ( err : unknown , mutating : boolean ): RetryDecision { if ( err instanceof StemSplitError ) { // 네트워크 레벨 에러 — 서버가 요청을 받지 못했을 수 있음 if ( err . code === " NETWORK_ERROR " ) return true ; // 읽기 전용 요청에 대한 5xx 응답 — 재시도해도 안전함 if ( err . status && err . status >= 500 && ! mutating ) return true ; // 429 — Retry-After 준수 if ( err . status === 429 && err . retryAfterMs !== undefined ) { return { retryAfterMs : err . retryAfterMs }; } } return false ; } mutating` 플래그가 규칙입니다. POST /jobs에 대한 5xx 응답은 작업이 생성되었지만 응답만 손실되었을 수 있음을 의미합니다.
재시도할 경우 비용이 이중으로 청구될 수 있습니다. 따라서 POST /jobs는 mutating: true로 설정하여 시도 횟수를 줄이고(3회), 서버가 요청을 아예 받지 못했음이 증명되는 오류에 대해서만 재시도합니다. 반면, GET /jobs/:id는 mutating: false이며 더 공격적인 정책(4회 시도, 5xx 오류 시 재시도 활성화)을 적용받습니다. 심지어 사전 서명된 URL(presigned URL)만 반환하고 과금 상태를 변경하지 않는 POST /upload조차 mutating: false로 표시할 수 있습니다. 이 단순한 구분만으로 이미 세 번의 프로덕션 장애를 막을 수 있었습니다. 대부분은 긴 음원 분리(stem-separation) 작업 중 10분간의 폴링(polling) 루프에서 발생하는 드문 502 오류 때문이었습니다.
- 상대 경로를 사전에 거부하세요
LLM에게 파일 경로를 인자로 받는 도구(tool)를 제공하면, LLM은 결국song.mp3나./song.mp3를 전달하게 됩니다. 더 최악인 경우는file:///Users/me/Music/song.mp3와 같은 형태인데, 이는 인자와 동일해 보이지만 Node.js의fs.createReadStream에서 실패하는 URL 형식의 경로입니다. 이를 검증하지 않으면 Node는 해당 경로를 MCP 서버의 프로세스 작업 디렉토리(working directory)를 기준으로 해석합니다. Claude Desktop이나 Cursor의 경우, 이는 보통/또는/Applications/...가 됩니다. 해당 위치에는 파일이 존재하지 않습니다. LLM은
+만약 절대 경로를 모른다면, 다시 시도하기 전에 사용자에게 물어보세요., ); } return trimmed ; } } 두 가지 강조할 점이 있습니다: 에러 메시지는 LLM에게 다음에 무엇을 해야 할지 정확히 알려줍니다. 단순히 "잘못된 경로"라고 하는 것이 아니라, "사용자에게 절대 경로를 물어보세요"라고 말하는 것입니다. 이것이 LLM이 포기해버리는 도구와 우아하게 복구(recover)하는 도구 사이의 차이입니다.~/foo는 Claude Desktop이 이를 잘 해석하기 때문에 허용됩니다. 이는 인간 친화적인 형식이며, 이를 지원하면 대화가 막히는 상황을 줄일 수 있습니다. 대부분의 파일 시스템 헬퍼(fs.realpath, os.homedir()를 사용한 path.resolve)가 이를 대신 처리해 줍니다. 부수적인 이점으로는, 이 방식이 도구 설명(tool description)을 더 짧게 만들어 준다는 점이 있습니다. Zod 스키마에 path: "/Users/you/Music/song.mp3와 같은 절대 경로"라고 작성하면, LLM은 스키마와 에러 메시지 양쪽에서 동일한 힌트를 얻게 됩니다. 3. 에러를 기계가 읽을 수 있게 만드세요 (Make errors machine-readable) 기본 MCP 에러 형태는 단순한 문자열입니다. 이는 사용자에게는 괜찮지만, LLM에게는 최악입니다. 에러 메시지를 보고 다음에 무엇을 할지 파악해야 하는 LLM은 에러가 개별적인 상태(discrete states)를 가질 때 가장 잘 작동합니다. "크레딧 부족"은 "속도 제한(rate limit) 도달"이나 "파일이 너무 큼"과는 다른 복구 방식이 필요합니다. 그래서 저는 항상 상위(upstream) 에러를 code`를 가진 클래스로 감쌉니다:
export type StemSplitErrorCode =
| "AUTH_INVALID"
| "INSUFFICIENT_CREDITS"
| "RATE_LIMIT_EXCEEDED"
| "FILE_TOO_LARGE"
| "UNSUPPORTED_FORMAT"
| "JOB_FAILED"
| "JOB_TIMEOUT"
| "NETWORK_ERROR"
| "API_ERROR";
export class StemSplitError extends Error {
constructor (
public readonly code: StemSplitErrorCode,
public readonly userMessage: string,
public readonly status?: number,
public readonly retryAfterMs?: number,
public readonly details?: unknown,
) {
super(userMessage);
this.name = "StemSplitError";
}
}
export async function buildErrorFromResponse (
res: Response,
): Promise<StemSplitError> {
const text = await res.text();
let body: { error?: string; code?: string } = {};
try {
body = JSON.
parse(text); } catch { /* ignore */ } if (res.status === 401) { return new StemSplitError("AUTH_INVALID", "StemSplit API key가 유효하지 않습니다. STEMSPLIT_API_KEY를 확인하세요.", 401); } if (res.status === 402) { return new StemSplitError("INSUFFICIENT_CREDITS", "StemSplit 크레딧이 부족합니다. stemsplit.io/app/billing에서 충전하세요.", 402); } if (res.status === 429) { const retryAfter = Number(res.headers.get("retry-after")); return new StemSplitError("RATE_LIMIT_EXCEEDED", StemSplit에 의해 속도 제한(Rate limit)이 적용되었습니다. ${retryAfter || 60}초 후에 다시 시도하세요., 429, isFinite(retryAfter) ? retryAfter * 1000 : undefined); } // ...기타 } 이 에러를 다시 MCP 클라이언트로 직렬화(Serialize)할 때는 다음 두 가지를 모두 포함하세요: { isError: true, content: [{ type: "text", text: err.userMessage }], _meta: { code: err.code, status: err.status } } Anthropic의 클라이언트들은 이해하지 못하는 _meta 필드를 무시하므로, 이는 하위 호환성(Forward-compatible)을 유지합니다. 또한 LLM은 사용자에게 전달하기 안전한 깨끗한 userMessage를 받게 됩니다.
- 약 5초 이상 걸리는 작업에는 진행 상황 알림(Progress notifications)을 발생시키세요
MCP는 진행 상황 알림을 지원합니다:
await server.notification({
method: "notifications/progress",
params: {
progressToken,
progress: 35, // 0–100
total: 100,
},
});
도구(Tool) 실행이 5초 이상 걸린다면(오디오 처리 작업은 확실히 그렇습니다), 이 기능을 사용하세요. 이 기능이 없다면 Claude Desktop은 "Running tool stemsplit/separate_stems..." 상태로 무한정 대기하게 되며, 사용자는 서버가 멈춘 것인지 아니면 작업이 진행 중인지 알 수 없습니다. 핵심은 이를 폴링 루프(Polling loop)를 통해 연결하는 것입니다:
export async function pollUntilDone<T>(
fetchStatus: () => Promise<{ status: string; progress?: number } & T>,
options: {
onProgress?: (progress: number) => void;
intervalMs?: number;
timeoutMs?: number;
} = {},
): Promise<T> {
const interval = options.intervalMs ?? 3000;
const timeout = options.timeoutMs ?? 10 * 60 * 1000;
const start = Date.now();
while (true) {
const status = await fetchStatus();
if (status.
progress !== undefined ) options . onProgress ?.( status . progress ); if ( status . status === " COMPLETED " ) return status ; if ( status . status === " FAILED " ) throw new Error ( " Job failed " ); if ( Date . now () - start > timeout ) throw new Error ( " Job timed out " ); await new Promise (( r ) => setTimeout ( r , interval )); } } 그런 다음, 도구 핸들러는 MCP 진행률 토큰을 통해 다음과 같이 연결합니다: const progressToken = request . params ?. _meta ?. progressToken ; const job = await pollUntilDone ( () => client . getJob ( jobId ), { onProgress : progressToken ? ( p ) => server . notification ({ method : " notifications/progress " , params : { progressToken , progress : p , total : 100 }, }) : undefined , }, ); 이것이 사용자가 긴 작업을 30초 만에 포기하는 것과, 막대가 움직이는 것을 보고 인내심을 갖고 기다리는 것의 차이점입니다. 5. 필요할 때 사전 서명된 URL(presigned URLs) 다시 가져오기 이 항목은 MCP 전용은 아니지만, 만료되는 URL을 가진 API를 감싸는 모든 MCP 서버에 해당됩니다. 클라우드 스토리지 제공업체(Cloudflare R2, S3, GCS)는 1~24시간 후에 만료되는 사전 서명된 URL을 발급합니다. 만약 귀하의 MCP 도구가 채팅 기록에 URL을 저장하고 사용자가 다음 날 돌아와 "이 스템들을 다시 다운로드할 수 있나요?"라고 요청하면, URL은 만료되어 LLM은 403 에러를 받게 됩니다. 사용자에게 전체 분리 작업을 재실행하도록 만들지 마세요. 대신, jobId를 받아 최신 사전 서명된 URL을 귀하의 API에서 다시 가져와 다운로드하는 별도의 download_stems 도구를 노출하세요: async function handleDownloadStems ( jobId : string , outputDir : string ) { const job = await client . getJob ( jobId ); if ( job . status !== " COMPLETED " ) { throw new StemSplitError ( " JOB_FAILED " , `Job ${ jobId } not complete.` ); } return downloadAllStems ( job . outputs , outputDir ); } LLM은 이를 자연스럽게 파악합니다. 만약 이전 채팅에서 jobId를 가지고 있다면, separate_stems를 재실행하는 대신 download_stems를 호출할 것입니다. 사용자는 90초 동안 기다리는 대신 2초 만에 다시 다운로드할 수 있습니다.
보너스: 이 방식은 작업을 다시 수행하지 않고도 사용자가 두 번째 다운로드 시 다른 출력 디렉터리 (output directory)를 선택할 수 있게 하는 방법이기도 합니다.
종합하자면, 이 다섯 가지 패턴은 원격 API (remote API)를 호출하는 모든 MCP 서버에 실질적인 차이를 만들어냅니다:
- 상태 변경을 인지하는 정책 (mutating-aware policy)을 적용한 `withRetry` — 일시적인 오류 (transient failures)의 90%를 해결합니다.
- 실행 가능한 오류 메시지를 포함한 절대 경로 검증 (Absolute path validation) — LLM을 혼란스럽게 만드는 상황을 방지합니다.
- 구조화된 오류 코드 (Structured error codes) — LLM이 복구 전략 (recovery strategy)을 선택할 수 있게 합니다.
- 진행 상황 알림 (Progress notifications) — 사용자가 포기하는 대신 기다릴 수 있게 합니다.
- ID 기반 재요청 (Re-fetch-by-ID) 도구 — 만료되는 URL을 실수 유발 요소 (footgun)에서 기능 (feature)으로 바꿉니다.
이 중 그 어떤 것도 MCP SDK 예제에는 포함되어 있지 않습니다. 이것들은 실제 사용자들을 대상으로 MCP 서버를 운영하면서만 배울 수 있는 교훈들입니다. 만약
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기