Skip to content

feat: speak MCP 2026-07-28 in the hand-written MCP code - #51

Merged
sstriker merged 6 commits into
mainfrom
feat/mcp-2026-07-28
Nov 2, 2026
Merged

sstriker merged 6 commits into
mainfrom
feat/mcp-2026-07-28

Conversation

@sstriker

@sstriker sstriker commented Nov 1, 2026 •

Copy link
Copy Markdown
Contributor

Summary

MCP 2026-07-28 removes the initialize handshake and the Mcp-Session-Id header. Each request carries its protocol version and client capabilities in _meta. Servers must answer server/discover, and each result carries resultType.

The memory server and the egress use rmcp 3.5.0, which already speaks 2026-07-28. Three parts of crates/memory speak 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 _meta of a request, the MCP-Protocol-Version, Mcp-Method and Mcp-Name headers, and the check of resultType. A result without resultType is complete. A result with a different value is an error.
  • tool_server.rs (amem tool-server) answers both types of client.
    • A request that names a version in _meta, or server/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).
    • Each result on the new path has resultType: "complete". server/discover and tools/list also give ttlMs: 0 and cacheScope: "private".
    • initialize stays. 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.
    • On both paths, the server compares Mcp-Method and Mcp-Name with the body when the client sends them, and refuses a difference (-32020).
    • A body that is not one JSON object, such as a batch, gets HTTP 400 and -32600. Before, it got 202 and the server ignored it.
    • When a request names an older version in _meta, the -32022 answer lists only 2026-07-28, and says that the older versions use initialize.
  • mcp_http.rs, the hook client in the sandbox, sends one tools/call with _meta and 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 sends server/discover.
    • If the server lists 2026-07-28, the client sends one tools/call.
    • If the server lists only older versions, the client uses initialize as before. It also uses initialize after 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.
    • Any other answer, such as a 502, a 429, or a 401, is an error. The client does not keep it.
    • The client keeps the answer for each server URL until a call fails.
    • After initialize, the client now sends the MCP-Protocol-Version header. The 2025-06-18 version of the spec requires it, and the old code did not send it.

Choices where the spec gives options

  • On the new path, a request without Mcp-Method or Mcp-Name is 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.rs sends server/discover first. It does not try tools/call first. A server that does not check sessions could run a new call under its old rules.

Tests

Test Run against What it reported
tool_server::tests::discovery_names_every_version_spoken_and_the_tools main left Null, right "complete"
tool_server::tests::a_stateless_request_is_served_and_its_result_says_it_is_complete main left Null, right "complete"
tool_server::tests::an_unsupported_version_is_refused_naming_the_supported_ones main left 200, right 400
tool_server::tests::headers_missing_or_at_odds_with_the_body_are_refused main left 200, right 400
tool_server::tests::a_client_that_opens_with_initialize_is_answered_as_before main left "2024-11-05", right "2025-11-25"
hosted::tests::a_reply_is_read_from_json_or_an_event_stream, the input_required case main unwrap_err() on an Ok value
mcp_http::tests, the resultType assertion main left Null, right "complete"
tests/hosted.rs a_server_of_the_stateless_revision_is_called_without_a_handshake main the tool server answered 400: -32601, "no initialize here"
tests/hosted.rs a_server_from_before_the_stateless_revision_is_called_after_the_handshake a client that does not fall back the tool server answered 400: "no session"
hosted::tests::what_a_server_speaks_is_read_from_its_answer_to_discovery the same left Ok(Stateless), right Ok(Handshake)
a_server_from_before_…_after_the_handshake a client that does not send MCP-Protocol-Version after initialize the tool server answered 400
mcp_http discovery check a discovery request without the headers left 400, right 200, -32020
a_name_header_at_odds_with_the_body_is_refused_after_a_handshake_too 774bd66 left 200, right 400, and the fetch tool ran under the name search
an_answer_to_discovery_that_names_no_revision_is_an_error_and_not_kept 774bd66 a 502 page was taken as an older server, and the client sent initialize
what_a_server_speaks_is_asked_once_and_again_after_a_call_fails a client that asks before each call left 2, right 1
the same a client that keeps the answer after a failed call left 1, right 2
a_body_that_is_not_one_request_is_refused_as_invalid 774bd66 left 202, right 400
an_unsupported_version_is_refused_naming_the_supported_ones, a _meta with 2025-06-18 774bd66 supported listed all four versions

The unit tests of mcp_meta are new, and no run showed them failing.

The tests that use initialize 2025-06-18 still pass: tests/hosted.rs, curation.rs, launch.rs and the egress tests.

Checks

  • make check passes: 752 tests.

Open

Adversarial pass

A fresh agent attacked the first commit. It found:

  1. On the handshake path, a request whose Mcp-Name did not agree with the body still ran.
  2. The hosted client took any answer to server/discover that it could not read, such as a 502 page, as an older server, and kept that answer.
  3. No test held the per-URL answer that the client keeps.
  4. The tool server ignored a batch and answered 202.
  5. The -32022 answer listed a version that it had just refused.

The last commit corrects these. No pass ran after it.

Two findings are not changed. They were in the code before this change:

  • The hosted client takes the first message with an id from an event stream, and does not compare the id with the request.
  • The hosted client does not support 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:

  • Test cases now show that error codes -32700 and -32600 clear the cached revision.
  • The hook client refuses a header value that has a control byte. The method header is no longer encoded, and the request is written as raw text.

Each new test failed on a version of the code without its fix.

make check passes on the branch with main merged: 757 tests.

Posted by Claude on behalf of @sstriker

sstriker and others added 2 commits October 31, 2026 14:14
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
Copilot AI balanced review requested due to automatic review settings November 1, 2026 07:21

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 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

Comment thread crates/memory/src/hosted.rs Outdated
Comment on lines +226 to +228
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),

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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

Comment thread crates/memory/src/hosted.rs Outdated
Comment on lines 425 to 431
}
.inspect_err(|_| learn(&url, None))?;
checked(
&memory,
tool,
reply(&message(&sent.content_type, &sent.body)?)?,
)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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 {

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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

sstriker and others added 4 commits November 2, 2026 23:27
…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
@sstriker
sstriker merged commit 2e37061 into main Nov 2, 2026
10 checks passed
@sstriker
sstriker deleted the feat/mcp-2026-07-28 branch November 2, 2026 23:28
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