- Rename conformance-baseline.yml to conformance-baseline-2025-11-25.yml
so both baseline files carry their spec revision symmetrically, and
bring conformance-server/conformance-client up to the same rigor
(--suite all, pinned CONFORMANCE, baseline check) as the draft targets
- score.php now takes a required spec-version and writes
<suite>-conformance-<spec-version>.json, so 2025-11-25 and 2026-07-28
each get their own badge instead of only the dated one
- conformance-weekly.yaml generates and publishes badges for both
matrix entries instead of gating on 2025-11-25 only, and drops the
now-stale unversioned badge files from the badges branch
- README shows all four badges alongside the existing packagist/CI/PHP
version/license row
- Trimmed workflow comments to the minimum needed
* [CI] Matrix the conformance jobs over both spec revisions
Runs server/client conformance against both 2025-11-25 and 2026-07-28,
each against its own endpoint and baseline. PR pipeline pins an exact
conformance package version; conformance-weekly tracks the moving
target on both dist-tags.
* [CI] Run the draft conformance suite from the alpha package
The conformance repo now ships 2026-07-28 scenarios on its alpha
dist-tag, so the draft targets no longer need a local checkout.
Pinned to the same version the pipeline runs.
* [CI] Baseline the failures --suite all newly exposes
The old workflow ran without --suite all/--spec-version, so several
pre-existing gaps never surfaced in CI. Now that both revisions run
the full suite:
- server: json-schema-2020-12 and resources-templates-read are
fixture bugs shared by both revisions; 2026-07-28 also needs
resources/subscribe and resources/unsubscribe declared removed
under the stateless wire, which the server doesn't yet do
- client: the example client doesn't call add_numbers or echo back
a tool's inputSchema; against 2026-07-28 it doesn't speak the
lifecycle or OAuth at all yet, so every such scenario fails
Verified locally: all four conformance jobs (server/client x
2025-11-25/2026-07-28) pass against these baselines.
tools/call answered with one JSON object, so a handler's progress and log
notifications had nowhere to go — and reached for a fiber that was not
there, coming back as -32603. Handlers now run in a fiber and the stream
opens after the first suspension, late enough that an error still gets the
status the spec fixes for it. logLevel finally gates the log messages it
was already being parsed for, and a server-initiated request is refused by
name rather than as a FiberError.
* [Server] Let the client gateway see modern-era capabilities
The six supports*() probes read session state only InitializeHandler wrote,
so every request served statelessly reported no client capabilities at all —
a tool guarding on them took the unsupported branch without a signal.
StatelessProtocol now writes the request's declaration under the same keys.
* [Server] Reject the RPCs 2026-07-28 removed
resources/subscribe and resources/unsubscribe were still dispatched and
answered 200 OK, recording subscriptions the modern era never reads. They
join initialize, ping and logging/setLevel in the era guard; the handlers
stay registered because the handshake era still serves them.
* [Server] Decode a wrapped Mcp-Name before comparing it
The Base64 sentinel was only unwrapped for Mcp-Param-*, so any resource URI
or tool name outside the header-safe ASCII set — which the spec explicitly
tells clients to wrap — was compared encoded and refused with -32020.
* [Server] Require the MCP-Protocol-Version header
It was only checked for contradicting the body, so omitting it entirely
passed — leaving the value intermediaries route on unenforced. Tied to the
header validator's presence, since that is what a header-bearing transport
installs and stdio carries its metadata inline.
* [Server] Read the modern-era request body whole
A single read() takes whatever the stream cares to give — 64 bytes from a
chunked transfer is legal — so an oversized-but-valid POST came back as a
parse error. Shares the legacy transport's incremental read as a trait.
* [Schema][Server] Answer a notification with a status, not a response
A body with no id was dispatched as a request and came back as a JSON-RPC
error carrying "id": "" — an id nobody issued, and on a path a client uses
to tell a modern server from a legacy one. Notifications now get 202 and no
body, and Error omits an id it could not read instead of emptying it.
* [Server] Stop emitting the error codes 2026-07-28 reserved
resources/read answered -32002, which this revision forbids; prompts/get
and completion/complete answered it for an unknown *name*, which it never
meant; tools/call answered -32601, which belongs to an unknown method.
Only the resources/read code is revision-gated — older peers expect -32002
there, and nowhere else. Also routes the -32021 capability exception out of
the prompt and resource handlers, which were swallowing it into -32603.
The revision has no way for a server to put a request on a response stream,
so a handler that needs input from the user says so in its result instead: a
handler returning InputRequiredResult comes back as
resultType: "input_required" with the asks and an opaque requestState, and
the client retries the same request carrying inputResponses.
RequestContext::getInputContext() is what the handler reads them through.
RequestStateCodec signs and time-bounds the state, so a tampered or expired
one is refused rather than trusted - the state travels through the client,
which is not a place to keep something the server later believes.
The revision puts the method, the subject name and the mirrored arguments in
headers so an intermediary can route and cache without parsing the body. A
header that contradicts the body it travels with therefore has to be
refused, not ignored: StandardHeaderValidator compares each against the
request and answers -32020 on a mismatch.
Set with Builder::setHeaderValidator(); a transport with no header layer is
unaffected.
* [Server][Tests] Serve the modern (SEP-2575) lifecycle
The revision removed the initialize handshake: every request carries its own
_meta - protocol version, client capabilities, client info - and answers
server/discover for what initialize used to return. There is no session to
keep, so there is nothing for the handshake-era dispatcher to do.
Builder::buildStateless() produces StatelessProtocol, and
StatelessHttpTransport serves it over one POST-only endpoint - GET and
DELETE are 405, and no Mcp-Session-Id exists to hand out.
Results go through a per-era wire codec rather than the handlers, so
resultType and the caching hints are stamped for 2026-07-28 and left off the
revisions that do not define them.
Covered by the modern conformance suite (make conformance-draft-server)
against its own baseline; unit coverage arrives with the fixes stacked on
this.
* Apply suggestions from code review
Co-authored-by: Christopher Hertel <mail@christopher-hertel.de>
* Fix critical stateless-lifecycle bugs from review
- Don't leak internal exception messages to clients on uncaught
\Throwable; use a generic message like the handshake-era protocol
and every built-in handler already do.
- Default a missing/invalid request id to null instead of "", and
stop answering notifications and malformed messages as if they
were "method not found" 404s.
- Read the POST body in a loop instead of a single read() call,
which PSR-7 allows to return short.