Skip to content

fix(mcp): JSON-RPC 규약 적합 — 버전 협상 부재와 비객체 프레임 무응답(응답 증발) - #3720

Closed
kevin9327 wants to merge 1 commit into
edwardkim:develfrom
kevin9327:pr/mcp-jsonrpc-conformance
Closed

fix(mcp): JSON-RPC 규약 적합 — 버전 협상 부재와 비객체 프레임 무응답(응답 증발)#3720
kevin9327 wants to merge 1 commit into
edwardkim:develfrom
kevin9327:pr/mcp-jsonrpc-conformance

Conversation

@kevin9327

Copy link
Copy Markdown
Contributor

요약

mcp-serve 의 JSON-RPC 2.0 / MCP 규약 위반 2건입니다. 둘 다 실제 바이너리를 stdio 로 구동해 재현했습니다.

initialize 가 클라이언트가 보낸 protocolVersion 을 무조건 되비춘다

>>> {"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"9999-99-99",...}}
<<< {"id":1,"jsonrpc":"2.0","result":{...,"protocolVersion":"9999-99-99",...}}

>>> {"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"banana",...}}
<<< {"id":1,"jsonrpc":"2.0","result":{...,"protocolVersion":"banana",...}}

존재하지 않는 개정판도, 아예 버전이 아닌 문자열도 "지원한다"고 답합니다. 그래 놓고 몸통은 2025-06-18 에 신설된 structuredContent 를 내보냅니다 — 같은 세션에서 실측:

>>> initialize protocolVersion=2024-11-05
<<< {...,"protocolVersion":"2024-11-05",...}
>>> tools/call hwp_info
<<< {...,"structuredContent":{"format":"hwp3","pageCount":16,...}}

MCP 2025-06-18 lifecycle 은 이렇게 정합니다:

"If the server supports the requested protocol version, it MUST respond with the same version. Otherwise, the server MUST respond with another protocol version it supports."
"If the client does not support the version in the server's response, it SHOULD disconnect."

되비추기는 마지막 문장의 메커니즘을 파괴합니다 — 클라이언트의 검사가 항상 통과하므로 끊을 기회 자체가 사라지고, 결국 못 읽는 응답을 받습니다.

② 파싱은 됐지만 Request 객체가 아닌 프레임이 무응답으로 버려진다

>>> [{"jsonrpc":"2.0","id":1,"method":"ping"}]     (JSON-RPC 배치)
<<< (아무것도 없음 — 2초 타임아웃, stdout 0바이트)

>>> "ping"  /  42  /  true  /  null
<<< (전부 무응답)

가장 날카로운 증거 — 스트림은 살아 있는데 응답 하나만 증발합니다:

>>> [{"jsonrpc":"2.0","id":1,"method":"ping"}]
>>> {"jsonrpc":"2.0","id":2,"method":"ping"}
<<< {"id":2,"jsonrpc":"2.0","result":{}}          ← id 1 은 영영 오지 않는다

원인은 msg.get("id") 가 비객체 프레임에 None 을 돌려주고, 그 None알림(notification)과 구분되지 않아 continue 로 빠지는 것입니다. 클라이언트는 id 1 을 영원히 기다립니다.

같은 뿌리의 인접 결함 — method 가 없거나 문자열이 아닌 경우:

>>> {"jsonrpc":"2.0","id":7}
<<< {"error":{"code":-32601,"message":"지원하지 않는 메서드: "},"id":7,...}

unwrap_or("") 가 빈 이름을 만들어 -32601 로 흘려보내고, 문구까지 이름이 빈 채로 나가 호출자가 원인을 못 짚습니다. JSON-RPC 2.0 §4method 는 String 이어야 하므로 이것은 "그런 메서드가 없다"가 아니라 "요청 구조가 틀렸다"(-32600)입니다.

수정

  • SUPPORTED_PROTOCOL_VERSIONS 목록 신설 + negotiate_protocol_version(). 지원 개정판이면 같은 값으로 확인, 아니면 서버 기준판을 제시합니다. 새 개정판을 실제로 구현하면 배열에 한 줄 더하는 것이 유일한 변경점입니다. 현재 목록은 ["2025-06-18"] 뿐입니다 — 서버의 실제 와이어 표면이 그것이고, 구현하지 않은 개정판을 주장하면 지금 고치는 결함을 그대로 재생산하게 됩니다.
  • INVALID_REQUEST(-32600) 신설. 비객체 프레임은 id 를 알아낼 수 없으므로 JSON-RPC 2.0 §5 대로 id: null 로 응답합니다.
  • 배열은 사유를 밝힙니다2025-06-18 changelog 의 "Remove support for JSON-RPC batching" 을 인용해 "이 개정판에 배치가 없다"고 답합니다. "배열이라 못 읽었다"가 아니라 사유가 곧 수정 지시가 되게 했습니다.
  • method 가 문자열이 아니면 -32600 + 프레임에서 읽어낸 id 를 그대로 되돌립니다.

동작 변화표

입력 프레임 이전 이후
initialize w/ "9999-99-99"·"banana"·"2024-11-05" 그대로 되비춤 "2025-06-18"
initialize w/ "2025-06-18" "2025-06-18" 변화 없음
initialize w/o protocolVersion "2025-06-18" 변화 없음
[{...}] 배치 0 바이트 -32600, id null, 배치 제거 사유
"ping" / 42 / true / null 0 바이트 -32600, id null
{"id":7} (method 없음) -32601 "지원하지 않는 메서드: " -32600, id 7
{"id":8,"method":123} -32601 동문 -32600, id 8
{not json -32700, id null 변화 없음
{"jsonrpc":"2.0","method":"notifications/initialized"} 무응답(정상) 변화 없음

회귀 가드 6종

무응답 결함은 기존 하네스(Server::request)로 검증할 수 없습니다 — read_line 에서 막혀 테스트가 실패가 아니라 hang 합니다. 그래서 읽기 전용 스레드 + recv_timeout(5s) 하네스(raw_frames)를 따로 만들어 회귀가 행이 아니라 실패로 보고되게 했습니다.

  • initialize_negotiates_instead_of_echoing_client_version — 미지원 5종
  • initialize_keeps_supported_version_and_defaults_when_absent — 짝 테스트(없으면 "항상 서버 버전 박기"로 잘못 고쳐도 통과)
  • non_object_frames_get_invalid_request_instead_of_silence — 배치·문자열·숫자·불리언·null
  • jsonrpc_batch_is_rejected_with_batching_removed_note — 사유에 개정판이 명시되는지
  • request_without_string_method_is_invalid_request_not_method_not_found
  • invalid_frame_does_not_swallow_the_next_response — 프레임 2건 → 응답 2건, 순서 보존

검증

  • cargo test --test mcp_server_contract12/12 (신규 6종 포함)
  • 인접 계약 mcp_session_edit·mcp_session_query·agent_profile_router — 15/15 무회귀
  • cargo clippy --profile release-test --bin rhwp — 경고 0 / cargo fmt --check — 통과
  • 기존 테스트 전부가 "2025-06-18" 을 보내므로 협상 변경은 기존 스위트에 무영향입니다.

범위 밖 (후속)

JSON-RPC 2.0 §4 는 id 도 String·Number·Null 이어야 한다고 정하는데, 현재 {"id":{"a":1}} 는 객체를 그대로 응답 id 로 되돌립니다. 같은 계열이지만 이 PR 의 증적 범위 밖이라 별도 이슈로 남깁니다.

🤖 Generated with Claude Code

두 결함 모두 실기 재현 확인:

① initialize 가 요청 protocolVersion 을 무조건 되비췄다. "9999-99-99" 도
   "banana" 도 합의된 것처럼 응답하고, 몸통은 2025-06-18 전용 표면
   (structuredContent)을 내보낸다 — 클라이언트는 끊어야 할 신호를 못 받은 채
   못 읽는 응답을 받는다. MCP lifecycle 은 지원하지 않는 개정판이면 서버가
   지원하는 개정판으로 답하라고 MUST 로 정한다.

② 파싱은 됐지만 Request 객체가 아닌 프레임(배치 배열·문자열·숫자·불리언·null)이
   한 바이트도 응답 없이 버려졌다. msg.get("id") 가 None 이라 알림과 구분되지
   않았기 때문이다. 실측: 배치 프레임 뒤 ping 만 응답되어 응답 하나가 통째로
   증발 — 클라이언트는 그 id 를 영원히 기다린다.
   method 가 없거나 문자열이 아닌 경우도 -32601 이 아니라 -32600 이다
   (문구도 "지원하지 않는 메서드: " 처럼 이름이 빈 채로 나갔다).

- SUPPORTED_PROTOCOL_VERSIONS 목록 + negotiate_protocol_version
- INVALID_REQUEST(-32600) 신설, 비객체 프레임·비문자열 method 라우팅
- 배열은 "2025-06-18 에서 배치가 제거됨"을 사유로 밝힌다 — 사유가 곧 수정 지시다
- 계약 테스트 6종: 무응답이 hang 이 아니라 실패로 보고되도록 타임아웃 하네스 사용

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants