16. 에러 처리·로깅·테스트와 Inspector 디버깅
🎯 이 장의 목표
- 프로토콜 에러와 실행 에러(isError)를 코드에서 올바르게 구분해 처리한다
- 안전한 로깅(민감정보 비노출, stderr/프로토콜 로깅)을 한다
- 인메모리 클라이언트로 단위 테스트하고 Inspector로 디버깅한다
15장에서 primitive를 정밀하게 등록했습니다. 이제 견고하게 만듭니다 — 에러가 나도 무너지지 않고, 무슨 일이 일어나는지 보이고, 자동으로 검증되는 서버.
두 층위의 에러를 코드로
flowchart TD
BODY["도구 본문 실행"] --> Q{"무엇이 잘못됐나?"}
Q -->|"인자가 스키마 위반 등\n프로토콜 차원"| RAISE["예외 → SDK가\nJSON-RPC error로 변환"]
Q -->|"실행은 됐으나 결과 실패\n(API 한도·비즈니스 규칙)"| ERR["isError=true 결과로 반환\n모델이 보고 추론 가능"]
classDef q fill:#bfdbfe,stroke:#1d4ed8,color:#000;
classDef proto fill:#fca5a5,stroke:#b91c1c,color:#000;
classDef exec fill:#fde68a,stroke:#b45309,color:#000;
class BODY,Q q;
class RAISE proto;
class ERR exec;
실행 에러 — isError로 모델에게 알리기
도구가 실행은 됐는데 비즈니스 수준에서 실패하면, 예외로 죽이기보다 isError: true 결과를 돌려 모델이 보고 교정하게 합니다. FastMCP에서는 보통 예외를 던지면 SDK가 적절히 변환해 주지만, 의도적으로 "모델이 읽을 실패 메시지"를 주고 싶을 때는 결과를 직접 구성합니다.
PYTHON
from mcp.server.fastmcp import FastMCP import mcp.types as types mcp = FastMCP("Demo") @mcp.tool() def get_stock(symbol: str) -> str: """종목 현재가를 조회한다.""" try: price = fetch_price(symbol) # 외부 API return f"{symbol}: {price}원" except RateLimitError: # 실행 에러 — 모델이 보고 재시도·대안을 택할 수 있게 return types.CallToolResult( content=[types.TextContent(type="text", text="조회 한도 초과. 잠시 후 다시 시도하세요.")], isError=True, )
📌 핵심
왜 예외로 안 죽이나: 프로토콜 에러(JSON-RPC error)는 "통합이 깨졌다"는 개발자용 신호라, 모델이 손쓸 게 없습니다. 반면 isError: true는 모델이 읽고 "다른 종목을 시도", "사용자에게 잠시 후 재시도 안내" 같은 교정 행동을 할 수 있는 정보입니다. 비즈니스 실패는 isError로, 통합 오류는 예외로.⚠️ 흔한 실수
outputSchema와의 상호작용: 이 도구에 outputSchema가 있더라도, isError: true 응답은 스키마 검증을 건너뜁니다(10장). 검증을 무조건 돌리면 에러 메시지가 가려지니, 에러 분기에서는 구조화 출력을 강요하지 마세요.프로토콜 에러 — 예외로 자연스럽게
없는 항목·잘못된 인자처럼 통합 차원의 문제는 그냥 예외를 던지면 SDK가 표준 JSON-RPC 에러로 바꿔 줍니다.
PYTHON
@mcp.resource("users://{user_id}/profile") def get_profile(user_id: str) -> str: """사용자 프로필을 반환한다.""" user = db.find(user_id) if user is None: raise ValueError(f"사용자를 찾을 수 없음: {user_id}") # → JSON-RPC error return user.to_json()
안전한 로깅
07장의 "stdout은 신성하다"를 다시 강조합니다. stdio 서버에서 print는 프로토콜을 깹니다.
PYTHON
import sys, logging # (1) 로그는 stderr로 logging.basicConfig(stream=sys.stderr, level=logging.INFO) log = logging.getLogger("demo") # (2) 또는 도구 안에서 프로토콜 로깅 (Context) @mcp.tool() async def work(x: int, ctx: Context) -> int: await ctx.info(f"work 시작: x={x}") # 클라이언트로 가는 구조화 로그 return x * 2
🔒 로그에 민감정보·내부 구조 노출 금지: 에러 메시지를 클라이언트에 돌려줄 때 스택 트레이스·내부 경로·설정·비밀값을 그대로 흘리지 마세요. OWASP MCP 가이드와 도구 모범사례 모두 "클라이언트로 가는 에러 메시지를 정화(sanitize)하라"고 권합니다. 사용자에겐 "조회 실패", 내부 로그(stderr)엔 상세 — 이렇게 분리합니다. 여러 소스를 합치는 도구는 부분 실패를 깔끔히 처리하고 어느 부분이 왜 실패했는지 알려 주되, 민감 정보는 빼고요.
단위 테스트 — 인메모리로 빠르게
전송을 띄우지 않고도, SDK의 인메모리 클라이언트로 서버를 직접 호출해 테스트할 수 있습니다. pytest + anyio/asyncio 패턴입니다. pytest는 파이썬에서 가장 널리 쓰이는 테스트 프레임워크이고, asyncio는 파이썬 표준 비동기 실행 라이브러리, anyio는 여러 비동기 백엔드를 아우르는 호환 라이브러리입니다. async 함수를 테스트하려면 이런 도구가 필요합니다(15장의 async 참고).
PYTHON
# test_server.py import pytest from mcp.shared.memory import create_connected_server_and_client_session as connect from server import mcp # 위에서 만든 FastMCP 인스턴스 @pytest.mark.anyio async def test_add(): async with connect(mcp._mcp_server) as client: result = await client.call_tool("add", {"a": 2, "b": 3}) assert result.content[0].text == "5" @pytest.mark.anyio async def test_unknown_tool_is_error(): async with connect(mcp._mcp_server) as client: result = await client.call_tool("nope", {}) assert result.isError # 없는 도구 → 에러
💡 팁
테스트 전략(24장에서 확장): 최소한 (1) 기능 — 유효 입력에 올바른 결과, (2) 검증 — 잘못된 입력이 걸러지는지, (3) 에러 — 실행 실패가 isError로 오는지, (4) 스키마 — outputSchema 선언 시 structuredContent가 맞는지를 덮으세요. 인메모리 테스트는 빠르고 전송·네트워크 변수를 배제해 로직에 집중하게 해줍니다.Inspector로 디버깅
14장에서 띄운 Inspector를 디버깅 관점에서 다시 봅니다.
BASH
mcp dev server.py # 로컬 스크립트 # 또는 임의 서버 패키지를 직접: npx -y @modelcontextprotocol/inspector uvx some-mcp-server arg1 arg2
디버깅 체크리스트:
| 증상 | 의심 지점 | 확인 |
|---|---|---|
| 연결 자체가 안 됨 | stdout 오염 | 본문에 print가 있나? stderr로 옮겼나 |
| 도구가 안 보임 | 데코레이터 누락/이름 오류 | Inspector "List Tools"에 나오나 |
| 인자 거부 | inputSchema 불일치 | 타입 힌트·Field 제약 확인 |
| 호출이 에러 | outputSchema vs structuredContent | 반환이 스키마와 맞나, None은 아닌가 |
| 권한/인증 실패 | 토큰·세션 | 환경 변수 주입, 인증 헤더(7부) |
💡 팁
재현 → 격리 → 수정: 버그가 나면 Inspector에서 최소 입력으로 재현하고, 로그(stderr/ctx.info)로 어느 단계인지 격리한 뒤 고칩니다. Inspector는 JSON-RPC 원문도 보여주니, 메시지 수준에서 무엇이 오갔는지 확인할 수 있습니다.견고한 서버 체크리스트
- 입력: 스키마 검증 + 본문에서 경로/주입 정화(15장)
- 에러: 비즈니스 실패는
isError, 통합 오류는 예외. 클라이언트로 가는 메시지 정화 - 로깅: stdout 금지, stderr/
ctx사용, 민감정보 비노출 - 타임아웃·취소·레이트리밋 고려(13장, 23장)
- 테스트: 기능·검증·에러·스키마 4축, 인메모리로 빠르게
이 장에서 배운 것
- 비즈니스 실패 →
isError: true(모델이 교정 가능), 통합 오류 → 예외(SDK가 JSON-RPC error로 변환). isError 응답은 outputSchema 검증을 건너뛴다. - 로그는 stderr 또는
ctx.info, 절대 stdoutprint금지. 클라이언트로 가는 에러는 정화해 민감정보를 숨긴다. - 인메모리 클라이언트로 전송 없이 단위 테스트(기능·검증·에러·스키마 4축).
- Inspector로 재현·격리·수정. 증상별 체크리스트로 흔한 버그를 빠르게 잡는다.
✍️ 확인 문제
- 결제 도구에서 "카드 한도 초과"는 예외로 던질까,
isError: true로 줄까? 그 이유를 모델 관점에서 설명해 보자. - 외부 API 호출이 실패해 스택 트레이스가 났다. 이걸 클라이언트에 그대로 돌려주면 안 되는 이유와 올바른 처리는?
- 전송을 띄우지 않고 도구 로직만 빠르게 검증하려면 어떤 테스트 방식을 쓰나?
5부 끝. 다음은 6부 · 클라이언트·호스트 관점(17장)으로 이어집니다. 이번엔 "서버를 쓰는 쪽"을 봅니다.