16 분 소요

MCP(Model Context Protocol) 서버를 처음 만들어 본 사람은 대개 같은 착각에 빠진다. 데코레이터 하나 붙이고 파이썬 함수 몇 줄을 쓰면 Claude Desktop이나 사내 에이전트 화면에 도구가 즉시 나타난다. “동작한다”는 감각은 5분 만에 얻는다.

문제는 그다음이다. 동작한다와 믿을 수 있다 사이에는 꽤 넓은 강이 있다. 사내 MCP 서버를 다루면서 문제를 만날 때마다 원인 후보가 너무 많아 헤매기 쉬웠다. 프로토콜 계층의 문제인지, 도구 스키마와 구현이 어긋난 것인지, 아니면 모델이 도구를 엉뚱하게 골라서인지 구분이 되지 않았다. 이 글은 그 혼란을 테스트 계층 구조로 정리해 둔 기록이다.

이 글은 2026년 8월 MCP Dev Summit Seoul 2026에서 공개된 세션 “Building and Testing MCP Servers With the Inspector, Conformance Suites, and Property-Based Testing”(Navin Pai, StackGen)의 발표 자료를 참고해 재구성했다.1 다만 글의 주제는 행사 후기가 아니라 MCP 서버 테스트 전략 그 자체다.

MCP의 기본 토폴로지(Host–Client–Server), JSON-RPC 2.0 핸드셰이크, Stdio/SSE 전송, Tool·Resource·Prompt 3프리미티브는 이전 글에서 이미 다뤘다. 여기서는 그 위에 얹히는 검증 계층만 판다.


파트 1: MCP 서버 테스트가 어려운 이유

1.1 만들기는 쉽다, 증명은 어렵다

MCP SDK의 진입 장벽은 의도적으로 낮다. 함수에 @mcp.tool()을 붙이면 타입 힌트가 그대로 입력 스키마(Input Schema)가 되고, docstring이 그대로 모델에게 보이는 도구 설명(Description) 이 된다. 보일러플레이트가 없으니 “잘 만들었다”는 느낌이 강하게 든다.

그런데 이 편의성은 책임을 없애 주지 않는다. 오히려 책임이 눈에 안 보이는 곳으로 밀려난다.

  • 스키마는 자동 생성되지만, 그 스키마가 실제 구현이 감당할 수 있는 입력을 표현하는지는 아무도 검사하지 않는다.
  • 설명은 자동 추출되지만, 그 문장이 모델에게 어떤 라우팅 신호로 작용하는지는 아무도 검사하지 않는다.
  • 도구는 로컬에서 잘 돈다. 그러나 호출자가 사람이 아니라 LLM이라는 사실은 테스트를 쓸 때까지 드러나지 않는다.

1.2 세 겹의 복잡도: 프로토콜 × 도구 계약 × 비결정성

일반적인 REST API 테스트는 “요청 → 응답 → 상태” 세 가지를 검증하면 대부분 커버된다. MCP 서버는 그 위에 층이 세 겹 더 쌓인다.

MCP 서버 테스트의 3중 복잡도:

  ┌────────────────────────────────────────────────────────────┐
  │ (3) 모델 계층 — 비결정성                                    │
  │   호출자가 LLM: 창의적으로 틀린 인자를 만든다               │
  │   도구 설명이 모델 컨텍스트에서 "실행"된다                  │
  ├────────────────────────────────────────────────────────────┤
  │ (2) 도구 계약 계층 — 스키마 ↔ 구현 일치                     │
  │   inputSchema / outputSchema / annotations / isError        │
  ├────────────────────────────────────────────────────────────┤
  │ (1) 프로토콜 계층 — JSON-RPC 프레이밍, 라이프사이클, 전송   │
  │   initialize · Mcp-Method 헤더 · 에러 코드 · 세션          │
  └────────────────────────────────────────────────────────────┘

(1) 프로토콜 계층. 메시지가 JSON-RPC 2.0 프레이밍을 지키는가, 라이프사이클(핸드셰이크 → 초기화 알림 → 요청) 순서를 지키는가, 잘못된 요청을 스펙이 정의한 에러 코드로 거절하는가. 전송이 Stdio인지 Streamable HTTP인지에 따라 검증 포인트도 달라진다.

(2) 도구 계약 계층. 도구가 선언한 inputSchema와 실제 구현이 받아들이는 입력이 일치하는가. outputSchema를 선언해 놓고 다른 모양을 반환하지는 않는가.

(3) 모델 계층. 여기가 MCP 테스트를 특별하게 만드는 지점이다. REST 테스트 직관이 그대로 통하지 않는 이유는 세 가지다.

  • 호출자가 LLM이라 “창의적으로 틀린다”. 사람은 문서에 맞춰 호출하지만 모델은 그럴듯하게 빗나간 인자를 만들어 낸다. 타입은 맞는데 의미가 틀린 값, 경계값 바로 바깥, 스키마에 없던 조합이 기본값처럼 들어온다.
  • 적대적 클라이언트가 기본 가정이다. 공개된 MCP 서버는 악의적 클라이언트가 붙는 것을 전제로 방어해야 한다. “우리 클라이언트는 그렇게 호출하지 않는다”는 테스트 설계 근거가 되지 못한다.
  • 도구 설명은 프롬프트다. tools/list 결과는 어떤 도구 호출이 승인되기 전에 이미 모델 컨텍스트에 들어간다. Trail of Bits가 “LINE JUMPING” 문제로 정리한 지점이다.2 즉 도구의 docstring에 숨은 지시를 넣어 두면, 그 도구를 한 번도 호출하지 않고도 모델의 행동을 바꿀 수 있다. 테스트 대상이 함수가 아니라 문장이 되는 순간이다.

1.3 실측이 말하는 것

MCP 서버 테스트가 어렵다는 이야기는 주장으로만 두지 않고 숫자로 뒷받침된다. 감사·스캔 결과는 꽤 불편하다.

지표 값 출처(발표 자료 인용)
펜테스트 대상 서버의 명령 주입(Command Injection) 비율 43% Equixly 감사 결과3
서버 리포지토리 스캔 규모 473개 리포지토리 statelyai/sample-mcp-servers 스캔4
발견된 런타임 폴트(Runtime Fault) 스레드 837건 위 스캔4
통보받은 팀 중 “이론상 문제” 또는 “수용 가능”으로 응답한 비율 45% 위 스캔4

마지막 줄이 가장 시사적이다. 결함의 절반 가까이가 기술 문제가 아니라 우선순위 문제로 남는다. 즉 테스트 자동화의 목적은 “결함을 찾는 것”에서 한 걸음 더 나아가, 결함을 무시할 수 없게 만드는 것이어야 한다. CI에서 빨간불이 켜지고, 그 빨간불이 머지를 막는 구조가 필요하다.

실측 결함 통계 개념도: 큰 숫자 카드 세 장에 펜테스트한 서버의 43%가 명령 주입, 473개 리포지토리에서 837개 런타임 폴트, 통보받은 팀의 45%가 "이론상·수용 가능"으로 응답한 비율을 정리


파트 2: Inspector로 시작하는 대화형 검증

2.1 MCP Inspector가 답하는 질문

MCP Inspector는 MCP 서버를 사람이 직접 열어 볼 수 있게 해 주는 공식 도구다. 테스트 전략에서 Inspector의 위치는 “자동화 이전 단계”가 아니라 탐색(Exploration) 도구다. 코드를 짜기 전에 다음 질문에 답을 준다.

  • 핸드셰이크가 끝까지 가는가? 서버가 어떤 역량(Capabilities)을 선언했는가?
  • tools/list 응답의 스키마가 내가 의도한 모양인가? 설명 문장이 읽히는가?
  • 도구를 실제로 호출하면 어떤 페이로드가 오는가? 실패할 때 isError가 세워지는가?
  • 알림(Notification)·리소스 구독처럼 평소 로그로 안 보이는 흐름이 실제로 발생하는가?

Inspector는 Web UI·CLI·TUI 세 가지 프런트엔드로 제공되며, 레거시 상태 기반(stateful) 방식과 무상태(stateless) 방식을 모두 다룰 수 있게 재작성되었다.5 이 점은 뒤에서 다룰 스펙 리비전 드리프트와 직결된다.

2.2 실행

가장 흔한 두 가지 사용 형태는 다음과 같다.1

# 대화형 Web UI (기본)
npx @modelcontextprotocol/inspector

# HTTP 전송 서버에 CLI로 비대화형 질의
npx @modelcontextprotocol/inspector \
    --cli http://localhost:3000/mcp \
    --transport http \
    --method tools/list

Stdio 전송 서버라면 서버 실행 명령을 인자로 넘겨 Inspector가 자식 프로세스로 띄우게 한다. 사내 CI에서 “사람이 열어 본 화면”과 “봇이 검사한 결과”를 같은 도구로 맞추고 싶을 때 CLI가 유용하다.

2.3 손으로 확인할 최소 시나리오

Inspector를 열었다면 다음 순서대로 손으로 밟아 본다. 이 목록은 그대로 뒤의 자동화 테스트 목록이 된다.

  1. 정상 경로: initialize → notifications/initialized → tools/list → tools/call 성공 1회.
  2. 스키마 경로: 인자를 하나 빼고 호출, 타입을 뒤집어 호출 → 둘 다 JSON-RPC 에러(traceback이 아니라)로 끝나는지 확인.
  3. 에러 경로: 백엔드(DB·외부 API)를 꺼 놓고 호출 → 에러 메시지가 사람이 읽을 수 있는 문장인지, isError: true가 세워지는지 확인.
  4. 전송 경로: Stdio면 stdout에 로그가 섞여 JSON 프레이밍을 깨뜨리지 않는지, HTTP면 세션 ID·헤더가 정상 왕복하는지 확인.

4번은 특히 사고가 잦다. Stdio 서버에서 디버그용 print()를 남겨 두면 그 로그가 프로토콜 스트림에 섞여 클라이언트 파서가 즉시 죽는다. 로그는 반드시 stderr로 보낸다.

2.4 Inspector의 한계

Inspector는 재현성과 회귀 방지를 제공하지 않는다. 오늘 손으로 확인한 성공 경로는 리팩터링 후에 다시 확인해야 하고, 리뷰어는 그 사실을 모른다. 그래서 Inspector는 피라미드의 위쪽 계층(탐색·디버깅)에 두고, 아래쪽 계층(자동 검증)을 별도로 만든다.


파트 3: Conformance — 프로토콜 계약을 스펙에 고정하기

3.1 Conformance가 검사하는 것

Conformance(적합성) 테스트는 “우리 서버가 그 리비전(revision)의 스펙대로 말하는가”를 검사한다. 도구가 하는 일이 맞는지가 아니라, 말투와 예절이 맞는지를 본다. 검사 항목은 다음과 같다.6

  • 모든 메시지가 해당 리비전의 JSON Schema를 통과하는가
  • 요청마다 프로토콜 버전과 역량이 올바른 위치(_meta·헤더)에 실리는가
  • Mcp-Method 헤더가 본문의 메서드와 일치하는가 (불일치 응답은 거절되어야 한다)
  • tools/call에 존재하지 않는 도구 이름을 넣으면 -32602(Invalid params) 로 거절하는가 (500이 아니라)
  • 실패를 실패로 보고하는가 — 연결 거부 같은 사실을 텍스트로 적어 놓고 isError: false로 “성공”이라 답하지 않는가

마지막 항목은 사소해 보이지만 파급이 크다. 모델은 isError 값을 신뢰하고 다음 행동을 정한다. 실패를 성공으로 포장하면 에이전트는 존재하지 않는 파일을 읽었다고 믿고 다음 단계로 나아간다.

3.2 실행 형태

공식 conformance 스위트는 리비전별 시나리오를 제공하며, 서버가 특정 리비전에 대해 “검증됨” 상태인지 판정하는 데도 같은 스위트가 쓰인다.6 실행 예시는 다음과 같다.

$ npx @modelcontextprotocol/conformance server \
    --url http://localhost:3000/mcp --spec-version 2026-07-28

PASS  every message validates against the revision's JSON Schema
PASS  _meta carries protocolVersion + capabilities on each request
PASS  Mcp-Method header matches body (mismatches rejected)
PASS  tools/call with unknown tool returns -32602, not a 500
FAIL  tools/list: ttlMs expired but stale result still served

41 passed · 1 failed · spec 2026-07-28

CI 게이트 파이프라인 개념도: PR 변경 감지 → Conformance(스펙 리비전 매트릭스) → 계약 테스트(in-memory pytest) → 보안 회귀(재현 테스트 5종) → 릴리스 스모크(롤백 기준 확인)를 잇는 다섯 단계

여기서 눈여겨볼 것은 실패 항목의 성격이다. “TTL이 지난 캐시를 그대로 내보냈다”는 프로토콜 위반이 아니라 캐시 정합성 결함이다. Conformance 통과가 곧 품질을 뜻하지는 않는다. 그래서 다음 파트의 도구 계약 테스트가 필요하다.

3.3 스펙은 움직이는 표적이다

MCP 스펙은 짧은 기간에 여러 번 개정됐다. 리비전 타임라인은 이렇다.7

리비전 성격
2024-11-05 초기 공개 리비전
2025-03-26 전송·역량 정비
2025-06-18 인증·구조 정비
2025-11-25 상태 처리 방식 전환기
2026-07-28 최신 리비전(2026년 8월 기준 2주 전). 헤더/본문 불일치 거절이 MUST가 됨

여기서 실무적으로 중요한 두 가지 결론이 나온다.

  • initialize를 스크립트로 고정해 둔 테스트는 유령을 검사하게 된다. 세션 상태를 매 요청에 실어 보내는 방식으로 옮겨 가면서, “핸드셰이크를 한 번 하고 연결을 유지한다”는 고정 시나리오는 더 이상 실제 프로토콜 흐름을 대표하지 않는다. 어제의 픽스처가 오늘의 400이 되는 식이다.
  • 변경 로그를 쫓지 말고 --spec-version 매트릭스를 돌려라. 여러 리비전을 동시에 상대로 테스트 행렬을 돌리면, 어느 리비전에서 어떤 동작이 기대되는지가 테스트 이름 자체에 드러난다.
  • deprecation은 12개월 시계다. 스펙에서 폐기된 항목은 유예 기간이 끝나는 날짜가 정해져 있다. 이것을 백로그의 마감일로 등록해 두지 않으면, 어느 날 클라이언트가 일제히 실패한다.

3.4 전송 계층 검증 포인트

같은 도구라도 전송이 다르면 검증할 것이 다르다.

검증 항목 Stdio 전송 Streamable HTTP 전송
프레이밍 줄 단위 JSON, stdout 오염 없음 HTTP 헤더·본문 일치, 압축/인코딩
세션 프로세스 수명 = 세션 수명 세션 식별자, 재개(resume), 다중 인스턴스
인증 OS 프로세스 권한 토큰·mTLS·프록시 신뢰 경계
실패 모드 자식 프로세스 비정상 종료, 부분 출력 502/504, 프록시 버퍼링, 스트림 중단

CI에서는 두 전송을 모두 대상으로 돌리는 편이 안전하다. 로컬에서는 Stdio만 검증해 놓고, 프로덕션에서는 HTTP로 배포하는 구성이 흔한데 여기서 회귀가 자주 발생한다.


파트 4: 도구 계약 테스트와 경계값 설계

4.1 In-memory 전송으로 도구 단위 테스트

프로토콜 계층이 어느 정도 고정됐다면, 다음은 도구 하나하나의 계약을 검증할 차례다. 핵심은 네트워크와 프로세스를 띄우지 않는 것이다. SDK가 제공하는 인프로세스(in-memory) 전송을 쓰면 테스트 하나가 밀리초 단위로 끝나고, 실패 원인도 도구 구현으로 좁혀진다. 형태는 다음과 같다.1

# 도구 계약 테스트: 프로세스를 띄우지 않고 in-memory 전송으로 연결한다
import pytest
from fastmcp import Client
from fastmcp.exceptions import ToolError


async def test_query_returns_rows():
    async with Client(server) as c:          # 같은 프로세스 안에서 연결
        res = await c.call_tool("query", {"sql": "SELECT 1"})
        assert res.data == [[1]]


async def test_query_blocks_writes():
    async with Client(server) as c:
        with pytest.raises(ToolError):       # 스펙대로면 도구 에러로 끝난다
            await c.call_tool("query", {"sql": "DROP TABLE users"})

이 구조의 장점은 실패 메시지가 명확하다는 것이다. “연결이 안 된다”가 아니라 “쓰기 차단 규칙이 뚫렸다”가 빨간불로 뜬다. 앞 파트에서 본 프로토콜 오류(프레이밍·헤더)와 도구 결함을 분리해서 볼 수 있게 되는 것이 이 계층의 목적이다.

4.2 경계값 매트릭스

도구별로 다음 축을 조합해 최소 케이스를 만든다. 전부 자동화하기보다, 각 축에서 대표값 1~2개를 고르는 편이 유지보수에 유리하다.

축 대표 입력 기대 동작
필수 인자 누락 {} 스키마 검증 실패, JSON-RPC 에러
타입 불일치 {"limit": "10"} 명시적 거절 또는 안전한 강제 변환(정책 결정 필요)
경계값 limit=0, limit=-1, 최대 허용치 ±1 상한/하한 규칙대로 처리
대용량 1MB·10MB 문자열, 깊이 40 중첩 객체 제한 시간 내 명시적 거절
유니코드 RTL 오버라이드, 제로폭 문자, 결합 문자 정규화 후 처리, 경로·식별자 오염 없음
동시성 같은 도구 동시 호출 N회 멱등 도구는 동일 결과, 쓰기 도구는 직렬화/락
취소 호출 도중 클라이언트 취소 자원 해제, 부분 부작용 없음

4.3 타임아웃·취소·부분 부작용

여기서 “graceful”은 예외를 잡는 것이 아니라, 정해진 시간 안에 유효한 JSON-RPC 에러로 끝나고, 부분적인 부작용을 남기지 않는 것이다.8 이 정의를 테스트로 옮기면 세 가지가 필수 케이스가 된다.

  • 타임아웃: 백엔드를 지연시켜 상한 시간 초과 시 에러 응답이 오는지, 프로세스가 매달리지 않는지.
  • 취소: 취소 후에도 원격 자원이 열린 채 남지 않는지(파일 핸들·DB 커넥션·서브프로세스).
  • 부분 실패: 다단계 쓰기 도구가 중간에 실패했을 때 앞 단계가 롤백되거나, 최소한 “어디까지 반영됐는지”가 응답에 드러나는지.

4.4 실서버가 자주 틀리는 네 가지

“MCP 서버 만들기는 너무 쉽다”는 문제의식 아래, 실제 서버에서 반복 관찰되는 결함 유형은 다음과 같다.1

결함 유형 증상 테스트로 잡는 방법
입력 미검증 inputSchema를 선언해 놓고 구현은 검사하지 않음 스키마 기반 생성 입력으로 호출(파트 5)
핸드셰이크 전 응답 핸드셰이크가 끝나기 전에 요청을 처리 순서를 강제하는 시나리오 테스트
헤더/본문 불일치 수용 Mcp-Method와 본문 메서드가 다른데 통과시킴 conformance 스위트(파트 3)
실패를 성공으로 보고 연결 실패 텍스트 + isError: false 에러 주입 후 isError 검증

여기서 실무 팁 하나. 기존에 이미 배포된 서버라면 한 번에 다 고치려 하지 말고 expected-failures 베이스라인을 만들고 PR마다 하나씩 태워 나간다. 통과해야 할 목록과 “아직 실패하는 게 정상인” 목록을 분리해 두면, 새로 생긴 실패는 즉시 드러나고 기존 부채는 조용히 줄어든다.


파트 5: Property-Based Testing으로 입력 공간 탐색

5.1 예제 기반 vs 속성 기반

예제 기반 테스트(example-based test)는 개발자가 생각한 입력을 검사한다. 속성 기반 테스트(Property-Based Testing, PBT)는 입력 생성기(generator) 를 정의하고 수백 개의 입력을 자동으로 만들어 낸다. 차이는 이렇다.

예제 테스트는 당신이 생각한 것을 검사한다. 속성 테스트는 당신이 생각하지 못한 것을 검사한다.9

MCP 서버에 PBT가 특히 잘 맞는 이유는, 입력 스키마를 이미 갖고 있기 때문이다. 스키마는 곧 생성기 명세다. 별도로 입력 분포를 설계할 필요가 없다.

5.2 스키마에서 생성기를 만들기

Python에서는 hypothesis와 hypothesis-jsonschema의 from_schema()를 조합하면 JSON Schema에서 바로 생성기를 얻는다.9

from hypothesis import given, settings
from hypothesis_jsonschema import from_schema

schema = tool("search_docs").inputSchema   # 이미 써 둔 스키마가 생성기가 된다

@given(args=from_schema(schema))
@settings(max_examples=500)
async def test_contract(args):
    res = await client.call_tool("search_docs", args)
    assert res.is_valid_jsonrpc                  # 에러는 괜찮다, traceback은 안 된다
    validate(res.structuredContent, tool("search_docs").outputSchema)

TypeScript 쪽에서는 fast-check 계열 도구로 같은 일을 한다. zod-fast-check의 ZodFastCheck().inputOf(schema) 형태가 그 대표적인 예다.9 어느 쪽이든 핵심은 같다. 스키마를 만족하는 모든 입력에 대해 “서버는 절대 죽지 않는다”는 속성을 주장하고, 반례를 자동으로 찾게 만든다.

PBT는 실패했을 때 반례를 축소(shrinking) 해서 보여 준다. “10MB짜리 문자열에서 터졌습니다”가 아니라 “문자열 ''에서 터졌습니다”처럼 최소 재현 케이스를 준다. 이 최소 케이스는 그대로 회귀 테스트로 승격시킨다.

5.3 어노테이션도 테스트 가능한 주장이다

MCP 도구에는 readOnlyHint, idempotentHint, destructiveHint 같은 어노테이션이 붙는다. 이건 문서용 메타데이터가 아니라 클라이언트와 모델이 신뢰하는 계약이다. 이 계약은 그대로 테스트 항목이 된다.10

어노테이션·계약 확인할 속성 테스트 형태
(기본) 예외 없음 JSON-RPC 에러로 끝나고 traceback이 새지 않는다 무작위 입력 500회, 프로세스 생존 확인
outputSchema 반환값이 선언한 스키마를 만족한다 반환값 스키마 검증
readOnlyHint: true 호출 전후 상태 차이가 없다 상태 스냅샷 → 호출 → diff = ∅
idempotentHint: true 두 번 호출해도 결과가 같다 $f(f(x)) = f(x)$ 확인

특히 readOnlyHint 검증은 사고 예방 효과가 크다. 읽기 전용이라고 광고한 도구가 실제로는 감사 로그를 쓰거나 캐시를 갱신하면, 호스트는 그 도구를 승인 없이 실행하도록 설정해 둔 상태다. 즉 어노테이션 거짓말은 보안 구멍이다.

5.4 스키마를 만족하는 입력은 “친절한 경우”다

PBT의 생성기는 스키마를 만족하는 입력을 만든다. 그런데 실제 네트워크에는 스키마를 만족하지 않는 입력이 날아온다. 스키마를 통과한 입력은 친절한 경우(friendly case)일 뿐이다.8

실제로 도착하는 것들은 이런 모양이다.

  • 스키마가 string이라 했는데 숫자·불리언·중첩 객체가 들어온다
  • 10MB 문자열, 깊이 40단 중첩 객체
  • 프레이밍 자체가 깨진 메시지: id 누락, 알 수 없는 메서드, 배치(batch) 배열
  • 적대적 유니코드: 경로 문자열 안의 RTL 오버라이드, 제로폭 문자

이 계층은 스키마 기반 생성기만으로는 커버되지 않는다. 크래시·행(hang)·누수를 분류해 주는 mcp-server-fuzzer 계열처럼 프로토콜을 인지하는 퍼저를 별도로 돌려야 한다.8 앞에서 정리한 “graceful” 조건(유효한 JSON-RPC 에러 · 정해진 시간 안에 종료 · 부분 부작용 0)을 그대로 테스트 이름으로 옮겨 놓으면 리뷰할 때 판단이 쉬워진다.


파트 6: 보안 회귀 테스트

6.1 위협 → 테스트 케이스 대응표

보안은 별도 프로세스로 돌리면 잊힌다. 위협 하나당 재현 테스트 하나를 붙여 회귀 테스트로 영구화하는 것이 요령이다. 사내 MCP 서버에서 반드시 넣어야 할 최소 집합은 다음과 같다.

위협 재현 입력 예시 기대 동작
명령 주입(Command Injection) 인자에 셸 메타문자·개행·;·백틱 셸 미경유 실행 또는 인자 이스케이프, 실행 거부
경로 탈출(Path Traversal) ../../etc/passwd, URL 인코딩 변형, 제로폭 삽입 허용 루트 밖 접근 거부(roots 경계 준수)
권한 상승 읽기 도구 + 쓰기 도구 조합으로 우회 시도 도구별 권한 분리, 조합으로도 우회 불가
Confused Deputy 신뢰 경계 밖 콘텐츠에 심긴 지시로 고위험 도구 호출 유도 호스트 매개·사용자 승인 없이는 실행 불가
Tool Poisoning 도구 설명에 숨은 지시·비가시 유니코드 설명 린트가 차단(아래 6.2)

43%라는 명령 주입 비율3은 남의 이야기가 아니다. 인자를 문자열로 받아 그대로 명령에 붙이는 패턴은 사내 자동화 도구에서 특히 흔하다.

6.2 도구 설명(Description) 린팅

도구 설명은 호출 승인 전에 모델 컨텍스트에 들어가기 때문에2, 설명 자체가 공격 표면이자 프롬프트다. 대표 사례는 이렇다.

  • 전형적인 데모: add(a, b)처럼 무해해 보이는 도구의 docstring 안에 곁다리 파라미터를 설명해 두고, 그 파라미터로 ~/.ssh/id_rsa를 읽어 외부로 전송한다.
  • 조용한 실패: 설명이 서로 거의 같은 도구가 여러 개 있으면 모델이 엉뚱한 도구를 고른다. 보안 사고는 아니지만 라우팅 오류로 장애가 난다.

그래서 설명을 린트한다. 린터가 잡아내야 할 항목은 다음과 같다.11

  • 설명과 어노테이션의 모순 (예: 설명에는 “조회만 한다”고 쓰고 readOnlyHint: true인데 실제로 쓰기 동작)
  • 설명 안의 숨은 유니코드 → 도구 중독(tool poisoning) 패턴
  • 다른 서버의 도구 이름을 가리는(shadowing) 이름 충돌
  • 서로 지나치게 유사한 설명 → 오라우팅 위험

린터는 CI에서 종료 코드로 실패해야 의미가 있다. 사람이 읽고 넘어갈 수 있는 경고는 린트가 아니다.

6.3 보안 회귀 스위트 운영 규칙

  • 취약점을 고칠 때마다 재현 테스트를 먼저 추가한다. 순서를 바꾸면 같은 결함이 6개월 뒤 다시 돌아온다.
  • 실패 케이스를 성공 케이스보다 먼저 쓴다. 보안 테스트는 해피 패스가 아니라 거절 경로를 검증한다.
  • 거절 경로도 관측 가능해야 한다. 거부 시 감사 로그가 남는지까지 확인한다.
  • 정책 변경은 테스트로 고정한다. “이 도구는 프로덕션 DB에 접근할 수 있다”는 결정은 코드가 아니라 테스트에 적어 둔다.

파트 7: CI 파이프라인과 테스트 피라미드

7.1 5계층 피라미드

앞에서 다룬 계층을 하나의 피라미드로 쌓으면 다음과 같다. 아래로 갈수록 빠르고 많이 돌리고, 위로 갈수록 느리고 드물게 돌린다.12

        ▲  느림 · 드묾 · 사람 개입
        │
   ┌────┴─────────────────────────────┐
   │  린트 + LLM 평가(Lint + Evals)   │  설명 품질, 도구 선택 정확도
   ├──────────────────────────────────┤
   │  Inspector(대화형)               │  탐색·디버깅·수동 재현
   ├──────────────────────────────────┤
   │  프로토콜 Conformance            │  스펙 리비전 적합성
   ├──────────────────────────────────┤
   │  속성 기반 퍼징(Properties)      │  입력 공간 탐색·계약 검증
   ├──────────────────────────────────┤
   │  단위 테스트(인프로세스)         │  도구 로직·경계값
   └──────────────────────────────────┘
        ▼  빠름 · 흔함 · 기계 판정

MCP 서버 테스트 피라미드 개념도: 아래에서 위로 단위 테스트(인프로세스) → 속성 기반 퍼징 → 프로토콜 Conformance → Inspector(대화형) → 린트 + LLM 평가 순으로 쌓고, 아래로 갈수록 빠르고 흔하며 위로 갈수록 느리고 드물게 돌린다

각 계층의 실행 비용과 차단 강도를 표로 정리하면 다음과 같다.

계층 도구 예 실행 시간 PR마다 차단? 실패 시 의미
단위(인프로세스) pytest + SDK in-memory 초 예(필수) 도구 로직 결함
속성 기반 퍼징 hypothesis / fast-check 수십 초 예(시드 고정) 계약 위반, 예외 누수
프로토콜 Conformance 공식 conformance 스위트 분 예(필수) 스펙 위반, 클라이언트 호환성
Inspector Web UI·CLI 수동 아니오 탐색·디버깅 전용
린트 + LLM 평가 설명 린터, 선택 평가 분~십분 경고성(정책에 따라) 오라우팅·중독 위험

피라미드의 형태가 뒤집히지 않게 주의한다. Inspector에 의존한 수동 검증만 쌓아 두면(“아래가 좁고 위가 넓은”) 회귀 방지 능력이 0이 된다. 반대로 단위 테스트만 수천 개 있어도 스펙 위반은 하나도 못 잡는다.

7.2 PR 게이트 예시

스펙 conformance 스위트를 모든 PR에서 실행하고 도구 설명의 모호성을 린트한다는 원칙을 CI로 옮기면 다음과 같은 형태가 된다.1 GitHub Actions 표현식이 Jekyll의 Liquid로 먼저 해석되지 않도록 raw 블록으로 감싸 두었다.


name: mcp-server-ci
on: [pull_request]

jobs:
  test:
    runs-on: ubuntu-latest
    strategy:
      matrix:
        spec-version: ["2025-11-25", "2026-07-28"]   # 리비전 매트릭스
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with:
          python-version: "3.12"
      - name: 단위·속성 테스트
        run: pip install -r requirements-dev.txt && pytest tests/unit tests/properties -q
      - name: 서버 기동 후 프로토콜 conformance
        run: npx @modelcontextprotocol/conformance server \
               --url http://localhost:3000/mcp \
               --spec-version ${{ matrix.spec-version }}
      - name: 도구 설명 린트
        run: mcp-toolsmith lint --stdio "python3 -m app.server"

포인트는 세 가지다. 스펙 리비전을 매트릭스로 고정해 드리프트를 조기에 드러내고, conformance를 필수 게이트로 두고, 설명 린트를 코드 린트와 같은 위치에 둔다. 린트가 별도 잡이 아니라 같은 잡 안에 있어야 리뷰어가 한 화면에서 원인을 본다.

7.3 무상태성과 스케일 아웃 검증

최신 리비전 흐름이 세션 상태를 요청별 메타데이터로 옮겨 가면서, 서버는 세션 친화성 없이 동작해야 한다. 이 요구는 테스트로 명시해야 한다.13

  • 인스턴스 2개를 라운드로빈으로 세워 놓고 전체 스위트를 돌린다. 어느 인스턴스로 가도 같은 결과가 나와야 한다.
  • 스위트 중간에 인스턴스 하나를 재시작한다. 아무것도 눈치채지 못해야 한다.

이 테스트를 통과하지 못하는 서버는 오토스케일링 환경에서 간헐적으로 실패한다. 그리고 그 실패는 재현이 거의 불가능해 디버깅 비용이 가장 비싼 종류다.

7.4 도구가 늘어나면 기존 도구가 깨진다

끝으로, 자주 간과되는 성질 세 가지를 짚어 둔다.13

  • 도구 #13이 도구 #1~12를 깨뜨린다. 도구를 하나 추가할 때마다 모델의 라우팅이 재편된다. 그래서 도구를 추가하는 PR에서는 도구 선택 평가(selection eval) 를 다시 돌려야 한다. “기존 테스트가 다 통과했으니 안전하다”가 성립하지 않는 영역이다.
  • 출력은 누군가의 컨텍스트다. 응답은 결국 토큰이고, tools/list는 매 요청에 실린다. 200KB짜리 응답은 기능이 아니라 버그다. 토큰 예산을 테스트로 강제한다.
  • 에러도 프롬프트다. 모델은 에러 문장을 읽고 다음 행동을 정한다. "error 429"보다 "rate limited, retry after 30s"가 압도적으로 낫다. 에러 메시지의 정보량과 다음 행동 안내를 테스트로 검증한다.

7.5 테스트는 일정표대로 썩는다

리비전이 5번 바뀌는 동안 테스트도 같이 늙는다. “당신의 테스트는 일정표대로 썩는다”는 말이 그래서 나온다.7 관리 방법은 세 가지다.

  1. 리비전 매트릭스를 테스트 이름에 노출한다. test_initialize_2024_11_05처럼 리비전을 이름에 넣으면, 어느 리비전을 언제 버릴지 판단이 쉬워진다.
  2. deprecation 마감일을 이슈로 등록한다. 12개월 유예는 넉넉해 보이지만 사내 플랫폼에서는 순식간이다.
  3. 스펙 감시를 자동화한다. 새 리비전이 나오면 매트릭스에 한 줄 추가하고 실패 항목을 베이스라인에 넣는다.

실무 적용: 사내 MCP 서버 CI 게이트 구축

여기서부터는 한국 엔지니어가 사내 프로덕션 AI 플랫폼(Azure/Kubernetes 기반, 사내 MCP 서버 다수 운영, LLM·RAG 서비스 병행)에서 이 전략을 실제로 도입하는 절차다. 목표는 “우리 MCP 서버는 테스트가 있다”가 아니라 “릴리스마다 같은 기준으로 검증된다” 로 상태를 바꾸는 것이다.

8.1 3주 도입 로드맵

주차 목표 산출물 완료 판정
1주 현재 상태 측정 conformance 1회 실행 결과, 결함 목록, expected-failures 베이스라인 실행 로그와 실패 항목이 이슈로 등록됨
2주 하위 계층 자동화 도구별 in-memory 단위 테스트, 경계값 케이스, 보안 재현 테스트 5종 pytest 로컬 통과, CI 잡 초안 동작
3주 게이트 활성화 리비전 매트릭스 잡, 설명 린트 잡, PR 템플릿 체크리스트 PR 1건에서 실패가 실제로 머지를 막음

중요한 것은 1주차의 베이스라인이다. 처음부터 전부 실패로 두면 아무도 CI를 보지 않는다. “지금 실패하는 것은 실패로 기록하되, 새로 생긴 실패만 빨간불”이 되도록 스냅샷을 뜬다. 시간이 없다면 투입 순서는 conformance 게이트 → 보안 재현 테스트 → 도구 설명 린트 → 속성 기반 테스트 → 선택 평가·토큰 예산 순으로 잡는다.

8.2 단계별 테스트 매트릭스

사내 서버 성숙도에 따라 게이트를 올린다. 아래 표에서 3단계는 신규 서버 기본값, 1단계는 레거시 서버의 현실적인 출발점으로 삼는다.

단계 대상 서버 단위 속성/퍼징 Conformance 설명 린트 선택 평가
1 (도입) 레거시 사내 서버 필수 스모크만 경고(cron 주 1회) 경고 없음
2 (표준) 신규 서버 필수 필수(입력 100회) 필수(현행 리비전) 필수 PR 요약 지표
3 (고위험) 쓰기 권한·권한 상승 경로 보유 필수 필수(입력 500회+퍼저) 필수(리비전 매트릭스 전량) 필수(차단) 도구 추가 PR마다 재실행

고위험 서버 판단 기준은 간단하다. 부작용이 있는 도구를 가졌는가(파일 쓰기, 배포 트리거, 메시지 발송, DB 변경), 그리고 신뢰 경계 밖 콘텐츠가 입력으로 들어오는가(웹·이슈·문서). 둘 중 하나라도 참이면 3단계로 올린다.

8.3 리포지토리 템플릿과 테스트 스캐폴딩

신규 사내 MCP 서버는 아래 구조로 시작하는 편이 좋다. 목적은 “테스트를 어디에 두는가”를 매번 고민하지 않게 만드는 것이다.

mcp-server-template/
├── app/
│   ├── server.py            # 도구 정의(설명·스키마 포함)
│   └── tools/               # 도구 구현
├── tests/
│   ├── unit/                # in-memory 전송, 도구 계약·경계값
│   ├── properties/          # 스키마 기반 생성기 + 속성 검증
│   ├── conformance/         # 리비전별 시나리오(베이스라인 포함)
│   └── security/            # 명령 주입·경로 탈출·권한 상승 재현
├── expected-failures.yaml   # 아직 실패해도 되는 항목(부채 목록)
├── scripts/
│   ├── run-inspector.sh     # 로컬 탐색용
│   └── lint-tools.sh        # 도구 설명 린트
└── .github/workflows/ci.yml

expected-failures.yaml을 리포지토리에 두는 것이 핵심이다. 베이스라인이 코드처럼 리뷰되므로, 부채를 조용히 늘리려는 시도가 눈에 띈다.

# expected-failures.yaml — 통과해야 할 항목이 아니라, 통과해도 놀라지 않을 항목
conformance:
  "tools/list: ttlMs expired but stale result still served": "이슈 PLAT-2413, 10/24 수정 예정"
lint:
  "search: shadows a tool name on another connected server": "게이트웨이 프리픽스 도입 후 해소"

8.4 PR 체크리스트

PR 템플릿에 다음을 넣고, 해당 없는 항목은 체크 대신 사유를 적게 한다.

  • 도구 입력 스키마를 변경했다면 경계값 테스트를 함께 갱신했다
  • 새 도구를 추가했다면 도구 선택 평가를 재실행하고 결과를 첨부했다
  • 도구 설명(docstring)에 숨은 지시·비가시 유니코드가 없고, 기존 도구와 이름·설명이 겹치지 않는다
  • 응답 크기 상한(토큰 예산)을 넘는 도구가 없다
  • 실패 경로가 isError: true로 보고되고, 에러 문장에 다음 행동 안내가 있다
  • 권한·자격 증명이 도구 설명이나 에러 메시지에 노출되지 않는다
  • 부작용 있는 도구는 readOnlyHint/idempotentHint 표기가 실제 동작과 일치한다
  • expected-failures.yaml 변경이 있다면 사유 이슈 번호를 적었다

8.5 릴리스 전 스모크 항목과 롤백 기준

배포 파이프라인에 넣을 최소 관문이다. 스테이징에 배포한 직후 자동으로 수행하고, 실패 시 배포를 되돌린다.

# 스모크 항목 판정 기준 롤백 트리거
1 핸드셰이크·tools/list 현행 리비전 conformance 전량 통과 실패 항목 1건 이상
2 대표 도구 성공 경로 3종 기대 스키마대로 응답 스키마 불일치
3 대표 도구 거절 경로 3종 유효 JSON-RPC 에러, 프로세스 생존 예외 누수·행(hang)
4 무상태성 인스턴스 2대 라운드로빈에서 동일 결과 간헐 불일치
5 응답 크기·지연 도구별 p95 지연·응답 토큰이 예산 내 예산 초과 2배 이상
6 감사 로그 고위험 도구 호출이 호출자·인자와 함께 기록됨 기록 누락

롤백 기준은 자동이어야 한다. 사람이 판단하는 롤백은 새벽에 일어나지 않는다. 관측 지표(도구 호출 실패율, 프로토콜 에러 코드 분포, 토큰 사용량)를 배포 후 10분간 감시하고, 임계치를 넘으면 이전 리비전으로 되돌린다.


References

  1. Navin Pai(StackGen), “Building and Testing MCP Servers With the Inspector, Conformance Suites, and Property-Based Testing”, MCP Dev Summit Seoul 2026 발표 자료(스피커 제공). 본문에 인용한 도구·명령·실행 예시는 이 자료를 따른다. ↩ ↩2 ↩3 ↩4 ↩5

  2. 동일 발표 자료가 Trail of Bits의 연구로 소개한 “LINE JUMPING”. tools/list 결과가 도구 호출 승인 전에 모델 컨텍스트에 들어가므로, 도구 설명 자체가 프롬프트 표면이 된다는 지적이다. ↩ ↩2

  3. 동일 발표 자료가 인용한 Equixly 감사 결과. 펜테스트 대상 MCP 서버의 43%에서 명령 주입 취약점이 확인되었다고 보고한다. ↩ ↩2

  4. 동일 발표 자료가 인용한 statelyai/sample-mcp-servers 스캔 결과. 473개 서버 리포지토리에서 837개의 런타임 폴트 스레드가 발견되었고, 통보받은 팀 중 45%가 해당 문제를 “이론상(theoretical)” 또는 “수용 가능(acceptable)”으로 분류해 응답했다고 한다. ↩ ↩2 ↩3

  5. 동일 발표 자료. MCP Inspector가 Web UI·CLI·TUI 세 프런트엔드를 제공하고, 레거시 상태 기반 방식과 무상태 방식을 모두 다룰 수 있도록 재작성되었다는 설명을 근거로 정리했다. ↩

  6. 동일 발표 자료의 conformance 검사 항목 및 실행 예시. Mcp-Method 헤더와 본문 불일치 거절, 존재하지 않는 도구 호출 시 -32602 반환 등이 포함된다. ↩ ↩2

  7. 동일 발표 자료의 리비전 타임라인(2024-11-05, 2025-03-26, 2025-06-18, 2025-11-25, 2026-07-28)과 “테스트는 일정표대로 썩는다”는 경고, deprecation 12개월 시계 서술을 근거로 정리했다. ↩ ↩2

  8. 동일 발표 자료의 “schema-valid is the friendly case” 및 graceful 정의(유효한 JSON-RPC 에러 · 정해진 시간 안에 종료 · 부분 부작용 0). 함께 소개된 프로토콜 인지 퍼저 계열 도구는 개념 수준으로만 서술했다. ↩ ↩2 ↩3

  9. 동일 발표 자료가 인용한 속성 기반 테스트 도구 체계 — Python의 Hypothesis·hypothesis-jsonschema(from_schema), TypeScript의 fast-check 계열 및 zod-fast-check. 라이브러리 문서는 https://hypothesis.readthedocs.io/, https://fast-check.dev/ 참고. ↩ ↩2 ↩3

  10. 동일 발표 자료의 “annotations are testable claims” 정리(NEVER CRASH, OUTPUT ≡ outputSchema, readOnlyHint, idempotentHint)를 근거로 표를 재구성했다. ↩

  11. 동일 발표 자료의 도구 설명 린트 예시. 린터 출력에 포함된 검사 항목(설명과 readOnlyHint의 모순, 설명 내 숨은 유니코드, 다른 서버 도구 이름 가림)을 옮겼다. ↩

  12. 동일 발표 자료의 테스트 피라미드(단위 → 속성 기반 퍼징 → 프로토콜 conformance → Inspector → 린트·LLM 평가)와 “REST 테스트 직관이 통하지 않는 이유” 3항목을 재구성했다. ↩

  13. 동일 발표 자료의 “test for the server it becomes” 항목 — 무상태 테스트(라운드로빈·중간 재시작), 도구 추가 시 라우팅 재편과 선택 평가 재실행, 응답 토큰 예산, 에러 문장의 정보량. ↩ ↩2

댓글남기기