MCP 서버 테스트 자동화: Python SDK 인메모리 Client와 pytest로 도구 호출 검증하고 CI에 붙이기
프로세스·포트 없이 서버 객체에 직접 붙는 테스트, 도구 목록·스키마 스냅숏, 오류 응답 검사, GitHub Actions 연동

핵심 요약
- Python SDK의 Client는 서버 객체를 직접 받아 프로세스·포트 없이 연결하므로 pytest 픽스처로 쓰기 쉽다.
- 테스트에서는 raise_exceptions=True를 켜서 도구 내부 예외를 그대로 본다.
- 도구 본문의 일반 예외는 is_error=True 결과로, MCPError는 최상위 JSON-RPC 오류로 돌아오므로 각각 테스트로 고정한다.
- list_tools 결과를 스냅숏으로 저장하면 모델이 보는 인터페이스 변경이 PR diff에 드러난다.
- 인메모리 테스트는 전송 계층을 거치지 않으므로 배포 전 실제 실행 명령으로 한 번 연결 확인한다.
MCP 서버를 Inspector로 한 번 확인하고 끝내면, 도구의 docstring 한 줄이나 매개변수 타입 하나를 바꿨을 때 에이전트 쪽 동작이 달라져도 알아채기 어렵다. 이 글은 Python MCP SDK로 만든 서버에 자동 테스트를 붙이는 방법을 다룬다. 읽고 나면 서브프로세스나 포트 없이 서버 객체에 직접 연결하는 테스트를 작성하고, 도구 스키마가 바뀌면 PR 단계에서 실패하게 만들 수 있다.
테스트할 대상 세 가지
MCP 서버에서 깨지기 쉬운 부분은 비즈니스 로직보다 "모델이 보는 인터페이스"다. 테스트는 다음 세 층으로 나눈다.
| 층 | 검사 내용 | 깨지는 예 |
|---|---|---|
| 인터페이스 | 도구 이름·설명·입력 스키마 | 매개변수 이름 변경, Literal 허용값 삭제, docstring 삭제 |
| 정상 동작 | 대표 입력에 대한 결과 내용 | 반환 형식이 dict에서 문자열로 바뀜 |
| 오류 동작 | 잘못된 입력에서 오류 결과가 돌아오는지 | 예외가 처리되지 않아 연결이 끊김 |
도구 이름·스키마를 어떻게 설계할지는 MCP 도구 인터페이스 설계에서 다뤘다. 이 글은 그 설계가 유지되는지를 기계적으로 확인하는 방법이다.
인메모리 Client로 서버 객체에 직접 붙기
SDK 테스트 문서에 따르면 Client는 서버 객체를 그대로 받아 서브프로세스나 네트워크 없이 연결한다. FastAPI의 TestClient와 같은 방식이다. 문서는 테스트에서 raise_exceptions=True를 켜라고 권한다. 이 옵션이 없으면 도구 내부 예외가 원격 클라이언트용 "Internal server error" 메시지로 바뀌어 원인을 보기 어렵다.
- 테스트 의존성 추가 —
uv add --dev pytest anyio - 서버 객체를 import 가능한 모듈로 둔다 —
server.py의mcp객체를 테스트에서 가져올 수 있게 하고,mcp.run()은if __name__ == "__main__":아래에만 둔다. - 픽스처 작성 — 아래
conftest.py를 만든다.
# tests/conftest.py
import pytest
from mcp import Client
from server import mcp
@pytest.fixture
def anyio_backend():
return "asyncio"
@pytest.fixture
async def client():
async with Client(mcp, raise_exceptions=True) as c:
yield c
정상 결과와 오류 결과 검사
문서에 따르면 call_tool은 CallToolResult를 반환하고, 결과에는 content(텍스트 콘텐츠 목록)와 경우에 따라 structured_content가 들어 있다. 도구 본문에서 예외가 나면 예외가 그대로 올라오지 않고 is_error=True인 결과가 돌아온다. 아래는 Python MCP SDK로 MCP 서버 만들기에서 만든 티켓 조회 서버를 기준으로 한 테스트다.
# tests/test_tools.py
import pytest
pytestmark = pytest.mark.anyio
async def test_get_ticket_returns_fields(client):
result = await client.call_tool("get_ticket", {"ticket_id": "T-101"})
assert not result.is_error
assert result.structured_content is not None
assert result.structured_content["status"] == "open"
async def test_unknown_ticket_is_error_result(client):
result = await client.call_tool("get_ticket", {"ticket_id": "T-999"})
assert result.is_error
text = " ".join(c.text for c in result.content)
assert "T-999" in text # 오류 메시지에 원인이 들어 있는지
async def test_list_rejects_unknown_status(client):
result = await client.call_tool("list_tickets", {"status": "pending"})
assert result.is_error # Literal 허용값 밖의 입력
세 번째 테스트처럼 스키마 밖의 입력을 일부러 넣어 보는 테스트가 중요하다. 모델은 허용값 밖의 값을 보내는 일이 실제로 있고, 그때 서버가 의미 있는 오류를 돌려줘야 모델이 입력을 고쳐 다시 시도할 수 있다. 오류 메시지 문구에 원인(어떤 값이 왜 틀렸는지)이 들어 있는지까지 확인한다.
v2 마이그레이션 가이드에 따르면 MCPError를 도구에서 직접 던지면 is_error=True 결과가 아니라 최상위 JSON-RPC 오류가 된다. 일반 예외와 MCPError의 동작이 다르므로, 어떤 오류를 어느 쪽으로 보낼지 정했다면 두 경우를 각각 테스트로 고정해 둔다.
도구 스키마 스냅숏으로 의도치 않은 변경 잡기
인터페이스 층은 스냅숏 테스트가 효과적이다. 도구 목록과 입력 스키마를 JSON 파일로 저장해 두고, 테스트에서 현재 값과 비교한다. 변경이 의도된 것이면 스냅숏 파일을 갱신해 같은 PR에 포함한다. 그러면 리뷰어가 diff에서 "모델이 보는 인터페이스가 바뀌었다"는 사실을 바로 볼 수 있다.
# tests/test_schema_snapshot.py
import json, os, pathlib, pytest
pytestmark = pytest.mark.anyio
SNAP = pathlib.Path(__file__).parent / "tools.snapshot.json"
async def test_tool_schema_snapshot(client):
tools = (await client.list_tools()).tools
current = sorted(
({"name": t.name, "description": t.description, "input_schema": t.input_schema} for t in tools),
key=lambda t: t["name"],
)
if os.environ.get("UPDATE_SNAPSHOT") == "1" or not SNAP.exists():
SNAP.write_text(json.dumps(current, ensure_ascii=False, indent=2))
assert current == json.loads(SNAP.read_text())
필드 이름은 v2 기준이다. 마이그레이션 가이드에 따르면 v2는 프로토콜 타입의 camelCase 필드를 snake_case로 바꿨으므로 inputSchema가 아니라 input_schema로 읽는다. 스냅숏을 갱신할 때는 UPDATE_SNAPSHOT=1 uv run pytest로 실행한다.
GitHub Actions에서 실행하기
인메모리 테스트는 외부 프로세스를 띄우지 않으므로 CI 구성이 단순하다.
# .github/workflows/mcp-test.yml
name: mcp-test
on: [pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: astral-sh/setup-uv@v6
- run: uv sync --dev
- run: uv run pytest -q
스냅숏 파일이 바뀐 PR은 CODEOWNERS로 서버 담당자의 리뷰를 받게 하면, 도구 설명이 슬그머니 바뀌어 에이전트 동작이 달라지는 일을 줄일 수 있다.
인메모리 테스트가 잡지 못하는 것
이 방식은 서버 로직과 인터페이스를 검증하지만 전송 계층은 거치지 않는다. stdio 서버가 표준 출력에 로그를 써서 연결이 깨지는 문제, HTTP 서버의 인증·헤더 처리, 실제 클라이언트의 설정 오류는 잡지 못한다. 배포 전에는 실제 실행 명령으로 서버를 띄워 Inspector로 한 번 연결해 보는 단계를 남겨 둔다. 연결 문제를 찾는 순서는 MCP Inspector로 서버 디버깅하기에 정리했다. 테스트를 먼저 쓰고 구현을 맞추는 흐름이 익숙하지 않다면 AI 코딩 어시스턴트와 TDD의 순서를 참고할 수 있다.
초보자가 자주 실수하는 포인트
- mcp.run()을 모듈 최상단에서 호출해 테스트에서 import하는 순간 서버가 실행되는 경우
- 정상 입력만 테스트하고 스키마 밖 입력의 오류 응답은 확인하지 않는 경우
- v1 필드명(inputSchema, isError)으로 결과를 읽어 v2에서 테스트가 깨지는 경우
체크리스트
- 서버 객체를 테스트에서 import할 수 있고 run()이 main 블록 안에 있는가
- 도구마다 정상 결과 테스트와 오류 결과 테스트가 있는가
- 도구 스키마 스냅숏 파일이 저장소에 있는가
- PR마다 pytest가 CI에서 실행되는가
- 배포 전 실제 전송 방식으로 연결 확인 단계를 두었는가
자주 묻는 질문
TypeScript SDK로 만든 서버도 같은 방식으로 테스트할 수 있나요?
TypeScript SDK에도 클라이언트와 서버를 메모리 안에서 연결하는 전송 구현이 있습니다. 다만 v2에서 패키지가 서버·클라이언트로 나뉘는 등 import 경로가 바뀌었으므로, 사용하는 버전의 저장소 예제를 기준으로 연결 코드를 작성합니다.
외부 API를 호출하는 도구는 어떻게 테스트하나요?
도구 함수가 직접 HTTP를 호출하지 않고 주입받은 클라이언트 객체를 쓰게 만든 뒤, 테스트에서는 가짜 객체로 바꿔 끼웁니다. 외부 API와의 실제 연동은 별도의 통합 테스트로 분리해 실행 빈도를 낮춥니다.
참고 자료 · 검증 기준
위 자료와 내용을 대조한 날짜: . 도구·서비스 정책은 이후 바뀔 수 있으므로 적용 전 공식 문서를 다시 확인하세요.
이 글은 실제 실무 환경 테스트 및 검증된 기술 자료를 바탕으로 작성되었으며, 환경 변화에 따라 지속적으로 갱신됩니다.