Skip to content

Adopt MCP 2026-07-28 in production and pin untested invariants - #66

Merged
blisspixel merged 1 commit into
mainfrom
mcp/adopt-v2-and-pin-invariants
Jul 31, 2026
Merged

Adopt MCP 2026-07-28 in production and pin untested invariants#66
blisspixel merged 1 commit into
mainfrom
mcp/adopt-v2-and-pin-invariants

Conversation

@blisspixel

Copy link
Copy Markdown
Owner

Production now serves the MCP 2026-07-28 revision, and a sweep pins eight rules that were enforced in code but named by no test.

MCP v2 adoption

Moving the pin is what makes recon reachable from a modern-only client at all: the spec's compatibility matrix says modern client plus legacy server fails, and server/discover, per-request _meta, resultType, and cache hints are all SDK-provided.

A v2 server still answers the legacy initialize handshake, so clients on 2025-11-25 keep working. recon's own gate proves that rather than trusting release notes. 1.28.1 stays the rollback pin and stays blocking, so both generations keep being exercised:

SDK Result
2.0.0 24/24 checks pass
1.28.1 17 pass, 7 correctly not-applicable

Clearing the blocker: the remote adapter

build_remote_application refused any family but v1, so the previous review stopped here rather than disable a shipped feature. Porting it surfaced a genuine latent bug:

  • The read-only allow-list read annotations.readOnlyHint. That attribute does not exist on v2 (it is read_only_hint, with the camelCase form surviving only as a serialization alias). Every tool would have looked non-read-only, so the adapter would have removed all of them and served an empty surface rather than a restricted one.
  • Transport options moved from a mutable settings object to keyword arguments on streamable_http_app.

Verified against the real v2 SDK: 22 tools filter to the same 17 read-only tools as before.

The disjoint-spelling problem

This recurs across the whole SDK boundary: outputSchema, inputSchema, mimeType, and the CallToolResult shape (v1 returns a tuple, v2 a model, so unpacking raises). Each is resolved once in sdk_compat and read through the protocol's own spelling.

Evidence that this is right: the generated surface inventory is byte-identical across generations.

Note for anyone touching tool annotations: passing the v2 spelling to v1 does not raise. Pydantic stores it as an unrelated extra attribute and leaves the real hint unset, so a mechanical rename would silently drop every annotation on the rollback pin.

Eight invariants pinned

The shareable_scope defect happened because grep found nothing. Each of these was confirmed by mutation (delete the guard, watch nothing fail) and each new test was verified to fail when the guard is removed:

  1. A Google CSE probe failure stays an unavailable channel, never "no CSE configured".
  2. A degraded email channel cannot become a weak-email-security posture claim.
  3. The same rule on the signal path, a separate copy that could drift.
  4. Parent-platform presence does not become a child-product use claim via a TXT token.
  5. A dormant provider slug alone does not produce an email security score (it rendered "Email security 0/5", which reads as badly secured rather than not measured).
  6. chain_resolve clamps depth (the existing test held vacuously: its mock returned no related domains).
  7. The MCP chain depth cap was a literal 3, not the shared MAX_CHAIN_DEPTH so the two copies could drift silently. Depth also scales the aggregate timeout.
  8. The result-cache poison table asserted nothing for its entire life. All twelve rows wrote a payload for one domain into another domain's cache file, so the read was rejected on the domain binding before reaching the field under test. Re-keying made all twelve real and immediately caught a range check that had never been exercised. A control row now fails if the table goes vacuous again.

One candidate was rejected after mutation: the per-channel cached-replay guard is pinned, just from a different test file than you would expect.

scripts/check.py passes green locally, as do both compatibility matrices and release_readiness.py.

Production now runs the v2 SDK, so recon serves the 2026-07-28 revision. That
is what makes it reachable from a modern-only client at all: the spec's
compatibility matrix says modern client plus legacy server fails. A v2 server
still answers the legacy initialize handshake, so clients on 2025-11-25 keep
working, and recon's own gate proves that rather than trusting it. 1.28.1 stays
the rollback pin and stays blocking, so both generations remain exercised: 24 of
24 checks pass on 2.0.0, 17 pass with 7 not-applicable on 1.28.1.

Clearing the blocker meant porting the optional remote adapter, which refused
any family but v1. Two differences moved into sdk_compat. The read-only
allow-list read `annotations.readOnlyHint`, which is not an attribute on v2, so
every tool would have looked non-read-only and the remote surface would have
come up empty rather than restricted. And the transport options moved from a
mutable settings object to keyword arguments on streamable_http_app.

That disjoint-spelling problem recurs across the SDK boundary: outputSchema,
inputSchema, mimeType, and the CallToolResult shape all differ. Each is resolved
once in sdk_compat and read through the protocol's own spelling, which is why
the generated surface inventory is byte-identical across generations.

Separately, a sweep for rules enforced in code that no test named. The
shareable_scope defect happened because grep found nothing; eight more of that
shape are now pinned, each verified to fail when the guard is removed. Two are
worth calling out. The MCP chain depth cap was a literal 3 rather than the
shared constant, so the two copies could drift apart silently. And the
result-cache poison table asserted nothing at all: every one of its twelve rows
wrote a payload for one domain into another domain's cache file, so the read was
rejected on the domain binding before it ever reached the field under test.
Re-keying the rows made all twelve real and immediately caught a range check
that had never been exercised.
@blisspixel
blisspixel merged commit 312060f into main Jul 31, 2026
27 checks passed
@blisspixel
blisspixel deleted the mcp/adopt-v2-and-pin-invariants branch July 31, 2026 18:13
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.

1 participant