도구가 실패했을 때 무슨 일이 벌어지는가는 MCP 서버 설계에서 자주 잊히는 부분이다. 성공 경로만 만들고 실패는 예외를 던져버리면, 그 실패는 LLM에게 “이 도구는 뭔가 안 된다”는 신호로만 남는다. 모델이 실패를 읽고 스스로 고쳐 다시 시도하게 만들려면, 에러도 응답으로 설계해야 한다.
에러에는 두 층이 있다
MCP에서 실패는 두 층으로 나뉜다.
첫째, 프로토콜 에러. 존재하지 않는 도구를 불렀거나 인자가 스키마와 맞지 않는 등, 호출 자체가 성립하지 않는 경우다. 이건 JSON-RPC 에러 응답으로 돌아가고, 대개 SDK가 알아서 처리한다.
둘째, 도구 실행 에러. 호출은 성립했지만 작업이 실패한 경우다 — 파일이 없거나, 0으로 나누거나, 입력이 조건을 못 채웠거나. MCP는 이걸 에러 응답이 아니라 정상 결과에 isError: true를 얹어 표현한다. content에는 여느 결과처럼 텍스트가 들어간다. 핵심이 여기 있다. 이 텍스트는 그대로 LLM의 컨텍스트에 들어가므로, 모델이 읽고 행동을 바꿀 수 있는 에러 메시지가 된다.
isError로 돌려주는 코드
@modelcontextprotocol/sdk(TypeScript) 기준으로 이렇게 쓴다.
server.registerTool(
"divide",
{
description: "a를 b로 나눈 몫을 돌려준다",
inputSchema: { a: z.number(), b: z.number() },
},
async ({ a, b }) => {
if (b === 0) {
return {
isError: true,
content: [{
type: "text",
text: "실패: b가 0이라 나눌 수 없다. 0이 아닌 b로 다시 호출하라.",
}],
};
}
return { content: [{ type: "text", text: String(a / b) }] };
}
);
핸들러 안에서 예외를 던지면 SDK가 잡아 isError 결과로 바꿔주기도 하지만(버전에 따라 다름), 그때 메시지는 예외 문자열 그대로다. 모델에게 무엇을 읽힐지는 직접 정하는 쪽이 낫다.
에러 메시지는 로그가 아니라 지시문이다
isError의 텍스트를 읽는 건 사람이 아니라 모델이다. 사람용 로그처럼 쓰면 안 되고, 세 가지를 담은 지시문으로 쓴다.
- 무엇이 실패했나 — “발행 거부” 같은 결과.
- 왜 — “본문이 1200자 미만(현재 987자)”.
- 다음 행동 — “내용을 더 채워 다시 호출하라”.
이 블로그의 publish_post 도구가 실제로 이 구조다. 분량 미달이면 발행을 거부하면서 현재 글자 수와 하한을 함께 돌려주는데, 그 메시지를 읽은 모델은 사람이 개입하지 않아도 본문을 보강해 재호출한다. 에러 메시지가 곧 재시도 루프의 설계가 되는 셈이다.
반대로 하지 말아야 할 것도 분명하다. 스택 트레이스를 통째로 덤프하면 토큰만 낭비하고, 내부 파일 경로나 환경 변수 같은 민감한 정보가 모델 컨텍스트로 새어 나갈 수 있다. 모델이 다음 행동을 정하는 데 필요한 만큼만 담는다.
description의 짝
지난 글에서 description은 “모델이 도구를 고르게 하는 인터페이스”라고 했다. 에러 메시지는 그 짝이다 — 모델이 실패에서 회복하게 하는 인터페이스. 도구의 성공 응답은 하나지만 실패 경로는 여럿이고, 에이전트가 무인으로 돌수록 그 경로의 품질이 전체 자동화의 품질을 정한다. 도구를 만들 때 성공 케이스 다음에 물을 것은 하나다. 이 도구가 실패했을 때, 모델은 무엇을 읽게 되는가.
이 글에 대한 의견은 아래 댓글로 남겨주세요 (GitHub 계정 필요). 로그인 없이 남기고 싶다면