fix(mcp): negotiate a handshake-era revision at initialize - #513
Conversation
agentrust-io#509 replaced the hardcoded `2024-11-05` in the `initialize` result with `PROTOCOL_VERSION`, addressing "stop hardcoding the downstream protocol version" from agentrust-io#496. That constant is `2026-07-28`, the revision that removed `initialize` altogether, so the gateway answered every handshake by naming a protocol in which the request just made does not exist, and in which each later request must carry `_meta` plus the mirrored MCP-Protocol-Version / Mcp-Method headers a handshake-era client has no way to know it should send. Confirmed against every revision a real client offers: asked 2025-06-18, 2025-03-26 or 2024-11-05, the gateway answered 2026-07-28 in all three cases. The direction of the swap is the defect. PROTOCOL_VERSION is correct on the outbound leg, where the gateway is the client and agentrust-io#509 got it right, and wrong on the inbound one, where reaching `initialize` is itself proof the caller is handshake-era. Negotiate over _LEGACY_PROTOCOL_VERSIONS instead, echoing the client's request when the gateway speaks it. A client asking for 2026-07-28 at a handshake is deliberately not humoured. server.py no longer imports PROTOCOL_VERSION; agentrust-io#509 added that import solely for this misuse. Verified by mutation: reverting only the `initialize` line while keeping the new constant fails 9 of the 10 new tests. Full unit suite 1076 passed, with the 8 pre-existing agent_manifest SDK failures unchanged from main. Signed-off-by: Mohammed Zoheb Shaik <zoheb.shaik7@gmail.com>
|
🟡 Contributor Check: MEDIUM
Automated check by AgenTrust Contributor Check. |
|
Codecov Report✅ All modified and coverable lines are covered by tests. 📢 Thoughts on this report? Let us know! |
imran-siddique
left a comment
There was a problem hiding this comment.
Two correctness issues block approval:
-
_LEGACY_PROTOCOL_VERSIONSomits2025-11-25, which is the latest initialize-capable MCP revision. The current lifecycle specification still definesinitializefor that revision and requires a server to echo a requested version it supports; otherwise clients may disconnect after the server downgrades them to2025-06-18. Please add2025-11-25as the newest supported handshake revision and cover the echo/fallback behavior. Reference: https://modelcontextprotocol.io/specification/2025-11-25/basic/lifecycle -
test_unnegotiable_params_fall_back_to_the_newest_legacy_revisionincludes array-shapedparamsand asserts success. The MCP schema definesInitializeRequest.paramsas anInitializeRequestParamsobject with requiredprotocolVersion,capabilities, andclientInfo. This test turns an existing validation gap into an asserted contract. Please reject non-object initialize params (or, at minimum, do not bless them as a successful negotiation case) and add the corresponding negative test. Reference: https://modelcontextprotocol.io/specification/2025-11-25/schema
The main direction of the fix is correct, the outbound constant remains properly isolated, the patch is otherwise scoped, and all GitHub CI jobs except the expected approval gate are green.
Review feedback on agentrust-io#513. 1. _LEGACY_PROTOCOL_VERSIONS omitted 2025-11-25, the newest revision that still defines `initialize`. A client offering the latest handshake revision was therefore answered 2025-06-18. The lifecycle spec requires a server to echo a requested version it supports, and says a client that does not support the server's answer SHOULD disconnect, so a needless downgrade is the same class of defect this branch already fixes, one revision over. 2025-11-25 now heads the tuple. Echo and fallback are covered twice over. The parametrized echo test walks _LEGACY_PROTOCOL_VERSIONS, and two further tests name 2025-11-25 literally: one that it is echoed rather than downgraded, one that an unknown version falls back to it. The literal pair matters because a test parametrized over the constant under test loses its own case when that constant is wrong, which is exactly the regression being fixed. 2. test_unnegotiable_params_fall_back_to_the_newest_legacy_revision asserted that array-shaped `initialize` params negotiate successfully, turning an existing validation gap into an asserted contract. InitializeRequestParams is an object with required protocolVersion, capabilities and clientInfo, so a non-object is now rejected with -32600, matching how agentrust-io#500 already rejects non-object tools/call params. Absent params stays legal and negotiates the newest revision. The array case moves to a negative test alongside a string and an integer. Verified by mutation, each fix independently: dropping 2025-11-25 fails three tests, re-blessing non-object params fails three more. Full unit suite 1082 passed, with the 8 pre-existing agent_manifest SDK failures unchanged from main. Ruff and mypy clean. Signed-off-by: Mohammed Zoheb Shaik <zoheb.shaik7@gmail.com>
|
Both addressed. 1.
|
Follow-up to PR #509, which was merged for #496.
Third on the to-do list in #496 was "stop hardcoding the downstream protocol version". PR #509 did that by replacing the hardcoded
2024-11-05in theinitializeresult withPROTOCOL_VERSION. That constant is2026-07-28, the revision that removedinitialize.So the gateway answers every handshake by naming a protocol in which the request just made does not exist, and in which each subsequent request must carry
_metaplus the mirroredMCP-Protocol-Version/Mcp-Methodheaders that a handshake-era client has no way to know it should send.Reproduced against main (b27a067)
Every revision a real client actually offers:
The direction of the swap is the whole defect
The intent behind #509's change was right; the constant was the wrong one for this leg.
PROTOCOL_VERSIONis correct outbound, where the gateway is the client calling upstream servers. #509 got that leg right and this PR does not touch it. It is wrong inbound, where reachinginitializeat all is proof the caller is handshake-era. There is no case in which the correct answer to a handshake is a revision that removed handshakes.initializenow negotiates over_LEGACY_PROTOCOL_VERSIONS(2025-06-18,2025-03-26,2024-11-05), echoing the client's request when the gateway speaks it and otherwise answering with the newest.A client that asks for
2026-07-28at a handshake is deliberately not echoed. It cannot be speaking a revision that has no handshake, so agreeing would settle on a protocol neither side is using. That case is pinned by a test rather than left implicit, since echo-what-was-asked is the reasonable default expectation here and this is the one place it is wrong.server.pyno longer importsPROTOCOL_VERSION. #509 introduced that import solely for this misuse, so removing it keeps the two legs from being confused for one another again.Scope
Three files, one behaviour. This does not touch the outbound work in #509.
It also does not address the rest of the inbound leg, what #496 called downstream. That issue examined it only far enough to spot the hardcoded version string, which is the finding this PR builds on. A fuller pass turns up the following. None is a regression from #509; they are longstanding gaps, all still true on
maintoday.notifications/initializedis answered with a JSON-RPC error body; it must be202with no body. Every client sends it immediately after the handshake, so the connection dies before a tool is listed.pingis method-not-found. Clients ping between tool calls and read a failure as the session dropping.404. A client probingresources/listorprompts/listreads that as the endpoint being gone and discards the whole server rather than the one method.server/discoveris unimplemented, though2026-07-28makes it MUST-implement.tools/callformock_toolsent withMcp-Method: tools/listandMcp-Name: some_other_toolcurrently returns200and executes, where the spec requires400with-32020. This is the case the mirroring rule exists for, and a gateway is the intermediary it was written about: anything in front routing or inspecting onMcp-Namesees one tool while the gateway executes another.Originis never validated. It has been a MUST since2025-03-26and is the guard against DNS rebinding.Evidence
tests/unit/test_initialize_protocol_version.py, 10 tests, pins the negotiation and asserts the outbound constant is untouched.initializeline while keeping the new constant fails 9 of the 10. The one that still passes is the outbound-constant guard, which correctly does not depend on the fix.agent_manifestSDK failures and thetest_intent_bindingcollection error are pre-existing and identical on unmodifiedmain, baselined in a clean worktree.