Skip to content

fix(mcp): negotiate a handshake-era revision at initialize - #513

Merged
imran-siddique merged 2 commits into
agentrust-io:mainfrom
zohebk8s:fix/509-initialize-protocol-version
Aug 17, 2026
Merged

fix(mcp): negotiate a handshake-era revision at initialize#513
imran-siddique merged 2 commits into
agentrust-io:mainfrom
zohebk8s:fix/509-initialize-protocol-version

Conversation

@zohebk8s

@zohebk8s zohebk8s commented Aug 15, 2026

Copy link
Copy Markdown
Collaborator

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-05 in the initialize result with PROTOCOL_VERSION. That constant is 2026-07-28, the revision that removed initialize.

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 _meta plus the mirrored MCP-Protocol-Version / Mcp-Method headers that a handshake-era client has no way to know it should send.

Reproduced against main (b27a067)

Every revision a real client actually offers:

PROTOCOL_VERSION constant = '2026-07-28'

  client asked 2025-06-18  ->  server answered 2026-07-28   [MISMATCH]
  client asked 2025-03-26  ->  server answered 2026-07-28   [MISMATCH]
  client asked 2024-11-05  ->  server answered 2026-07-28   [MISMATCH]

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_VERSION is 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 reaching initialize at 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.

initialize now 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-28 at 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.py no longer imports PROTOCOL_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 main today.

  • notifications/initialized is answered with a JSON-RPC error body; it must be 202 with no body. Every client sends it immediately after the handshake, so the connection dies before a tool is listed.
  • ping is method-not-found. Clients ping between tool calls and read a failure as the session dropping.
  • Unknown methods return HTTP 404. A client probing resources/list or prompts/list reads that as the endpoint being gone and discards the whole server rather than the one method.
  • server/discover is unimplemented, though 2026-07-28 makes it MUST-implement.
  • Mirrored request headers are not validated against the body. A tools/call for mock_tool sent with Mcp-Method: tools/list and Mcp-Name: some_other_tool currently returns 200 and executes, where the spec requires 400 with -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 on Mcp-Name sees one tool while the gateway executes another.
  • Origin is never validated. It has been a MUST since 2025-03-26 and 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.
  • Verified by mutation: reverting only the initialize line 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.
  • Full unit suite: 1076 passed, 10 skipped. The 8 agent_manifest SDK failures and the test_intent_binding collection error are pre-existing and identical on unmodified main, baselined in a clean worktree.
  • Ruff and mypy clean.

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>
@github-actions

github-actions Bot commented Aug 15, 2026

Copy link
Copy Markdown
Contributor

🟡 Contributor Check: MEDIUM

Check Result
Profile MEDIUM
Credential LOW
Overall MEDIUM

Automated check by AgenTrust Contributor Check.

@github-actions github-actions Bot added the needs-review:MEDIUM Contributor check flagged MEDIUM risk label Aug 15, 2026
@codecov-commenter

Copy link
Copy Markdown

⚠️ Please install the 'codecov app svg image' to ensure uploads and comments are reliably processed by Codecov.

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

@imran-siddique imran-siddique left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Two correctness issues block approval:

  1. _LEGACY_PROTOCOL_VERSIONS omits 2025-11-25, which is the latest initialize-capable MCP revision. The current lifecycle specification still defines initialize for that revision and requires a server to echo a requested version it supports; otherwise clients may disconnect after the server downgrades them to 2025-06-18. Please add 2025-11-25 as the newest supported handshake revision and cover the echo/fallback behavior. Reference: https://modelcontextprotocol.io/specification/2025-11-25/basic/lifecycle

  2. test_unnegotiable_params_fall_back_to_the_newest_legacy_revision includes array-shaped params and asserts success. The MCP schema defines InitializeRequest.params as an InitializeRequestParams object with required protocolVersion, capabilities, and clientInfo. 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>
@zohebk8s

Copy link
Copy Markdown
Collaborator Author

Both addressed.

1. 2025-11-25

Added as the newest handshake revision:

_LEGACY_PROTOCOL_VERSIONS = ("2025-11-25", "2025-06-18", "2025-03-26", "2024-11-05")

Negotiation now, across every case a real client produces:

  asked 2025-11-25 -> answered 2025-11-25
  asked 2025-06-18 -> answered 2025-06-18
  asked 2025-03-26 -> answered 2025-03-26
  asked 2024-11-05 -> answered 2024-11-05
  asked 1999-01-01 -> answered 2025-11-25
  asked 2026-07-28 -> answered 2025-11-25
  params absent    -> answered 2025-11-25

Echo and fallback are covered twice, deliberately. The parametrized echo test walks _LEGACY_PROTOCOL_VERSIONS, and two further tests name 2025-11-25 literally: that a client offering it is echoed and specifically not answered 2025-06-18, and that an unknown version falls back to it.

The literal pair exists because the parametrized test draws its cases from the constant under test, so removing a revision from the tuple removes its own case with it. That shape of coverage cannot fail on the defect you reported. Confirmed by mutation: with only the parametrized test, dropping 2025-11-25 failed one test; with the literal pair it fails three.

2. Non-object initialize params

Took the stronger of your two options. A non-object params now returns 400 with -32600, matching how #500 already rejects non-object tools/call params, so both entry points behave alike.

  params=['positional', 'params'] -> HTTP 400  -32600  no result member
  params='a string'               -> HTTP 400  -32600  no result member
  params=42                       -> HTTP 400  -32600  no result member

test_unnegotiable_params_fall_back_to_the_newest_legacy_revision is renamed to ..._newest_supported_revision and now carries object cases only. The array case moved into test_non_object_initialize_params_are_rejected alongside a string and an integer.

No over-rejection: {}, {"capabilities": {}} and a full request all still return 200, and absent params still negotiates the newest revision.

Scope note on member validation

Worth raising rather than leaving for you to find. The schema marks protocolVersion, capabilities and clientInfo all required on InitializeRequestParams, and params itself required on InitializeRequest. This change validates the shape of params and not its members, so {} and {"capabilities": {}} still negotiate successfully, and the fallback test still asserts that.

That leniency predates this branch and is left unchanged on purpose. Nothing consumes those members: the handler reads params.get("protocolVersion") and nothing else, and neither client capabilities nor clientInfo is referenced anywhere in src/ on the inbound path, so omitting them changes no decision, audit entry, or route. The schema also constrains what a client sends rather than obliging a server to reject, unlike the -32020 rule the 2026-07-28 binding states explicitly for mirrored headers.

Enforcing members would therefore be a strictness change rather than a fix, and it would have to decide what a handshake carrying no params at all should do, which today is a supported path. What did need doing was making sure no test promises the leniency: the fallback test now carries a scope note saying it asserts "negotiation still resolves" rather than "this payload is conformant".

Say the word if you would rather members were tightened here too, and it can land in the same PR.

Evidence

  • Mutation, each fix independently: dropping 2025-11-25 fails 3 tests; re-blessing non-object params fails 3 tests.
  • tests/unit/test_initialize_protocol_version.py is 16 tests.
  • Full unit suite: 1082 passed, 10 skipped. The 8 agent_manifest SDK failures and the test_intent_binding collection error are pre-existing and identical on unmodified main.
  • Ruff and mypy clean.
  • Outbound PROTOCOL_VERSION untouched, and asserted untouched by test.

Still three files and one behaviour.

@imran-siddique
imran-siddique merged commit dda3f4d into agentrust-io:main Aug 17, 2026
12 of 13 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

needs-review:MEDIUM Contributor check flagged MEDIUM risk

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants