본문으로 건너뛰기

© 2026 Molayo

Dev.to헤드라인2026. 06. 22. 03:28

에이전트가 호출하는 MCP 도구의 스키마가 변경되었습니다. 하지만 에이전트는 알아채지 못했습니다.

요약

MCP 서버의 도구 스키마가 변경되어도 에이전트는 이를 감지하지 못하고 잘못된 데이터를 기반으로 동작하는 '조용한 실패'가 발생할 수 있습니다. 이를 방지하기 위해 도구 계약의 SHA-256 해시를 고정하여 스키마 드리프트를 감지하는 해결책을 제시합니다.

핵심 포인트

  • MCP 서버의 스키마 변경은 구조적으로 유효하여 에러 없이 조용한 실패를 유발함
  • 파라미터 명칭 변경, 의미 재정의, enum 범위 축소 등이 주요 원인
  • 도구의 전체 계약(스키마 및 설명)에 대한 SHA-256 해시 고정 권장
  • 실행 전 계약 드리프트(drift)를 확인하여 데이터 무결성 보장

도구 호출(tool call)이 충돌하지 않았습니다. 200 상태 코드를 반환했고 유효한 JSON을 보냈습니다. 하지만 잘못된 계약(contract)을 대상으로 실행되었습니다. 상위 시스템(upstream)에서 3일 전에 필드 하나를 변경했고, 당신의 에이전트는 계속해서 이전 이름을 보냈으며, 서버는 이를 조용히 누락시켰고, 결과는 빈 값으로 돌아왔습니다. 에러는 없었습니다. 아무도 일주일 동안 알아채지 못했습니다.

제가 이야기하고 싶은 실패는 바로 이런 것입니다. 도구가 예외를 던져서 로그에 빨간색이 뜨는 요란한 종류의 실패가 아닙니다. 호출은 정상적으로 이루어지고, 서버는 웃으며 응답하지만, 데이터가 미묘하게 잘못된 조용한 종류의 실패 말입니다.

요약하자면: MCP 서버는 호출 사이에 도구의 계약을 변경할 수 있습니다. 파라미터(param)의 이름을 바꾸거나, 필드의 의미를 재정의하거나, 열거형(enum)의 범위를 좁히는 등의 방식이며, 이는 여전히 구조적으로 유효한(structurally valid) 방식입니다. 따라서 JSON 검증(validation)은 통과하고, 서버는 200 응답을 보내며, 당신의 에이전트는 단 하나의 에러도 없이 잘못된 데이터 조각을 바탕으로 동작합니다. 해결책은 각 도구의 전체 계약(스키마와 설명 모두)에 대한 SHA-256 해시를 고정(pin)하고, 실행 전에 계약이 드리프트(drift)된 호출을 잡아내는 것입니다. 표준 라이브러리만 사용합니다. 아래 데모는 결정론적(deterministic)입니다.

모두가 응답을 주시하지만, 계약을 주시하는 사람은 거의 없습니다.

저를 괴롭히는 부분은 바로 이 지점입니다. 우리는 도구가 응답을 했는지 확인하는 데는 익숙해졌습니다. 상태 코드(status code)를 확인하고, 타임아웃(timeout)을 설정하며, 5xx 에러 발생 시 재시도(retry)를 합니다. 이를 위한 라이브러리도 이미 많이 나와 있습니다.

하지만 에이전트가 스키마를 학습한 시점과 실제로 도구를 호출한 시점 사이에 도구의 _계약(contract)_이 변경되었는지 확인하는 사람은 거의 없습니다.

구성 요소들을 빠르게 복습해 보겠습니다. MCP 서버는 tools/list를 통해 도구를 노출합니다. 각 도구는 파라미터를 설명하는 JSON 스키마(JSON Schema)인 inputSchema와, 모델이 도구를 어떻게 호출할지 결정할 때 의존하는 인간이 읽을 수 있는 description을 포함합니다. MCP 명세(specification)는 이 점을 명확히 하고 있습니다: 도구 정의에는 "inputSchema: 예상되는 파라미터를 정의하는 JSON Schema"가 포함됩니다. 당신의 에이전트(또는 그 뒤의 모델)는 보통 세션 시작 시점에 이 계약을 한 번 읽고, 이를 바탕으로 호출을 생성합니다.

이제 업스트림(upstream)에서 릴리스를 배포합니다. 당신의 호출 사이의 어느 시점에 서버는 다음과 같은 작업을 수행할 수 있습니다:

  • additionalProperties를 열어둔 상태에서 파라미터 이름을 변경하여 (queryq로 변경), 기존의 query가 조용히 누락되게 함,
  • 필드의 이름과 타입은 유지하되 그 '의미(meaning)'를 뒤바꿈 (limit가 "최대 결과 수"에서 "페이지 인덱스"로 변경),
  • 열거형(enum)의 범위를 좁혀서, 한때 유효했던 값이 새로운 기본값으로 강제 변환되게 함 (format: "text"가 조용히 markdown으로 변경),
  • 그리고, 가장 눈에 띄는 경우로, 완전히 새로운 파라미터를 필수(required)로 지정함.

이 네 가지 중 세 가지는 '구조적으로 유효한 호출(structurally valid calls)'입니다. 형태(shape)가 여전히 JSON Schema 검사를 통과하므로, 유효성 검사(validation) 단계에서는 아무런 문제가 나타나지 않습니다. 서버는 유효한 JSON과 함께 200 응답을 보냅니다. 유요한 유일한 문제는 응답 내용뿐입니다. HTTP 세계의 그 어떤 것도 지면이 움직였다는 사실을 당신에게 알려주지 않습니다.

"하지만 스펙에는 이에 대한 알림 기능이 있잖아요"

있습니다. 만약 서버가 listChanged 기능(capability)을 선언한다면, 스펙에 따르면 도구 목록이 변경될 때 서버는 notifications/tools/list_changed를 전송해야(SHOULD) 하며, 클라이언트는 이를 다시 가져와야(SHOULD) 합니다.

두 번의 'SHOULD'입니다. 'MUST'가 아닙니다. 이 단어 선택이 모든 문제의 핵심입니다.

운영자 입장에서 다시 읽어보면 그 간극이 확연히 드러납니다. 서버는 알림을 보내야(should) 하지만, 그러지 않는 경우가 허다합니다. 특히 지난달 누군가 느낌대로 코딩한(vibe-coded) 제3자 래퍼(wrapper)라면 더욱 그렇습니다. 클라이언트는 다시 유효성 검사를 해야(should) 하지만, 그러지 않는 경우도 많습니다. 그리고 설령 알림을 보내는 서버라 할지라도, 단지 '목록(list)'이 변경되었다는 사실만 알려줄 뿐입니다. 목록에 여전히 남아 있는 도구의 스키마(schema)가 당신의 발밑에서 재구성되었는지에 대해서는 아무것도 말해주지 않습니다. inputSchema 내부의 이름 변경은 도구를 추가하거나 제거하지 않습니다. 목록은 동일해 보이지만, 계약(contract)은 달라져 있습니다.

결국 프로토콜은 표면적인 문제가 존재함을 인정하면서도, 런타임 강제(runtime enforcement)의 책임은 정중하게 당신에게 떠넘깁니다. 좋습니다. 직접 강제해 봅시다.

왜 조용한 버전이 더 비용이 많이 드는가

저는 프로덕션 스크래퍼(production scrapers)를 운영합니다. 32개의 게시된 액터(actors)를 통해 평생 2,190회의 실행을 수행했으며, 가장 바쁜 것은 962회 실행된 Trustpilot 리뷰 스크래퍼입니다. 제가 이 수치를 언급하는 것은 단순히 숫자를 과시하려는 것이 아니라, 이 교훈이 MCP 스펙이 아닌 바로 그 현장에서 얻어진 것이기 때문입니다.

이러한 실행 과정 전반에 걸쳐, 업스트림(upstreams)은 제 의사와 상관없이 계약(contracts)을 한 번 이상 변경했습니다. 타겟 사이트가 JSON 필드 이름을 변경하고 기존 필드를 무시하기 시작했습니다. 제3자 엔드포인트(third-party endpoint)는 page 파라미터(param)를 유지하면서도, 이를 1부터 시작하는 방식에서 0부터 시작하는 방식으로 조용히 전환하여 모든 결과가 한 페이지씩 어긋나게 만들었습니다. 이전에는 "rows"를 의미하던 필드가 완전히 다른 것을 의미하기 시작했습니다. 이 중 어느 것도 오류를 발생시키지 않았습니다. 모든 응답은 200(OK)이었습니다.

그리고 제가 겪었던 가장 최악의 실패 유형은 크래시(crash)가 아니었습니다. 크래시는 몇 분 안에 잡아낼 수 있습니다. 소리가 크고, 당신에게 알람을 보내며, 바로 수정할 수 있습니다. 정말 비용이 많이 드는 것은 바로 _오래된 계약에 대해 조용히 잘못된 호출을 수행하는 것(silently wrong call against a stale contract)_입니다. 호출은 유효하고, 서버는 이를 수락하며, 데이터는 틀려 있지만, 며칠 뒤 다운스트림(downstream)의 누군가가 왜 숫자가 이상하냐고 물을 때서야 비로소 알게 됩니다. 그때쯤이면 당신은 이미 그 잘못된 데이터 조각 위에 세 가지 기능을 더 구축한 상태일 것입니다.

여기 불편한 사실이 있습니다. 엄격한 JSON 스키마(JSON Schema) 검증도 당신을 구원해주지 못합니다. 이름이 바뀌었지만 허용된 필드, 재정의된 파라미터, 강제된 열거형(enum) 기본값은 모두 구조적으로 유효한(structurally valid) 호출입니다. 형태(shape)는 괜찮지만, 의미가 이동한 것입니다. MCP가 이 문제를 만들어낸 것은 아닙니다. MCP는 기존의 업스트림 드리프트(upstream-drift) 문제를 도구 계약(tool-contract) 계층으로 옮겨 놓았을 뿐이며, 친절하게도 이를 고정할 수 있는 깔끔한 장소를 제공합니다. 그러니 고정하십시오.

해결책: 도구 계약을 위한 락파일(lockfile)

당신은 이미 이 아이디어를 신뢰하고 있습니다. package-lock.json, poetry.lock, Cargo.lock은 모두 동일한 일을 합니다. 당신이 의존하는 정확한 것을 기록하고, 그것이 당신 모르게 변경되면 비명을 지릅니다.

MCP 도구에 대해서도 그렇게 하십시오. 처음 접촉할 때, 각 도구의 정형화된 _계약(contract)_에 대한 SHA-256 해시를 생성하고 이를 고정(pin)하십시오. 여기서 계약이란 inputSchemadescription을 함께 의미합니다. 모든 호출 전에 현재 계약의 해시를 다시 계산하여 비교하십시오. 일치하면 → 진행합니다. 불일치하면 → 호출에 플래그를 표시하고, 더 이상 존재하지 않는 계약을 향해 맹목적으로 실행하는 대신 무엇이 정확히 변경되었는지 알려주십시오.

스키마(schema)뿐만 아니라 설명(description)까지 해싱(hashing)하는 것이 핵심 비결입니다. 예를 들어, 어떤 파라미터가 "max results"에서 "page index"로 변경되었더라도 이름과 integer 타입이 동일하다면, 스키마만 해싱하거나 타입 검증기(type validator)만 사용해서는 이를 감지할 수 없습니다. 하지만 모델이 호출을 생성하기 위해 읽는 단어들이 바뀌었기 때문에, 계약(contract) 해시는 변경됩니다. 이것이 바로 검증(validation)으로는 잡아낼 수 없는 조용한 의미론적 드리프트(semantic drift)를 포착하는 방법입니다.

"정준(Canonical)" 방식 또한 실제로 중요한 역할을 합니다. JSON 객체의 키(key) 순서는 의미가 없으므로, 해싱하기 전에 키를 재귀적으로 정렬합니다. 그렇지 않으면 계약을 다른 키 순서로 재직렬화(re-serialize)하는 서버의 경우, 실제로는 아무것도 변하지 않았음에도 드리프트가 발생한 것처럼 보일 수 있습니다. 제가 여기서 의존하는 필드 의미론(field semantics)은 JSON Schema 문서에 근거합니다: "기본적으로 properties 키워드로 정의된 속성들은 필수(required) 사항이 아닙니다. 하지만 required 키워드를 사용하여 필수 속성 목록을 제공할 수 있습니다. required 키워드는 0개 이상의 문자열로 구성된 배열을 받습니다." 따라서 필드가 required로 이동하는 것은 계약의 변경이며, 차이점(diff)은 이를 반드시 포착해야 합니다. 이는 검증으로는 절대 잡아낼 수 없는 이름 변경, 설명 변경, 그리고 열거형(enum)의 범위 축소(narrowing)와 함께 이루어져야 합니다.

전체 코드는 다음과 같습니다. 표준 라이브러리인 hashlibjson만 사용했습니다. 네트워크, 시계, 난수(randomness)를 사용하지 않으므로, 매 실행 시 출력값이 바이트 단위로 완전히 동일합니다(저는 검증을 위해 이 점에 의존하며, 자세한 내용은 아래에서 다룹니다). 이 코드는 프로덕션용 코드가 아닌 실행 가능한 로컬(runnable local) 데모로 취급하십시오. 피스처(fixtures)는 수동으로 작성되었습니다.

#!/usr/bin/env python3
"""
schema_pin_check.py - MCP 도구 계약을 위한 락파일(lockfile).
...

python3 -I schema_pin_check.py로 실행하면 매번 다음과 같은 결과가 나옵니다:

=== schema_pin_check (합성 피스처, 실제 실패 모드) ===

NAIVE  (핀 없음, 에이전트가 서버의 200을 신뢰함):
  call search_reviews -> 200 OK (서버가 수락함; 에이전트가 이동)

AI 자동 생성 콘텐츠

본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.

원문 바로가기
0

댓글

0