MCP 서버의 쓰기 도구(write tools)가 본문(body)을 조용히 누락시키는 문제
요약
MCP 서버 구축 시 Zod의 z.unknown() 사용으로 인해 쓰기 작업의 본문(body)이 클라이언트 단계에서 누락되는 문제를 다룹니다. 스키마가 비어 있으면 클라이언트가 필드를 제거하는 현상을 설명하고, 이를 해결하기 위한 올바른 스키마 정의 방법을 제시합니다.
핵심 포인트
- z.unknown() 사용 시 클라이언트가 body 필드를 누락할 수 있음
- 읽기 작업은 정상이나 POST 등 쓰기 작업에서 오류 발생 가능성
- z.union을 사용하여 객체와 문자열을 모두 허용하는 스키마 정의 필요
- 스키마 변경 후에는 실제 API와 대조하는 테스트 스위트 구축 권장
만약 모델이 어떤 엔드포인트든 호출할 수 있게 해주는 패스스루 도구(passthrough tool)를 사용하여 Model Context Protocol (MCP) 서버를 구축했다면, 일부 클라이언트에서 쓰기 작업(write operations)이 조용히 작동하지 않을 실질적인 가능성이 있습니다. 읽기(reads) 작업은 정상적으로 작동하기 때문에 이를 눈치채지 못했을 수도 있습니다.
저는 Hetzner를 위한 오픈 소스 MCP 서버를 구축하던 중 이 문제에 부딪혔습니다. 목록 조회(Listing)는 완벽하게 작동했습니다. 하지만 모든 생성(create), 업데이트(update), 삭제(delete) 작업은 API로부터 동일한 불만 사항을 반환했습니다.
A valid JSON document is required.
본문(body)이 빈 상태로 전달되고 있었습니다. 그 이유와 이를 해결한 단 한 줄의 코드를 소개합니다.
먼저, 정상적으로 작동하는 예시입니다. 이 서버는 자연어를 통해 로드 밸런싱된 스택을 구축하고 다시 해제하며, 모든 유료 작업 앞에는 비용 방어(cost guard) 장치가 마련되어 있습니다.
증상 (The symptom)
패스스루 도구는 모델이 어떤 페이로드(payload)든 보낼 수 있도록 자유 형식의 본문(body)이 필요합니다. Zod에서 이를 정의하는 가장 명백한 방법은 무해해 보입니다.
body: z.unknown().optional()
읽기 작업은 본문이 필요하지 않으므로 모든 GET 요청은 문제없이 통과되었습니다. 하지만 모델이 POST를 시도하는 순간, 본문이 API에 전혀 도달하지 못했습니다.
근본 원인 (The root cause)
z.unknown()은 빈 JSON 스키마(JSON schema)로 컴파일됩니다. 객체도 아니고, 문자열도 아닌, 빈 형태(empty shape)입니다.
여러 MCP 클라이언트들은 스키마가 비어 있는 속성(property)을 누락시킵니다. 검증할 내용도 없고 보낼 내용도 없기 때문에, 요청이 클라이언트를 떠나기 전에 해당 필드가 제거됩니다. 결과적으로 귀하의 서버는 본문이 undefined인 상태로 호출을 받게 되고, 빈 페이로드를 전달하며, API는 이를 거부하게 됩니다.
잔인한 점은 에러가 귀하의 서버가 아닌 상위 서비스(upstream service)에서 발생하기 때문에, 한 시간 동안 엉뚱한 곳에서 원인을 찾게 된다는 것입니다.
해결 방법 (The fix)
본문에 실제 스키마를 부여하십시오. 객체(object)만으로도 충분하며, JSON 문자열(JSON string)도 허용하도록 만들면 클라이언트가 페이로드를 어떻게 직렬화(serialize)하든 상관없이 작동합니다.
body: z.union([z.record(z.string(), z.unknown()), z.string()]).optional()
그다음 사용하기 전에 문자열 형태의 body를 객체로 파싱하세요. 이제 스키마가 비어 있지 않으므로, 클라이언트가 해당 필드를 전달할 것이며, 문자열 분기(string branch)는 직렬화된 JSON을 보내는 클라이언트들을 커버합니다. 이것이 수정의 전부입니다.
수정 사항이 실제로 유효한지 확인한 방법
눈에 보이지 않는 스키마 변경은 신뢰할 수 없는 스키마 변경입니다. 그래서 저는 클라이언트가 로드하는 것과 정확히 동일한 아티팩트(artifact)인 컴파일된 서버를 stdio를 통해 실제 API와 대조하며 구동하는 테스트 스위트(suite)를 작성했습니다. 읽기(Reads), 비용 및 삭제 방지(delete guards), 그리고 모든 리소스 유형에 대해 생성 후 삭제(create then delete) 과정을 수행합니다. 비용이 발생하는 모든 리소스는 finally 블록에서 제거되므로, 실패하더라도 비용이 누출되지 않습니다.
이 테스트는 버그를 잡아냈고, 수정 사항을 증명했으며, 이제 모든 변경 사항마다 실행됩니다. 중요한 숫자는 단순히 컴파일이 되는지가 아니라, 모든 도구가 실제 API에 응답했는지와 계정이 깨끗하게 유지되었는지입니다.
MCP 서버를 구축한다면
두 가지를 기억하세요.
- 입력을
unknown으로 타이핑하거나 타입을 지정하지 않은 모든 도구를 감사(Audit)하세요. 실제 스키마를 부여하십시오. - 쓰기 도구(write tools)를 테스트할 때는 유닛 테스트(unit tests)뿐만 아니라, 빌드된 서버를 통해 실제 서비스와 대조하며 테스트하세요. 이 실패는 오직 클라이언트 경계(client boundary)에서만 나타났습니다.
제가 이 문제를 발견한 서버는 MIT 라이선스 하에 오픈 소스로 제공됩니다. 이 서버는 AI 어시스턴트에게 Hetzner 플랫폼의 전체 기능인 Cloud, Storage Box, 그리고 Robot 전용 서버를 제공합니다.
npx -y hetzner-mcp
MCP 도구를 구축하면서 겪었던 가장 고통스러운 '조용한 실패(silent failure)'는 무엇이었나요?
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기