2부 4장. 에러를 잘 내는 도구 만들기
좋은 MCP 서버는 잘 돌아가는 서버가 아니다. 잘 실패하는 서버다.
이 말이 처음엔 조금 이상하게 들릴 수 있다. 우리는 보통 에러를 없애려고 코드를 쓴다. 그런데 MCP에서는 생각을 조금 바꿔야 한다. 도구를 호출하는 쪽이 사람이 아니라 LLM이기 때문이다. 사람은 에러를 보면 개발자 문서를 찾아보거나, 변수 타입을 다시 확인하거나, 콘솔 로그를 읽을 수 있다. 하지만 모델은 그런 식으로 문제를 풀지 않는다. 대신 에러 메시지에 적힌 문장을 읽고 다음 행동을 결정한다.
그래서 MCP에서 에러는 단순한 고장이 아니라, 다음 수를 알려 주는 표지판에 가깝다. 잘 만든 에러는 모델에게 "지금 이 입력은 틀렸고, 이렇게 고치면 된다"라고 알려 준다. 반대로 못 만든 에러는 모델을 멍하게 만든다. 그러면 엉뚱한 인자를 다시 넣거나, 같은 도구를 무한 재시도하거나, 심하면 아무 근거 없이 답을 지어내기 시작한다.
지난 장에서 우리는 PKM-MCP에 search_notes라는 첫 도구를 붙였다. 이제 그 도구를 한 단계 성숙하게 만들 차례다. 이번 장의 목표는 기능 추가가 아니다. 실패를 의미 있게 드러내는 법을 익히는 것이다.

4.1 ToolError로 의미 있는 메시지 전달
MCP는 실패를 크게 두 층으로 나눈다. 첫째는 프로토콜 자체가 깨진 경우다. 예를 들어 존재하지 않는 도구를 호출했거나, 요청 형식이 잘못되었거나, JSON-RPC 수준에서 문제가 생긴 경우다. 이런 것은 프로토콜 에러로 처리된다. 둘째는 도구는 정상적으로 찾았지만, 실행 중에 작업을 끝내지 못한 경우다. 예를 들어 파일이 없거나, 입력이 비어 있거나, 외부 API가 실패한 경우다. 이런 것은 tools/call 결과 안에서 isError: true로 표현하는 도구 실행 에러다.1
이 차이는 생각보다 중요하다. 프로토콜 에러는 대개 클라이언트 레벨에서 끝나 버리지만, 도구 실행 에러는 성공 결과와 마찬가지로 모델의 문맥 안으로 다시 들어간다. 즉, 모델은 그 에러 메시지를 읽고 스스로 다음 시도를 조정할 수 있다.2 MCP에서 좋은 에러 메시지가 생산성을 높이는 이유가 여기에 있다.
FastMCP의 ToolError는 바로 이 지점을 위해 존재한다. 의도된 실패를 "그냥 예외"가 아니라 "모델에게 전달할 메시지"로 다루게 해 주는 예외라고 생각하면 쉽다.3
예를 들어 아래 두 메시지를 비교해 보자.
나쁜 메시지는 이렇다.
Invalid input
이건 사실상 아무 말도 하지 않은 것과 비슷하다. 무엇이 잘못됐는지, 어떻게 고칠지, 지금 다시 시도해도 되는지 알 수 없다.
좋은 메시지는 이렇게 생긴다.
query가 비어 있습니다. 예: 'oauth', '주간 회의', 'vector search'처럼 2글자 이상의 검색어를 넣어 주세요.
이 메시지는 세 가지를 동시에 전달한다.
무엇이 문제인지
왜 지금 실행할 수 없는지
다음에 어떻게 고치면 되는지
바로 이런 메시지가 모델을 살린다.
도구 핸들러 안에서는 예상 가능한 실패를 ToolError로 바꾸는 습관이 좋다. 반대로 예상하지 못한 내부 오류는 서버 로그에 자세히 남기되, 모델에게는 깔끔한 문장만 돌려주는 편이 안전하다. 스택 트레이스, 내부 파일 경로, API 키 조각 같은 것은 디버깅에는 유용하지만, 모델에게는 도움이 되지 않고 때로는 보안 문제까지 만든다.4
간단한 예를 보면 감각이 온다.
from fastmcp.exceptions import ToolError
if not query.strip():
raise ToolError(
"query가 비어 있습니다. 예: 'oauth'나 '회의 메모'처럼 검색어를 넣어 주세요."
)여기서 중요한 점은 에러를 멋지게 꾸미는 것이 아니다. 재시도 가능한 실패와 재시도하면 안 되는 실패를 구분해서 말해 주는 것이다. 예를 들어 "잠시 후 다시 시도해 달라"는 메시지는 네트워크나 레이트 리밋 상황에 어울린다. 반면 "task_id가 존재하지 않는다"는 메시지는 다른 ID를 찾아야 한다는 뜻이다. 메시지가 다음 행동을 바꿔야 한다.2
한 가지 더 기억할 것이 있다. 나중에 도구에 outputSchema를 붙여 구조화된 성공 결과를 반환하더라도, 에러 응답까지 성공 스키마에 억지로 끼워 넣으면 안 된다. MCP 사양상 isError: true인 도구 결과는 텍스트 중심의 에러 내용을 담으면 되고, 성공용 structuredContent를 꼭 맞출 필요는 없다.1 실제로 이 경계를 잘못 처리해서 원래의 ToolError 메시지를 잃어버린 사례도 있었다.5 성공 데이터와 실패 안내는 같은 상자에 억지로 넣지 않는 편이 낫다.
4.2 도구 어노테이션 — read-only / destructive / idempotent
좋은 실패는 메시지에서만 끝나지 않는다. 도구의 성격을 미리 알려 주는 것도 중요하다. MCP의 도구 어노테이션은 바로 그런 역할을 한다. 이름, 설명, 입력 스키마가 "이 도구가 무엇을 하는가"를 말해 준다면, 어노테이션은 "이 도구가 어떤 종류의 위험을 갖는가"를 힌트로 알려 준다.6
이번 장에서 특히 중요한 것은 세 가지다.
readOnlyHint는 이 도구가 상태를 바꾸지 않는지 알려 준다. 조회, 검색, 목록 확인 같은 도구에 붙인다. search_notes가 딱 여기에 해당한다.
destructiveHint는 상태를 바꾼다면 그 변화가 파괴적인지 알려 준다. 삭제, 덮어쓰기, 영구 취소 같은 작업이 여기에 들어간다.
idempotentHint는 같은 입력으로 같은 도구를 여러 번 호출해도 환경에 추가 효과가 없는지 알려 준다. 예를 들어 "이 노트의 내용을 정확히 X로 설정한다"는 도구는 대체로 멱등적이다. 반면 "노트 끝에 한 줄 덧붙인다"는 도구는 호출할 때마다 결과가 달라지므로 멱등적이지 않다.
PKM-MCP의 관점에서 보면 분류가 꽤 선명하다.
search_notes는 읽기 전용이고, 파괴적이지 않으며, 같은 인자로 다시 호출해도 시스템 상태를 더 바꾸지 않으니 대체로 멱등적이다.나중에 만들
create_note는 읽기 전용이 아니고, 보통은 파괴적이지 않지만, 같은 요청을 두 번 보내면 노트가 두 개 생기거나 충돌할 수 있으므로 멱등적이라고 쉽게 말하면 안 된다.update_note는 구현 방식에 따라 다르다. "내용을 덮어쓴다"면 멱등적일 수 있지만, "본문 끝에 덧붙인다"면 멱등적이지 않다.delete_note는 읽기 전용이 아니고 파괴적이다. 멱등성은 구현에 따라 해석이 갈릴 수 있으니, 확신이 없으면 보수적으로 잡는 편이 낫다.
여기서 꼭 붙잡아야 할 사실이 하나 있다. 어노테이션은 보장이 아니라 힌트다.6 서버가 readOnlyHint: true라고 적었다고 해서 클라이언트가 그것을 법처럼 믿어서는 안 된다. 신뢰할 수 있는 서버에서는 UX와 승인 정책을 더 똑똑하게 만들 수 있지만, 낯선 서버에서는 어디까지나 참고 정보다. MCP 사양과 공식 논의도 이 점을 아주 분명하게 강조한다.6
그래도 힌트가 쓸모없는 것은 아니다. 오히려 반대다. 제대로 달아 두면 클라이언트는 read-only 도구를 더 가볍게 실행하고, 파괴적 도구에는 확인 단계를 끼워 넣고, 멱등적인 작업에는 재시도를 더 자신 있게 할 수 있다. 즉, 어노테이션은 보안장치라기보다 위험 어휘에 가깝다.
그리고 기본값이 꽤 보수적이라는 점도 재미있다. 어노테이션을 아예 생략하면, 클라이언트는 대개 그 도구를 "읽기 전용도 아니고, 파괴적일 수도 있고, 재시도도 조심해야 하는 도구"로 보게 된다.6 서버 작성자가 귀찮아서 아무것도 안 적으면, 사용성은 떨어지고 경계심만 커진다. 그러니 안전한 도구일수록 더 열심히 설명해 주는 편이 낫다.
4.3 일부러 깨뜨려보기: AI가 헛소리하는 도구는 어떻게 생겼나
이제 조금 짓궂게 가 보자. 좋은 도구를 만드는 가장 빠른 방법 중 하나는 나쁜 도구를 일부러 만들어 보는 것이다.
예를 들어 이런 search_notes를 상상해 보자.
@mcp.tool
def search_notes(q: str, n: int = 999):
"""search"""
return run_search(q, n)코드는 짧고 멀쩡해 보인다. 하지만 모델 입장에서는 거의 함정 카드에 가깝다.
첫째, 이름과 설명이 너무 흐리다. q와 n은 사람 개발자끼리는 통할지 몰라도, 모델에게는 불친절하다. search도 아니고 search_notes라서 그나마 낫지만, 여전히 무엇을 어떻게 검색하는지 알 수 없다.
둘째, 검증이 없다. 빈 문자열, 공백만 있는 문자열, 한 글자 검색어, 지나치게 큰 n 같은 값이 다 통과한다. 그러면 검색 결과가 엉망이 되거나, 너무 많은 결과가 문맥창을 잡아먹거나, 구현 세부에 따라 전 메모를 다 긁어 오는 일까지 벌어진다.
셋째, 실패가 모호하다. run_search() 안에서 예외가 터지면 모델은 "알 수 없는 오류" 같은 메시지만 볼 가능성이 크다. 그러면 모델은 원인을 모른 채 같은 호출을 반복하거나, 아예 도구를 포기한다.4
넷째, 도구의 성격이 안 드러난다. 읽기 전용인지, 상태를 바꾸는지, 재시도해도 되는지 불명확하다. 클라이언트는 괜히 긴장하게 되고, 사용자 경험은 거칠어진다.6
LLM이 "헛소리하는 도구"는 대개 복잡한 AI 문제 때문에 생기지 않는다. 훨씬 더 소박한 이유로 망가진다. 이름이 흐리고, 입력이 널널하고, 에러가 뭉툭하고, 결과가 너무 많거나 너무 적다. 결국 모델은 틀린 도구를 고르거나, 틀린 인자를 넣거나, 실패에서 배우지 못한다.
반대로 잘 만든 도구는 모델과 이런 식으로 대화한다.
사용자가 애매한 요청을 하면, 스키마가 1차로 막는다. 스키마로 표현하기 어려운 조건은 핸들러가 2차로 검사한다. 실행 중 환경 문제가 생기면 ToolError가 3차로 설명해 준다. 그러면 모델은 "아, 내가 지금 넣은 값이 비어 있었구나", "아, limit이 너무 컸구나", "아, 노트 디렉터리 자체가 없구나"를 이해하고 다음 행동을 바꾼다. 이런 실패 → 이해 → 수정의 루프가 바로 에이전트 시스템의 회복력이다.5
만들기: PKM의 도구에 검증 로직과 에러 처리 추가
이제 PKM-MCP의 search_notes를 실제로 단단하게 만들어 보자. 이번 개선의 핵심은 네 가지다.
스키마 수준에서 입력 범위를 줄인다.
핸들러 안에서 비즈니스 규칙을 한 번 더 검사한다.
파일 시스템 오류를
ToolError로 번역한다.결과가 0건인 상황과 진짜 실패를 구분한다.
마지막 항목이 특히 중요하다. 검색 결과가 없는 것은 실패가 아니다. 반면 노트 디렉터리가 통째로 없거나, 파일을 읽을 수 없거나, 인자가 비어 있는 것은 실패다. 좋은 서버는 이 둘을 헷갈리지 않는다.
아래는 개선된 코드다.
from pathlib import Path
from typing import Annotated
from fastmcp import FastMCP
from fastmcp.exceptions import ToolError
from pydantic import BaseModel, Field
mcp = FastMCP(name='PKM-MCP')
NOTES_DIR = Path('./notes')
MAX_QUERY_LENGTH = 120
class NoteHit(BaseModel):
id: str = Field(description='파일명에서 만든 메모 고유 ID')
title: str = Field(description='메모 제목')
path: str = Field(description='notes 디렉터리 기준 상대 경로')
snippet: str = Field(description='검색어 주변의 짧은 본문 발췌')
score: int = Field(description='단순 키워드 매칭 점수')
class SearchNotesResult(BaseModel):
query: str = Field(description='정규화된 검색어')
total: int = Field(description='반환된 결과 수')
results: list[NoteHit] = Field(description='관련도 순 메모 목록')
def normalize_query(value: str) -> str:
query = ' '.join(value.split()).strip()
if not query:
raise ToolError(
"query가 비어 있습니다. 예: 'oauth', '주간 회의', 'vector search'처럼 검색어를 넣어 주세요."
)
if len(query) < 2:
raise ToolError(
'query는 2글자 이상이어야 합니다. 너무 짧은 검색어 대신 더 구체적인 키워드를 사용해 주세요.'
)
if len(query) > MAX_QUERY_LENGTH:
raise ToolError(
f'query는 {MAX_QUERY_LENGTH}자 이하여야 합니다. 긴 문장 대신 핵심 키워드만 남겨 주세요.'
)
return query
def load_note_text(path: Path) -> str:
try:
return path.read_text(encoding='utf-8')
except UnicodeDecodeError:
raise ToolError(
f"'{path.name}' 파일을 UTF-8 텍스트로 읽을 수 없습니다. PKM-MCP는 현재 UTF-8 마크다운만 검색합니다."
)
except OSError:
raise ToolError(
f"'{path.name}' 파일을 읽는 중 문제가 발생했습니다. 파일 권한과 경로를 확인해 주세요."
)
def make_snippet(text: str, keyword: str, radius: int = 60) -> str:
lowered = text.lower()
needle = keyword.lower()
idx = lowered.find(needle)
if idx == -1:
return text[: radius * 2].replace('n', ' ').strip()
start = max(0, idx - radius)
end = min(len(text), idx + len(keyword) + radius)
return text[start:end].replace('n', ' ').strip()
@mcp.tool
def search_notes(
query: Annotated[
str,
Field(
description='메모 제목과 본문에서 찾을 키워드 또는 짧은 구문',
min_length=2,
max_length=120,
),
],
limit: Annotated[
int,
Field(
description='반환할 최대 결과 수',
ge=1,
le=10,
),
] = 5,
) -> SearchNotesResult:
"""로컬 마크다운 메모에서 정확한 키워드 검색을 수행합니다.
사용자가 특정 단어, 태그, 짧은 구문이 들어 있는 메모를 찾고 싶을 때 사용합니다.
이 도구는 메모를 읽기만 하며 생성·수정·삭제하지 않습니다.
결과가 없을 때는 빈 목록을 반환하고, 입력이나 환경이 잘못되었을 때는 명확한 에러를 반환합니다.
"""
query = normalize_query(query)
if not NOTES_DIR.exists():
raise ToolError(
"노트 저장소 './notes'를 찾지 못했습니다. 디렉터리를 만들거나 NOTES_DIR 설정을 확인해 주세요."
)
if not NOTES_DIR.is_dir():
raise ToolError('NOTES_DIR가 디렉터리가 아닙니다. 파일이 아니라 폴더 경로를 지정해야 합니다.')
try:
note_paths = sorted(NOTES_DIR.rglob('*.md'))
except OSError:
raise ToolError('노트 디렉터리를 스캔하지 못했습니다. 파일 권한과 경로를 확인해 주세요.')
if not note_paths:
return SearchNotesResult(query=query, total=0, results=[])
hits: list[NoteHit] = []
query_lower = query.lower()
for path in note_paths:
text = load_note_text(path)
title = path.stem.replace('-', ' ').replace('_', ' ')
lowered = text.lower()
score = lowered.count(query_lower)
score += title.lower().count(query_lower) * 2
if score == 0:
continue
hits.append(
NoteHit(
id=path.stem,
title=title,
path=str(path.relative_to(NOTES_DIR)),
snippet=make_snippet(text, query),
score=score,
)
)
hits.sort(key=lambda item: (-item.score, item.title))
hits = hits[:limit]
return SearchNotesResult(query=query, total=len(hits), results=hits)겉모습은 지난 장과 비슷하지만, 성격은 꽤 달라졌다.
먼저 Field가 입력의 바깥 경계를 만든다. query는 최소 2글자, 최대 120자이고, limit은 1에서 10 사이로 제한된다. 이런 검증은 아주 싸고, 아주 효과적이다. LLM이 "다섯" 같은 문자열을 숫자 대신 넣거나, limit=9999 같은 값을 던지는 일을 초반에 잘라낼 수 있다.5
그다음 normalize_query()가 스키마만으로는 표현하기 애매한 규칙을 담당한다. 공백만 있는 문자열, 지나치게 긴 문장, 지나치게 짧은 검색어 같은 것은 타입만 맞는다고 해서 유효한 입력이 아니다. 바로 이런 부분이 비즈니스 검증이다.
load_note_text()도 중요하다. 파일 읽기 실패를 그냥 OSError로 날려 버리면 모델은 별로 배울 것이 없다. 하지만 "UTF-8 마크다운만 지원한다"거나 "파일 권한을 확인해 달라"는 메시지를 주면, 실패가 갑자기 행동 가능한 정보가 된다.
또 하나 눈여겨볼 점은, 결과 없음은 예외가 아니라 정상 결과라는 점이다. oauth를 검색했는데 아무 메모도 없으면 total=0과 빈 배열을 돌려주면 된다. 이것은 실패가 아니다. 이런 구분을 흐리게 만들면 모델은 없는 정보를 있는 척하거나, 반대로 정상적인 빈 결과를 오류로 오해하게 된다.
도구 어노테이션도 함께 정리해 두자. search_notes는 readOnlyHint=true, destructiveHint=false, idempotentHint=true로 분류하는 것이 자연스럽다. 이 정보는 클라이언트가 이 도구를 좀 더 안심하고 다루는 데 도움을 준다.6
Inspector로 일부러 깨뜨려보기
이 장의 실습은 "성공하는지 보기"보다 "어떻게 실패하는지 보기"가 더 중요하다. Inspector에서 아래 상황을 하나씩 호출해 보자.
query=''query='a'query='oauth', limit=0query='oauth', limit=50./notes디렉터리를 잠시 없애고 검색 호출결과가 전혀 없는 검색어 호출
처음 다섯 개는 각각 다른 방식으로 막혀야 한다. 어떤 것은 스키마가 잡고, 어떤 것은 ToolError가 잡는다. 마지막 한 개만 정상 결과여야 한다. 빈 결과는 실패가 아니기 때문이다.
테스트도 같은 발상으로 짜면 좋다. 검증 코드는 "예외가 나는가"만 보는 것이 아니라, 어떤 메시지로 실패하는가까지 확인해야 한다. 실제 운영에서는 바로 그 문장이 모델의 다음 행동을 결정하기 때문이다.7
그리고 디버깅할 때는 복잡한 쓰기 작업보다 읽기 전용 도구부터 확인하는 습관이 좋다. 현재 PKM-MCP에서는 search_notes가 그 역할을 한다. 가장 단순한 read-only 호출이 안정적으로 돌아가는지 먼저 확인한 뒤, 그 위에 더 위험한 도구를 쌓는 편이 문제를 분리하기 쉽다.8
실패를 설계하는 사람이 결국 서버를 오래 운영한다
이번 장에서 우리가 한 일은 화려하지 않다. 검색 알고리즘이 갑자기 똑똑해진 것도 아니고, 벡터 DB를 붙인 것도 아니다. 그런데도 서버의 품질은 크게 달라진다.
이유는 간단하다. LLM에게 가장 무서운 것은 실패 자체가 아니라, 의미 없는 실패이기 때문이다. 아무 힌트 없는 에러는 모델을 멈추게 하거나, 더 나쁘게는 헛된 자신감을 만든다. 반대로 잘 설계된 실패는 모델을 한 번 틀리게 하고, 두 번째에는 맞게 만든다.
이제 PKM-MCP의 search_notes는 단순히 "동작하는 도구"가 아니라 "실패해도 다음 수를 알려 주는 도구"가 되었다. 그리고 이것이 앞으로 CRUD 도구, RAG 검색, OAuth, 배포, 감사 로그까지 이어지는 긴 여정의 기초가 된다.
다음 장에서는 한 걸음 더 나아가, 읽기 전용 데이터를 도구가 아니라 Resource로 노출하는 방법을 살펴볼 것이다. 지금까지는 AI에게 "찾아라"를 가르쳤다면, 이제는 "여기 읽을 것이 있다"를 가르칠 차례다.
참고
2Better MCP tools/call Error Responses
3MCP Error Handling: Don't Let Your Tools Fail Silently
4How to fix MCP tool execution errors
5How to Validate MCP Tool Inputs
6Tool Annotations as Risk Vocabulary
7MCP ToolError responses fail output schema validation
8MCP Tool Input Validation Testing Guide
9MCP Tool Calls Fail: Debugging Inputs, Validation, and Error Payloads
이 노트는 보도·비평·교육·연구 목적으로 작성되었습니다. 저작권 문의가 있으시면 에서 알려주세요.