- Published on
Claude MCP 서버 500 오류 - SSE·툴콜 디버깅
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버가 500을 뱉는 순간 제일 난감한 점은 “클라이언트 문제인지, MCP 서버 문제인지, 아니면 Claude 호출(툴콜 포함) 문제인지”가 한 덩어리로 보인다는 것입니다. 특히 MCP 서버가 SSE로 스트리밍을 하고, 중간에 툴을 호출하는 구조라면 오류 지점이 다음처럼 여러 층으로 갈라집니다.
- 클라이언트
EventSource/스트리밍 파서가 끊겼다 - 프록시/로드밸런서가 스트리밍 응답을 버퍼링하거나 타임아웃으로 끊었다
- MCP 서버가 SSE 프레이밍을 깨뜨렸다(개행 규칙,
data:형식 등) - 툴콜 입력/출력 JSON이 깨져 Claude가 거부하거나, 서버가 역직렬화하다 죽었다
- 툴 실행이 오래 걸려 타임아웃이 났는데 예외 처리가 누락돼
500으로 승격됐다
이 글은 “MCP 서버 500”을 실제로 빨리 잡는 순서대로, SSE 경로와 툴콜 경로를 분리해 진단하는 체크리스트와 코드 패턴을 제공합니다.
1) 먼저 500을 ‘두 종류’로 분리하기
같은 500이라도 체감 난이도는 완전히 다릅니다.
- SSE 자체가 깨져서 500
- 연결 직후 바로
500 - 몇 이벤트 보내다가 중간에
500으로 종료 - 클라이언트에서는 “스트림이 갑자기 끊김”만 보임
- 툴콜 수행 중 예외가 터져서 500
- 모델이 툴을 호출하려는 순간 또는 툴 실행 중에 발생
- 서버 로그를 보면 특정 툴 이름에서만 재현
따라서 재현을 다음 두 케이스로 강제 분리하세요.
- 툴을 전부 비활성화하고도 500이 나는가
- 스트리밍을 끄고(일반 JSON 응답)도 500이 나는가
이 두 질문만으로도 원인의 70%가 갈립니다. 툴콜 JSON 문제라면 아래 글에서 다룬 패턴도 함께 확인하는 게 빠릅니다.
2) SSE 500 디버깅: 프레이밍, 헤더, 버퍼링
2.1 SSE는 “형식”이 아니라 “약속”이라 깨지기 쉽다
SSE는 HTTP 응답 본문을 다음 규칙으로 흘립니다.
- 이벤트는 빈 줄(개행 2번)로 구분
- 데이터 라인은
data: ...로 시작 - 중간에 프록시가 버퍼링하면 클라이언트가 이벤트를 못 받음
Next.js나 Express에서 스트리밍을 직접 구성할 때, 아래 두 가지가 특히 흔한 함정입니다.
Content-Type을text/event-stream으로 안 줌Cache-Control/Connection/X-Accel-Buffering같은 헤더 누락
2.2 Node(Express)에서 안전한 SSE 템플릿
아래는 “최소한의 안전장치”를 포함한 SSE 핸들러 예시입니다.
import express from 'express';
const app = express();
app.get('/sse', async (req, res) => {
res.status(200);
res.setHeader('Content-Type', 'text/event-stream; charset=utf-8');
res.setHeader('Cache-Control', 'no-cache, no-transform');
res.setHeader('Connection', 'keep-alive');
// nginx 프록시 뒤라면 버퍼링 방지
res.setHeader('X-Accel-Buffering', 'no');
// 일부 런타임에서 즉시 flush가 필요
// @ts-ignore
res.flushHeaders?.();
const send = (obj: unknown) => {
res.write(`data: ${JSON.stringify(obj)}\n\n`);
};
const heartbeat = setInterval(() => {
// 주기적 전송으로 idle timeout 방지
res.write(`: ping\n\n`);
}, 15000);
req.on('close', () => {
clearInterval(heartbeat);
});
try {
send({ type: 'ready' });
send({ type: 'token', value: 'hello' });
send({ type: 'done' });
res.end();
} catch (e: any) {
// SSE에서는 에러를 JSON으로 한 번 보내고 종료하는 편이 디버깅에 유리
send({ type: 'error', message: e?.message ?? 'unknown' });
res.end();
}
});
app.listen(3000);
핵심은 “서버 내부 예외가 나더라도 SSE 프레이밍을 유지하면서 에러 이벤트를 보내고 종료”하는 것입니다. 그렇지 않으면 클라이언트는 그냥 연결 종료로만 인식하고, 원인 추적이 어려워집니다.
2.3 프록시/로드밸런서가 SSE를 죽이는 전형적인 패턴
SSE는 인프라 설정에 매우 민감합니다.
- nginx 버퍼링: 응답을 모아서 한 번에 보내면 SSE가 사실상 동작하지 않습니다.
X-Accel-Buffering: no또는 location 설정이 필요합니다. - idle timeout: ALB/Cloudflare/Ingress가 일정 시간 데이터가 없으면 끊습니다. 하트비트가 필요합니다.
- gzip/변환:
no-transform이 없으면 중간 계층이 변환을 시도할 수 있습니다.
만약 “로컬에서는 되는데 배포하면 500/끊김”이라면, 앱 코드보다 인프라 타임아웃을 먼저 의심하세요. 500 자체가 프록시에서 만들어지는 경우도 있습니다.
3) 툴콜 500 디버깅: ‘서버 예외’로 승격되는 지점 찾기
툴콜이 있는 MCP 서버는 보통 다음 플로우입니다.
- 사용자 입력 수신
- Claude 호출(스트리밍 또는 비스트리밍)
- 모델이 툴 호출을 요청
- MCP 서버가 실제 툴 실행
- 툴 결과를 모델에 다시 전달
- 최종 응답 스트리밍
여기서 500은 주로 4번(툴 실행)과 5번(툴 결과 재주입)에서 터집니다.
3.1 “툴 실행 예외”를 500으로 만들지 말고, 도메인 에러로 내리기
툴 실행에서 발생하는 예외를 곧장 throw하면, 프레임워크 기본 에러 핸들러가 500으로 바꿔버립니다. 디버깅을 위해 최소한 다음을 분리하세요.
- 사용자 입력 문제:
400 - 권한/인증 문제:
401또는403 - 외부 의존성 다운:
502또는503 - 툴 내부 처리 실패:
500이 맞더라도 “툴 이름과 입력”을 반드시 남김
다음은 툴 실행 래퍼 예시입니다.
type ToolResult = { ok: true; data: unknown } | { ok: false; error: { code: string; message: string } };
async function runToolSafely(toolName: string, input: unknown): Promise<ToolResult> {
const startedAt = Date.now();
try {
// 여기서 zod 같은 것으로 input 검증을 강제하는 것을 권장
const data = await actualToolHandlers[toolName](input);
return { ok: true, data };
} catch (e: any) {
const ms = Date.now() - startedAt;
console.error('tool_failed', {
toolName,
ms,
message: e?.message,
stack: e?.stack,
// 민감정보가 없다면 입력도 일부 샘플링
inputPreview: JSON.stringify(input)?.slice(0, 500),
});
return {
ok: false,
error: {
code: 'TOOL_EXECUTION_FAILED',
message: e?.message ?? 'tool failed',
},
};
}
}
이렇게 하면 “툴 실패”를 곧바로 500으로 폭발시키지 않고, Claude에게도 구조화된 실패를 전달할 수 있어 재시도/대체 경로를 설계하기 쉬워집니다.
3.2 툴 입력/출력 JSON 역직렬화 실패가 500으로 보이는 경우
툴콜에서 가장 흔한 문제는 JSON입니다.
- 모델이 만든 JSON에 trailing comma가 들어감
- 숫자여야 하는데 문자열이 들어감
- 필수 필드 누락
- 툴 결과가 너무 커서 전송/파싱에서 실패
이 경우 서버는 보통 다음 중 하나로 죽습니다.
JSON.parse예외가 상위로 전파돼500- 스키마 검증 라이브러리가 throw하고
500
해결은 “스키마 검증을 하고, 실패를 400 또는 툴 실패로 처리”하는 것입니다. 위에 링크한 툴 JSON 가이드 글과 함께, MCP 서버에서도 동일한 원칙을 적용하세요.
4) SSE + 툴콜 결합에서 자주 터지는 ‘중간 종료’ 문제
스트리밍 중 툴콜이 들어가면, 서버는 보통 다음처럼 상태 머신이 됩니다.
- 스트림으로 토큰 보내다가
- 툴콜 이벤트를 감지하면
- 스트림을 잠깐 멈추고 툴 실행
- 결과를 다시 모델에 넣고
- 스트림 재개
여기서 흔한 버그는 다음입니다.
- 툴 실행 중 예외가 발생했는데, 이미 SSE 헤더를 보낸 상태라 일반 JSON 에러 응답으로 바꿀 수 없음
- 스트림을 재개할 때 이벤트 구분자(빈 줄) 누락
- 동시성 문제로
res.write가 섞여 프레이밍이 깨짐
해결책은 “SSE에서는 항상 동일한 이벤트 envelope로만 통신한다”는 원칙입니다. 즉, 성공/실패 모두 data: { type: ... }로 보내고, HTTP status로 의미를 전달하려 하지 않는 편이 운영에서 더 안전합니다.
5) 관측 가능성: 500을 ‘재현 가능한 사건’으로 만들기
5.1 요청 단위 상관관계 ID를 강제
SSE는 한 요청이 길게 유지되기 때문에 로그가 섞이기 쉽습니다. 다음을 반드시 넣으세요.
requestId를 생성해 모든 로그에 포함- 툴 실행 로그에도 동일한
requestId포함 - 가능하면 Claude 호출에도
requestId를 메타로 포함(서버 내부 추적용)
import crypto from 'crypto';
function newRequestId() {
return crypto.randomUUID();
}
app.get('/mcp', async (req, res) => {
const requestId = newRequestId();
console.log('req_start', { requestId });
try {
// ...
} catch (e: any) {
console.error('req_fail', { requestId, message: e?.message, stack: e?.stack });
res.status(500).json({ requestId, error: 'internal_error' });
}
});
5.2 “에러를 숨기는 재시도”를 금지하고, 재시도는 계층을 정해라
SSE + 툴콜 환경에서 무작정 재시도를 넣으면, 같은 요청이 중복 실행되며 더 큰 장애(중복 결제/중복 작업)를 만듭니다.
- Claude 호출 재시도는 “멱등성”이 확보될 때만
- 툴 재시도는 툴 성격에 따라 분리(읽기 툴은 가능, 쓰기 툴은 위험)
- 레이트리밋/과금 이슈로 인한 실패는 별도 정책이 필요
Claude 호출이 429로 실패하는 케이스까지 같이 나타난다면, 500 디버깅과 별개로 레이트리밋 전략을 먼저 정리해야 합니다.
6) 실전 체크리스트: 30분 안에 원인 좁히기
6.1 SSE 경로 체크
- 응답 헤더에
Content-Type: text/event-stream이 있는가 - 프록시가 버퍼링하지 않는가(
X-Accel-Buffering: no또는 설정) - 하트비트가 있는가(15초 내
: ping같은 주석 이벤트) - 이벤트마다
\n\n으로 구분되는가 - 서버 예외가 나도 SSE 프레이밍을 유지하며
type: error이벤트를 보내는가
6.2 툴콜 경로 체크
- 툴 입력을 스키마로 검증하고 실패를 구조화했는가
- 툴 출력이 너무 크지 않은가(결과를 요약/페이지네이션)
- 툴 실행 타임아웃이 있는가(없으면 영원히 걸리다 끊김)
- 툴 실패가 throw로 전파돼 곧장
500이 되지 않는가 requestId로 Claude 호출, 툴 실행, SSE write 로그가 연결되는가
7) 결론: 500은 ‘에러’가 아니라 ‘경계면’에서 생긴다
Claude MCP 서버의 500은 대개 “SSE 스트리밍 경계” 또는 “툴콜 JSON/실행 경계”에서 발생합니다. 따라서 디버깅의 핵심은 기능을 고치는 게 아니라 경계면을 분리하고 관측 가능하게 만드는 것입니다.
- SSE는 헤더/프레이밍/버퍼링/타임아웃을 먼저 고정
- 툴콜은 스키마 검증과 예외 격리로
500승격을 막기 - 로그는
requestId중심으로 스트림 전체를 한 사건으로 묶기
이 3가지만 잡아도 “원인을 모르는 500”은 대부분 “재현 가능한 특정 실패”로 바뀌고, 그 다음부터는 해결이 매우 빨라집니다.