Skip to content

2026 07 28 - #2

Open
chr-hertel wants to merge 11 commits into
feature/schema-2026-07-28-driftfrom
feature/2026-07-28
Open

2026 07 28#2
chr-hertel wants to merge 11 commits into
feature/schema-2026-07-28-driftfrom
feature/2026-07-28

Conversation

@chr-hertel

Copy link
Copy Markdown
Owner

No description provided.

Comment on lines +5 to +6
# Overridable so the fixture can coexist with whatever else a developer
# already has on 8000; CI and the Makefile default keep using 8000.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

no comment needed

Comment thread src/Client.php
Comment on lines +115 to +121
/**
* Protocol revision negotiated during the handshake.
*
* This is the version the server answered with, which is not necessarily the
* one configured on the builder: a server that cannot speak the requested
* revision counter-offers one it supports. Null until the handshake completed.
*/

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

no comment needed

Comment thread tests/Conformance/Fixtures/nginx.conf Outdated
Comment on lines +6 to +9
# The two lifecycles are served side by side so one conformance run can
# target either without restarting the fixture: / speaks the handshake era
# (2025-11-25 and earlier), /stateless speaks the modern era (SEP-2575).
# Declared before `location /` so the prefix match wins.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

no comment needed

chr-hertel and others added 11 commits August 15, 2026 04:24
Ports the type definitions the 2026-07-28 revision introduces outside of
sampling tool use, which modelcontextprotocol#409 and modelcontextprotocol#420 already cover. Every addition is
optional and defaults to current behaviour, so a connection negotiated on an
older revision is unaffected.

Elicitation gains modes. ElicitationMode splits `form` — build a form from the
requested schema — from `url`, which sends the user out of band and returns
only the accept/decline/cancel outcome. That is why requestedSchema becomes
optional and `url` appears beside it. ClientCapabilities learns the matching
sub-capabilities, where an `elicitation` naming no mode declares form, the
only shape that existed before url mode.

Schemas loosen where the revision loosens them: Tool::outputSchema may
describe any JSON value rather than only an object, and
CallToolResult::structuredContent follows.

Adds the three error codes the revision defines (-32020 header mismatch,
-32021 missing required client capability, -32022 unsupported protocol
version) and switches ProtocolVersionMiddleware to the last of them, so a
rejected version carries the supported set as structured data the client can
retry from rather than only as prose.

Icon gains `theme`, Implementation gains `title`.
`Implementation::title` reached the typed constructor unchecked, so malformed
wire data raised a TypeError instead of InvalidArgumentException.

`ToolUseContent::input` accepted a list and serialized it as a JSON array,
where the protocol requires an object. The empty array stays exempt: it is
also an empty map and still emits `{}`.

`ToolChoice` and `ElicitRequest` read their mode with isset(), which is false
for an explicit null, so `{"mode": null}` silently became the default instead
of being rejected. Both use array_key_exists() now, letting the existing type
check refuse null.
…ders

`Implementation::title` could be parsed but never sent: neither
`Client\Builder::setClientInfo()` nor `Server\Builder::setServerInfo()`
accepted one, so every SDK user emitted null.

Both gain a trailing optional `$title`. On the server it sits where the
Implementation constructor already puts it, so existing positional calls keep
their meaning; the client builder forwards it by name, leaving the icons and
websiteUrl slots defaulted.
The object-only hydration guard was never that: `!is_array()` admitted
`[1, 2, 3]` and `[]`, which serialize to JSON arrays, while rejecting the
scalars 2026-07-28 permits. The truthiness emission gate was backwards in the
same way — it dropped `[]`, `0`, `false` and `""`, yet emitted lists, strings
and an empty stdClass.

Hydration now accepts any JSON value, and `null` alone means absent, matching
`ToolResultContent` which already carries this field that way. Which values a
given revision permits is a question for version-aware serialization, which
results cannot answer yet.
`ElicitRequest::forUrl()` built a request no SDK user could send: the only
public gateway method always constructed form mode from an ElicitationSchema,
and `request()` is private.

`elicitUrl()` joins `elicit()`, and both funnel through one send path that
hydrates the result with the request's own mode. Without that, a url-mode
accept — contentless by design — threw, because ElicitResult requires content
whenever the action is accept. The result carries no discriminator of its own,
so the mode has to come from the request it answers.

`supportsElicitationUrl()` reports whether the client named the mode, reusing
the sub-capability reader the sampling checks already use.
2026-07-28 replaces the initialize handshake with a per-request model: no
session, no server-initiated requests, and `server/discover` in place of
negotiation. That is a different control flow rather than a variant of the
existing one, so the dispatcher is separate — the two eras share handlers,
not the path through them.

  - StatelessProtocol dispatches one self-describing request, giving each an
    ephemeral session so existing handlers keep working unchanged.
  - RequestMeta carries what the handshake used to establish once. The
    protocol version and client capabilities are required; clientInfo is a
    SHOULD and must never be a reason to refuse a request.
  - A header that contradicts the version in _meta is reported as a mismatch
    *before* the version is judged supported: the server has been told two
    different things and cannot know which was meant, so pointing the client
    at renegotiation would send it after the wrong problem.
  - subscriptions/listen acknowledges, tags every frame with the listen
    request's own JSON-RPC id, and honours the notification filter. The
    stream ticks while it waits, because PHP only learns the peer hung up by
    writing to it and a purely sleeping loop pins an FPM worker for the full
    lifetime after the client has gone.
  - InputRequiredResult lets tools/call answer with the input it still needs,
    which is how a modern server elicits at all. Only the ask is implemented;
    consuming inputResponses and verifying requestState is not.

CallToolHandler no longer folds a missing client capability into a generic
tool error: that would report a call that ran and failed, when the client
needs to know to retry declaring the capability. Response's template becomes
covariant so a handler can declare the union of results it may answer with.

The conformance fixture serves both eras side by side (/ and /stateless) so a
difference in results points at the lifecycle and not at drifted fixtures.
server-stateless passes in full; everything still outstanding is recorded
per-check in conformance-baseline-2026-07-28.yml with its cause.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-28 requires three things on results that no earlier revision has: the
`resultType` discriminator, the SEP-2549 caching hints, and a `_meta`
serverInfo identity. Adding them to the result classes is the obvious move and
the wrong one — those classes serve every revision, so a server negotiating
2025-11-25 would start emitting vocabulary its clients have never seen. The
spec permits that (an absent `resultType` MUST read as `complete`, and the
older schemas accept unknown keys), but permission is not a reason to change
what already-deployed clients receive.

So the result model stays revision-neutral and the fields are stamped on the
way out, by a codec chosen from the negotiated version — the same split the
TypeScript SDK draws between its neutral types and its per-revision wire
codecs. Rev2025Codec is the identity transform, which is the substantive
claim: there is no path by which modern vocabulary reaches an older peer.

Rev2026Codec applies the three steps in a fixed order. The stamp runs before
the cache fill so a result still asking for input never acquires a TTL for
content it has not produced, and the fill resolves most-specific-author-first
— a valid authored value, then configured policy, then "private, do not
cache". An authored value that is not valid is dropped rather than repaired:
it cannot go on the wire, and the next author down already has an answer.

Discovery gives up its serverInfo parameter in the process. Identity is a
per-response requirement, not something discovery in particular answers, so
the codec stamps it on every result and DiscoverResult models only what
discovery actually reports.

Draft conformance goes from 70/52 to 96 passed / 26 failed: every
wire-schema-valid failure bar one clears, and the eight SEP-2549 checks pass.
The dated lifecycle stays at 71/1 and all 90 inspector snapshots are
byte-identical — which is the result worth checking, since it is the whole
reason for the indirection.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The Mcp-Method / Mcp-Name / Mcp-Param-* headers exist so an intermediary —
a gateway, a proxy, a rate limiter — can route and police MCP traffic without
parsing the body. That only holds if the two cannot disagree, so a server that
does read the body is required to confirm they match. Without the check a
caller can show the gateway one request and the server another, which is the
whole attack the headers were meant to close off.

Validation runs after version negotiation and before dispatch. The order is
deliberate: a peer on an unsupported revision has a more fundamental problem
than a header that disagrees with its body, and reporting the headers first
would send it off fixing the wrong thing.

Details worth naming:

  - Mcp-Name means a different params member per method (name, uri, taskId),
    so the mapping is a table rather than a field lookup. A method absent from
    it carries no name and is exempt — the header has nothing to agree with.
  - Base64 unwrapping is strict, and re-encodes to confirm. PHP's decoder
    accepts mispadded and out-of-alphabet input and returns plausible bytes,
    which would turn a corrupted header into a silent mismatch, or worse a
    silent match.
  - Only Mcp-Param headers the tool itself declares are checked. An
    unrecognized one belongs to somebody else in the chain — intermediaries
    forward what they do not understand — so rejecting it would break traffic
    this server has no stake in.
  - Values are trimmed before comparison (RFC 9110 §5.5) and header names
    compare case-insensitively.

Applied only on the modern era, where SEP-2243 lives: the handshake path is
untouched and its wire behaviour is unchanged.

Draft conformance goes from 96/26 to 115 passed / 16 failed — both header
scenarios now pass outright (14/14 and 10/10) rather than reporting their
requirements as untestable. The dated lifecycle stays at 71/1.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The ask half already worked: a handler could return an InputRequiredResult and
the client would see it. What was missing was everything after — reading the
answers back, trusting the state that came with them, and resuming.

Resuming is the whole difficulty. There is no session, so the server that
serves the retry may not be the one that asked, and its only memory is a blob
it handed to the client. That blob comes back attacker-controlled, so
RequestStateCodec signs it (HMAC-SHA256, base64url, stamped expiry) and
verify() refuses anything that does not check out: wrong MAC, expired, or
malformed. The MAC comparison is constant-time so it cannot be turned into an
oracle, and failures report a bare category — 'mac', 'expired', 'malformed' —
because a message distinguishing "signed by another key" from "signed for
another request" tells a forger which half to fix.

Signed, not encrypted. The payload is tamper-evident, not private, and the
docs say so where a caller will read them before putting a secret in it.

Handlers reach the material through RequestContext: getInputContext() is null
on a first call and populated on a retry, which is the only thing telling the
two rounds apart. mintRequestState() seals context on the way out.
getClientCapabilities() exposes what the request declared, because a server
MUST NOT ask for input the client cannot supply — an elicitation request to a
client without the capability is a prompt nobody can answer.

Two smaller things the conformance run turned up:

  - The encode seam no longer round-trips results through associative arrays.
    That flattening is lossy where it matters: an empty object becomes an
    empty array, and `params: {}` reached the wire as `params: []`, which is a
    different thing to a JSON Schema. The codec only touches top-level
    members, so the result's own serialization is now passed straight through.
  - StatelessHttpTransport gained the middleware stack. CORS and DNS-rebinding
    protection are browser-facing concerns with no bearing on which era is in
    play, and the handshake transport has had them by default all along; the
    protocol-version middleware is deliberately not included, since in this era
    the version travels in _meta and is checked against the header there.

Draft conformance goes from 115/16 to 138 passed / 3 failed, with every
SEP-2322 check green. The dated lifecycle stays at 71/1.

One honest gap remains, and it is baselined as such rather than as a
pre-existing failure: tools-call-with-progress passes at / and fails at
/stateless, because a tools/call answer here is a single JSON response and
notifications emitted mid-call have nowhere to go. Closing it means answering
tools/call over a stream — the same seam IncompleteResult chunks will want.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The 2026-07-28 work was commented far more heavily than the surrounding code —
Protocol.php carries two inline comments across 618 lines, where the new
dispatcher had thirty-nine. Class docblocks shrink to what they are elsewhere:
a line or two saying what the type is for.

What stays is the reasoning that is not recoverable from the code: why the
header check runs after the version check, why the subscription loop yields
instead of sleeping, why Base64 decoding re-encodes to verify, why an empty
PHP array cannot be sent where a JSON object is expected. What goes is
narration of what the next line already says.

No behaviour change: 1046 tests pass, phpstan and cs clean, draft conformance
still 138/3 and the dated lifecycle 71/1.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@chr-hertel
chr-hertel changed the base branch from main to feature/schema-2026-07-28-drift August 15, 2026 02:46
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