Repository navigation
feat: speak MCP 2026-07-28 in the hand-written MCP code - #51
Conversation
MCP 2026-07-28 removes the initialize handshake and the session. Each request names its protocol version and client capabilities in _meta. Each streamable HTTP request mirrors its version, method and name in headers. Each result has a resultType. rmcp 3.5 already does this for the memory server. This change does it for the three parts of amem that do not use rmcp. amem tool-server now serves both revisions. A request that names its version in _meta, or that asks server/discover, gets 2026-07-28 behaviour. The server checks the version, the client capabilities, and the MCP-Protocol-Version, Mcp-Method and Mcp-Name headers. It refuses a missing header with -32020, as the spec requires. It refuses an unknown version with -32022 and lists the versions that it supports. Results get resultType "complete" and the server info. An initialize request still opens the old path. initialize now answers with the version that the client asks for if the server supports it, and with 2025-11-25 if not. Before, it repeated any version. The hook client in the sandbox now sends one stateless tools/call. Before, it sent three requests. A test runs it against the real memory server through the egress router. The hosted-tool client sends server/discover first. If the answer lists 2026-07-28, it sends one stateless tools/call. If not, it uses the initialize handshake. On that path it now also sends the negotiated MCP-Protocol-Version header. It keeps the result of the probe per server URL and forgets it when a call fails. Both clients refuse a resultType other than "complete" and accept a result that has no resultType. The new module mcp_meta holds the shared parts: the _meta keys, the headers, the base64 encoding of header values, and the resultType check. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Bhj4Ck4y5iwZUsjpgQx8A2
…change amem tool-server now compares a sent Mcp-Name header with the body on both paths, as it already did for Mcp-Method. Before, a call whose header named "search" and whose body named "fetch" ran fetch on the handshake path. A client of the handshake can still leave out both headers. amem tool-server now refuses a body that is not one JSON object, for example a batch, with HTTP 400 and -32600. Before, it answered 202 and dropped the body. A stateless request that names an unsupported version now gets -32022 with only 2026-07-28 in data.supported. The message says that older versions are reached through initialize. Before, the list included the handshake versions, also the version that the request named. The hosted-tool client now reads the HTTP status of the server/discover answer. It falls back to the handshake on 400, 404, 405, 415 or 422 without a 2026-07-28 error, or on a success with -32601. Any other answer is an error, and the client does not keep it as the revision of the server. Before, a 502 page, a 429 or a -32001 refusal made the client use the handshake for the life of the process. 415 and 422 are in the list because rmcp session servers send them for a request that they cannot read or that comes before initialize. New tests cover each fix. A new test also covers the per-server cache: two calls make one discovery request, and a failed call makes the next call ask again. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Bhj4Ck4y5iwZUsjpgQx8A2
There was a problem hiding this comment.
🟡 Changes recommended
Protocol handling retains stale cache entries after response-validation failures, accepts unsupported discovery result types, and rejects valid encoded method headers.
3 open issues
What changed in this PR
This PR updates the three hand-written MCP components in crates/memory to 2026-07-28, following the protocol documentation update in #48.
Changes:
- Adds shared request metadata, header encoding, and result validation.
- Adds stateless tool-server requests and hosted-client discovery, while retaining legacy handshake support where needed.
- Converts hook calls to one stateless request and adds protocol compatibility tests.
| File | Description |
|---|---|
crates/memory/tests/hosted.rs |
Tests stateless calls and legacy handshake compatibility. |
crates/memory/src/tool_server.rs |
Adds discovery, request validation, and stateless results. |
crates/memory/src/mcp_meta.rs |
Defines shared protocol metadata, headers, and result checks. |
crates/memory/src/mcp_http.rs |
Sends hook calls as single stateless requests. |
crates/memory/src/main.rs |
Registers the shared MCP module. |
crates/memory/src/hosted.rs |
Adds protocol discovery, caching, and legacy fallback. |
crates/memory/Cargo.toml |
Adds the Base64 dependency for header encoding. |
Cargo.lock |
Records the binary’s Base64 dependency. |
Review effort: Balanced
| let listed = m.as_ref().map(|m| &m["result"]["supportedVersions"]); | ||
| match listed { | ||
| Some(v) if (200..300).contains(&status) && v.is_array() => return named(v, true), |
There was a problem hiding this comment.
Fixed in f8c0f29. Discovery now runs mcp_meta::complete on a result that lists supportedVersions. A result of another kind is an error, and the client does not cache it. The test a_discovery_result_of_a_kind_not_taken_is_refused_and_not_kept failed on the old code, because the call succeeded.
Posted by Claude on behalf of @sstriker
| } | ||
| .inspect_err(|_| learn(&url, None))?; | ||
| checked( | ||
| &memory, | ||
| tool, | ||
| reply(&message(&sent.content_type, &sent.body)?)?, | ||
| ) |
There was a problem hiding this comment.
Fixed in f8c0f29. A 200 reply now clears the cached revision in these cases:
- the reply does not parse;
- it has no result and no error;
- its result is not an object, or has an unsupported
resultType; - its error has no code, or the code is -32700, -32600, -32601 or a 2026-07-28 error.
A tool error (isError, -32602, -32603, or a server code) keeps the cache, because it concerns the call, not the revision. The test a_reply_not_of_the_revision_kept_asks_again_and_a_refused_call_does_not covers each case. A later commit adds the -32700 and -32600 cases.
Posted by Claude on behalf of @sstriker
| /// A client of the handshake may leave them out. | ||
| fn mirrored(method: &str, params: &Value, headers: &HeaderMap) -> Result<(), Refusal> { | ||
| if let Some(m) = header(headers, METHOD_HEADER)? { | ||
| if m != method { |
There was a problem hiding this comment.
The server is correct here, and our client had the fault. The 2026-07-28 transport allows the Base64 sentinel only for Mcp-Name and Mcp-Param-{Name}: "For headers that permit the Base64 sentinel encoding (Mcp-Name and Mcp-Param-{Name}), servers MUST decode encoded values". So a server compares Mcp-Method as sent. Our client encoded Mcp-Method, and f8c0f29 stops that. The test a_method_header_is_compared_as_sent_and_never_decoded keeps the server rule. The test the_method_header_carries_the_method_as_it_is covers the client. A later commit makes the hook client refuse a header value that has a line break.
Posted by Claude on behalf of @sstriker
…cp-Method as is Discovery now refuses a result that is not complete. Such a result does not go into the cache. A call now clears the cached revision when its reply does not parse, has an unsupported resultType, or has a protocol error. A tool error, an unknown tool or a server error keeps the cache. The client now sends Mcp-Method without base64. MCP 2026-07-28 permits the sentinel only in Mcp-Name and Mcp-Param headers. The server keeps the comparison of Mcp-Method as sent, and a new test holds this. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Bhj4Ck4y5iwZUsjpgQx8A2
A 200 reply with error -32700 or -32600 clears the cached revision, but no test showed it. Two cases now do. The hook client also refuses a header value that has a line break or another control byte, because the request is written as raw text and the method header is no longer encoded. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Bhj4Ck4y5iwZUsjpgQx8A2
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Bhj4Ck4y5iwZUsjpgQx8A2

Summary
MCP 2026-07-28 removes the
initializehandshake and theMcp-Session-Idheader. Each request carries its protocol version and client capabilities in_meta. Servers must answerserver/discover, and each result carriesresultType.The memory server and the egress use rmcp 3.5.0, which already speaks 2026-07-28. Three parts of
crates/memoryspeak MCP without rmcp, and they spoke only 2025-06-18 with the handshake. This change moves them to 2026-07-28. Each part still works with clients and servers that use the handshake.mcp_meta.rs(new) holds what the three parts share: the versions, the_metaof a request, theMCP-Protocol-Version,Mcp-MethodandMcp-Nameheaders, and the check ofresultType. A result withoutresultTypeis complete. A result with a different value is an error.tool_server.rs(amem tool-server) answers both types of client._meta, orserver/discover, goes to the new path. On this path, the server refuses a request without a version (-32602), with a version that it does not speak (-32022, with the versions that it speaks), or with headers that are missing or do not agree with the body (-32020).resultType: "complete".server/discoverandtools/listalso givettlMs: 0andcacheScope: "private".initializestays. It now answers with the version that the client asks for if the server speaks it, else with 2025-11-25. Before, it gave back any version that the client asked for, also 2026-07-28.Mcp-MethodandMcp-Namewith the body when the client sends them, and refuses a difference (-32020)._meta, the -32022 answer lists only 2026-07-28, and says that the older versions useinitialize.mcp_http.rs, the hook client in the sandbox, sends onetools/callwith_metaand the headers. Before, it sent three requests. It talks only to the memory server of this project, so it does not ask for the version first.hosted.rs, the client for tool servers in other places, first sendsserver/discover.tools/call.initializeas before. It also usesinitializeafter a 400, 404, 405, 415 or 422 without a 2026-07-28 error, or after a -32601 answer. An older rmcp server answers 422 or 415 to a request without a session.initialize, the client now sends theMCP-Protocol-Versionheader. The 2025-06-18 version of the spec requires it, and the old code did not send it.Choices where the spec gives options
Mcp-MethodorMcp-Nameis refused. The spec lists a missing required header as a failure. On the old path, a missing header is accepted, because older clients do not send these headers.hosted.rssendsserver/discoverfirst. It does not trytools/callfirst. A server that does not check sessions could run a new call under its old rules.Tests
tool_server::tests::discovery_names_every_version_spoken_and_the_toolsmainNull, right"complete"tool_server::tests::a_stateless_request_is_served_and_its_result_says_it_is_completemainNull, right"complete"tool_server::tests::an_unsupported_version_is_refused_naming_the_supported_onesmain200, right400tool_server::tests::headers_missing_or_at_odds_with_the_body_are_refusedmain200, right400tool_server::tests::a_client_that_opens_with_initialize_is_answered_as_beforemain"2024-11-05", right"2025-11-25"hosted::tests::a_reply_is_read_from_json_or_an_event_stream, theinput_requiredcasemainunwrap_err()on anOkvaluemcp_http::tests, theresultTypeassertionmainNull, right"complete"tests/hosted.rsa_server_of_the_stateless_revision_is_called_without_a_handshakemaintests/hosted.rsa_server_from_before_the_stateless_revision_is_called_after_the_handshakehosted::tests::what_a_server_speaks_is_read_from_its_answer_to_discoveryOk(Stateless), rightOk(Handshake)a_server_from_before_…_after_the_handshakeMCP-Protocol-Versionafterinitializemcp_httpdiscovery check400, right200, -32020a_name_header_at_odds_with_the_body_is_refused_after_a_handshake_too200, right400, and thefetchtool ran under the namesearchan_answer_to_discovery_that_names_no_revision_is_an_error_and_not_keptinitializewhat_a_server_speaks_is_asked_once_and_again_after_a_call_fails2, right11, right2a_body_that_is_not_one_request_is_refused_as_invalid202, right400an_unsupported_version_is_refused_naming_the_supported_ones, a_metawith 2025-06-18supportedlisted all four versionsThe unit tests of
mcp_metaare new, and no run showed them failing.The tests that use
initialize2025-06-18 still pass:tests/hosted.rs,curation.rs,launch.rsand the egress tests.Checks
make checkpasses: 752 tests.Open
scripts/conformance.py, its fake server, andscripts/demo/mcp.pystill useinitialize. rmcp keeps the old path, so they still work.PLAN.md:48says thatamem serveanswersinitialize. This is a note of an earlier state.Adversarial pass
A fresh agent attacked the first commit. It found:
Mcp-Namedid not agree with the body still ran.server/discoverthat it could not read, such as a 502 page, as an older server, and kept that answer.The last commit corrects these. No pass ran after it.
Two findings are not changed. They were in the code before this change:
idfrom an event stream, and does not compare theidwith the request.x-mcp-header, which 2026-07-28 requires of a client. A tool server that uses it refuses each call with -32020.Second review round
Copilot found three problems, and f8c0f29 answers them. Two were real. The third named the server, but the fault was in our client, which encoded
Mcp-Method. A separate agent reviewed the full branch, because Copilot had run out of quota. Its verdict was merge. c261007 closes the two gaps that it found:Each new test failed on a version of the code without its fix.
make checkpasses on the branch withmainmerged: 757 tests.Posted by Claude on behalf of @sstriker