Commit graph php-sdk/src/Server/Stateless/StatelessProtocol.php
Author SHA1 Message Date
Christopher Hertel
daac88d626
[Server] Make subscriptions/listen actually deliver (#451)
The stream acknowledged, slept thirty seconds and closed, so tools and
resource changes never reached a client — and with resources/subscribe
gone, that left the revision with no server-push at all. A notification bus
carries them: in-memory where publisher and stream share a process, PSR-16
where php-fpm puts them in different ones. The conformance server proves
the latter — a tools/call in one worker reaches a stream held in another.
2026-08-19 00:08:02 +02:00
Christopher Hertel
7dd72d82de
[Server] Let a server say how long its answers stay fresh (#450)
Every cacheable result went out as ttlMs 0, private — the letter of
SEP-2549 and none of its point, with no way to change it. CachePolicy adds
a default and per-method overrides; a resource can override per read. The
conservative default stands, because public means a shared cache may serve
one caller's answer to another.
2026-08-18 23:53:16 +02:00
Christopher Hertel
cad686aba7
[Server] Complete the multi round-trip request surface (SEP-2322) (#449)
* [Server] Let resources/read ask for input, and stop caching retries

The MRTR pattern names three methods and resources/read was the one whose
handler dropped an InputRequiredResult into the resource formatter. A
retry's answer also carried ttlMs and cacheScope, which the caching rules
forbid: its inputs are not part of any cache key.

* [Server] Refuse an ask the client cannot answer

A handler could put an elicitation into inputRequests for a client that
never declared one, and the only symptom was a retry that never came.
Checked against the request's own capabilities now, and reported as -32021
with the missing set — which is the code the spec defines for exactly this.

* [Server] Hand multi round-trip answers back typed

response() returned whatever JSON arrived, so every handler re-implemented
the same array poking. A malformed answer now reads as absent, which sends
the handler back to asking rather than to failing.

* [Server] Gate streamed input_required asks by capability too

checkInputRequests() only ran on the non-streaming path, so a handler
that emits a notification before returning InputRequiredResult could
still leak an undeclared-capability ask under HTTP 200.
2026-08-18 23:41:00 +02:00
Christopher Hertel
dd3783a720
[Server] Stream a modern-era response when there is something to stream (#448)
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.
2026-08-18 23:22:45 +02:00
Christopher Hertel
5e73683079
[Server] Close the conformance gaps in the modern lifecycle (#447)
* [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.
2026-08-18 23:09:34 +02:00
Christopher Hertel
ef0c1c2808
[Server][Tests] Complete the multi round-trip request flow (SEP-2322) (#446)
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.
2026-08-18 22:45:35 +02:00
Christopher Hertel
de35ca40bf
[Server][Tests] Validate the standard MCP request headers (SEP-2243) (#445)
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.
2026-08-18 22:27:05 +02:00
Christopher Hertel
3d596ca75b
[Server][Tests] Serve the modern (SEP-2575) lifecycle (#444)
* [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.
2026-08-18 22:08:54 +02:00