Published on

Claude MCP 서버 500 오류 - SSE·툴콜 디버깅

Authors

서버가 500을 뱉는 순간 제일 난감한 점은 “클라이언트 문제인지, MCP 서버 문제인지, 아니면 Claude 호출(툴콜 포함) 문제인지”가 한 덩어리로 보인다는 것입니다. 특히 MCP 서버가 SSE로 스트리밍을 하고, 중간에 툴을 호출하는 구조라면 오류 지점이 다음처럼 여러 층으로 갈라집니다.

  • 클라이언트 EventSource/스트리밍 파서가 끊겼다
  • 프록시/로드밸런서가 스트리밍 응답을 버퍼링하거나 타임아웃으로 끊었다
  • MCP 서버가 SSE 프레이밍을 깨뜨렸다(개행 규칙, data: 형식 등)
  • 툴콜 입력/출력 JSON이 깨져 Claude가 거부하거나, 서버가 역직렬화하다 죽었다
  • 툴 실행이 오래 걸려 타임아웃이 났는데 예외 처리가 누락돼 500으로 승격됐다

이 글은 “MCP 서버 500”을 실제로 빨리 잡는 순서대로, SSE 경로와 툴콜 경로를 분리해 진단하는 체크리스트와 코드 패턴을 제공합니다.

1) 먼저 500을 ‘두 종류’로 분리하기

같은 500이라도 체감 난이도는 완전히 다릅니다.

  1. SSE 자체가 깨져서 500
  • 연결 직후 바로 500
  • 몇 이벤트 보내다가 중간에 500으로 종료
  • 클라이언트에서는 “스트림이 갑자기 끊김”만 보임
  1. 툴콜 수행 중 예외가 터져서 500
  • 모델이 툴을 호출하려는 순간 또는 툴 실행 중에 발생
  • 서버 로그를 보면 특정 툴 이름에서만 재현

따라서 재현을 다음 두 케이스로 강제 분리하세요.

  • 툴을 전부 비활성화하고도 500이 나는가
  • 스트리밍을 끄고(일반 JSON 응답)도 500이 나는가

이 두 질문만으로도 원인의 70%가 갈립니다. 툴콜 JSON 문제라면 아래 글에서 다룬 패턴도 함께 확인하는 게 빠릅니다.

2) SSE 500 디버깅: 프레이밍, 헤더, 버퍼링

2.1 SSE는 “형식”이 아니라 “약속”이라 깨지기 쉽다

SSE는 HTTP 응답 본문을 다음 규칙으로 흘립니다.

  • 이벤트는 빈 줄(개행 2번)로 구분
  • 데이터 라인은 data: ...로 시작
  • 중간에 프록시가 버퍼링하면 클라이언트가 이벤트를 못 받음

Next.js나 Express에서 스트리밍을 직접 구성할 때, 아래 두 가지가 특히 흔한 함정입니다.

  • Content-Typetext/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 서버는 보통 다음 플로우입니다.

  1. 사용자 입력 수신
  2. Claude 호출(스트리밍 또는 비스트리밍)
  3. 모델이 툴 호출을 요청
  4. MCP 서버가 실제 툴 실행
  5. 툴 결과를 모델에 다시 전달
  6. 최종 응답 스트리밍

여기서 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”은 대부분 “재현 가능한 특정 실패”로 바뀌고, 그 다음부터는 해결이 매우 빨라집니다.