Commit graph

273 commits

Author SHA1 Message Date
Christopher Hertel
c36e805086
[Server] Restrict FileSessionStore::gc() to owned session files (#479) 2026-08-25 00:43:08 +02:00
Christopher Hertel
d59950f1ac
[Server][Client] Bridge W3C trace context between HTTP headers and _meta (SEP-414) (#473)
RequestMeta::parseTraceContext() now falls back to the native traceparent/
tracestate/baggage HTTP headers when _meta carries none, and HeaderFactory
mirrors an application-set _meta trace context onto those same headers on
outbound requests. _meta wins when both are present.
2026-08-24 01:26:22 +02:00
Christopher Hertel
1e9520e731
[Docs] Deprecation policy, SDK tier target, and CONTRIBUTING.md (#472)
* [Docs] Add deprecation policy doc (SEP-2596)

Formalizes the Active -> Deprecated -> Removed lifecycle and the
twelve-month deprecation window the SDK already applies via SEP-2577,
and states the SDK's own BC promise ahead of 1.0.

* [Docs] Add SDK Tier 2 gap analysis (SEP-1730)

* [Docs] Wire deprecation policy and SDK tier docs into the guide nav

Adds both pages under a new Project section in mkdocs.yml, links them
from index.md's "Where to go next", and points their repo-root
references at GitHub URLs instead of paths escaping docs/ (dead under
Zensical's --strict build).

* [Docs] Add CONTRIBUTING.md

Root-level contribution guide: dev setup, the make ci checks to run
before a PR, coding standards, testing expectations, the docs-guide
link-resolution rule Zensical enforces, and licensing terms. Linked
from README's existing Contributing section.

* [Docs] Trim deprecation policy per review

Drop the worked SEP-2577 example and the "relationship to the
protocol lifecycle" section; both were redundant restating of detail
that lives in the code/CHANGELOG and protocol-versions.md already.

* [Docs] Cite the Tier 3 audit and add a versioning policy (SEP-1730)

Replace our own hand-rolled conformance numbers with the authoritative
audit in modelcontextprotocol/modelcontextprotocol#3274: SDK is
currently Tier 3, blocked from Tier 2 by client OAuth conformance
(20%) and no stable 1.0.0+ release. Add the Versioning section the
audit found missing — SemVer commitment pre-1.0, Symfony's BC promise
after — and point CONTRIBUTING.md at it.

* [Docs] Link Symfony's BC promise from the deprecation policy

Was plain text; sdk-tier.md's Versioning section already links it.

* [Docs] Drop the "process gaps addressed" aside from sdk-tier.md

* Apply suggestion from @chr-hertel

* Apply suggestion from @chr-hertel
2026-08-24 01:23:52 +02:00
Christopher Hertel
d09fe3019e
[Server] Make resource subscribe/unsubscribe errors protocol-version-aware (SEP-2164) (#474)
* [Server] Make resource subscribe/unsubscribe errors protocol-version-aware (SEP-2164)

ReadResourceHandler already picked -32602 vs -32002 based on the
negotiated revision; ResourceSubscribeHandler and
ResourceUnsubscribeHandler still hard-coded the retired -32002 for
every client. Bring them in line.

* Remove unnecessary comments

Co-authored-by: Christopher Hertel <mail@christopher-hertel.de>
2026-08-24 01:17:09 +02:00
Christopher Hertel
32464ec32e
[Changelog] Restructure 0.8.0 section (#478) 2026-08-24 01:16:10 +02:00
Christopher Hertel
c882401ee7
[Server] Log failures in CompletionCompleteHandler (#471) 2026-08-22 13:08:17 +02:00
Ben Younes
b1c7272761
[Server] Lower expected tool failure logging (#433)
Expected tool failures are part of normal tool execution, so log them at debug severity while preserving error logging for unexpected exceptions.
2026-08-22 01:46:29 +02:00
Christopher Hertel
e0b9c1a26f
[Server] Serve ClientGateway::elicit() under the modern lifecycle (#466)
One handler asks the user something on any revision now. Where the client
can be asked mid-request it still is; where it cannot, the ask becomes the
input_required result that revision carries and the same call returns the
answer once the client re-sends it.

Costs one handler entry per ask, so side effects belong after the last
question. Answers from earlier rounds travel in the requestState, which is
why asking more than once needs Builder::setRequestState().

sample() and listRoots() still raise a LogicException there: that revision
removed them outright.
2026-08-20 01:25:59 +02:00
Christopher Hertel
0cd5d56164
[Docs] Render the guides with Zensical instead of phpDocumentor (#414)
* [Docs] Render guides with Zensical, deploy via GitHub Pages actions

phpDocumentor's guide renderer copies the markdown through mostly verbatim:
relative links between pages keep pointing at `*.md` targets that do not exist
in the built site, and nothing validates them, so the published guides are full
of dead links.

Zensical (the Material for MkDocs team's successor to MkDocs) resolves internal
links against the page tree and fails the build on a broken one. `zensical
build --strict` already found four dead links on the first run — three
repo-relative links escaping docs/ (fixed to point at GitHub) and one wrong
in-page anchor.

phpDocumentor stays on for the class-level API reference only; `make docs`
builds the guides into site/ and mounts the reference at site/api/, so its two
header links back to the guides now target the site root.

Deployment moves from pushing a gh-pages branch to the official GitHub Pages
actions, and from release-only to every push on main, with pull requests
building (but not deploying) so a broken link fails review instead of the site.

NOTE: this needs the repository's Pages source switched to "GitHub Actions"
(Settings -> Pages) once.

* [Docs] Adopt the Python SDK's documentation look and feel

The docs site now uses the same theme configuration as
https://py.sdk.modelcontextprotocol.io/ so the language SDKs read as one set
of docs: the MCP mark as logo and favicon, Inter/JetBrains Mono, the
black/slate palette with a three-way (system/light/dark) toggle, instant
navigation, code copy/annotate, and a right-hand table of contents.

The markdown extension set is widened to the same list (tabbed blocks,
task lists, footnotes, emoji/icons, mermaid fences), which the content
restructure builds on.

Styling is otherwise stock: no custom stylesheet, and Zensical's own
`modern` theme variant, pinned explicitly rather than left to the default.

The one departure is code highlighting, which is broken out of the box here:
Pygments only highlights PHP after a `<?php` tag, so the guides — whose code
blocks are almost all fragments — rendered as flat plain text. The `php`
lexer is extended with `startinline`, and complete-file blocks use a
`php-file` lexer that keeps the literal open tag highlighted.

* [Docs] Restructure the guides into task-oriented sections

The guides were ten flat pages, each opening with a hand-maintained table of
contents and each mixing several audiences: `mcp-elements.md` covered tools,
prompts, schema generation and handler-side logging, `transports.md` covered
both transports plus framework integration, and `server-builder.md` covered
configuration, sessions and custom message handlers.

They are now split along the same lines as the Python SDK's documentation
(https://py.sdk.modelcontextprotocol.io/), one topic per page:

  Get started        installation, first server, the Inspector
  Servers            tools, resources, resource templates, prompts,
                     completions, schema generation, registration
  Inside your handler  the ClientGateway, logging
  Running your server  builder, STDIO, HTTP, framework integration,
                     sessions, authorization
  Clients            connecting, transports, capabilities, server-initiated
                     requests, error handling
  Advanced           events, protocol extensions, custom message handlers

Prose is carried over as-is apart from the seams; what is new is the landing
page and one index page per section, which say what the section is for and
where to go next, so no page is a dead end.

The per-page "Table of Contents" lists are gone — the theme renders one from
the headings — and GitHub's `> [!IMPORTANT]` blockquotes became admonitions,
which Zensical renders as callouts rather than plain quotes.

Every code sample and factual claim was then checked against src/ and the
runnable examples, which turned up long-standing errors in the carried-over
prose. Samples that could not run: a prompt using a `system` role (MCP has
only user/assistant), `Mcp\Schema\PromptMessage` (it is under `Schema\Content`)
constructed with an array instead of a single content object,
`Mcp\Capability\Prompt\Completion\ProviderInterface` (it is
`Mcp\Capability\Completion\ProviderInterface`), `new EmbeddedResource(type:,
resource: [...])` (neither parameter exists), a `: resource` return type
(not a PHP type), `#[McpResource]` with a `{path}` variable (that is a
template), a stray quote in the builder example, a `middlewares:` argument
(it is `middleware:`), and `getRequest()->getAttribute()` in the OAuth guide
(no such method — the values arrive on the request meta). Claims corrected:
sampling's `system_prompt` option (it is `systemPrompt`), `SampleMessage`
(it is `SamplingMessage`), `Notification` called an interface (abstract
class), "parameter order matters" for URI templates (bound by name), full
RFC 6570 support (only simple `{var}`), the tool description fallback chain,
`void` returning empty content, a non-zero STDIO exit code, `ErrorEvent`
being null for parse errors, handlers being "prepended", `Psr16StoreSession`,
and PSR-3 log context being sent to the client (it is dropped). Sixteen
`examples/` paths were missing their `server/` segment.

README, the OAuth ADR and one source comment now point at the published site
instead of at markdown files that moved.

* [Docs] Run the docs workflow unfiltered, keep README links relative

The `paths:` filters were mostly noise: `src/**` had to be in the list for
the phpDocumentor build, which meant the workflow ran on nearly every PR
anyway. They also missed two real inputs — `.phpdoc/template/**` and
`composer.lock` — so a template tweak or a phpDocumentor bump could change
the rendered site without triggering a build.

README links go back to relative repo paths. The README is not part of the
Zensical site (`docs_dir` is `docs/`), so those links are only ever resolved
by GitHub and Packagist, where absolute URLs break in-repo navigation for no
gain. The site pointer and the generated API reference stay absolute.

* [Docs] Adopt the doc changes that landed on main into the new structure

Twelve commits landed while the restructure was open, seven of them touching
the flat guides this branch replaces. Renames merged on their own; the three
files that were split apart needed their new sections placed by hand:

- Connection retries, protocol version negotiation, sampling with tools and
  roots from `client.md` into `client/connecting.md` and
  `client/server-requests.md`
- `ResourceLink` and structured output from `mcp-elements.md` into
  `servers/tools.md`
- Protocol version negotiation from `server-builder.md` into
  `run/server-builder.md`

The `setMaxRetries()` note claiming the value is never acted on is gone —
#413 implemented it. Callouts became admonitions and cross-links were
repointed at the new paths, as everywhere else in this branch.

* [Docs] Document the elicitation and roots client examples

Both were missing from the examples guide: the elicitation client predates
this branch, the roots client arrived with #395.

* [Docs] Fold the 2026-07-28 lifecycle into the new structure

Main's stateless-lifecycle.md becomes a docs/lifecycle/ section, and the pages
main touched — deprecations, the middleware split, the client's modern era —
land where the restructure moved them.

* [Docs] Link the Inspector to its documentation, not its repo

* [Docs] Distribute the 2026-07-28 material into the task sections

The Python and TypeScript SDKs file each feature where the task lives and keep
only an era comparison on its own page. Follow them: input-required moves next
to the other handler concerns, caching, subscriptions and era routing to
"Running your server", and one Protocol versions page carries the rest.

* [Docs] Move protocol version negotiation onto the Protocol versions page

The builder page keeps the setProtocolVersion() knob and links out; how a
revision is agreed now sits beside the era it belongs to.

* [Docs] Slim the README to a funnel, turn Examples into an index

* [Docs] Fix the code samples and API listings flagged by the audit

* [Docs] Link the negotiation spec section at latest, not draft
2026-08-20 00:03:54 +02:00
Christopher Hertel
7fe88677ad
[Docs][Examples][CI] Document and gate the 2026-07-28 lifecycle (#457)
docs/stateless-lifecycle.md walks the revision end to end - per-request
_meta, server/discover, multi round-trip requests, caching and subscriptions
- and a runnable example on each side shows what that looks like in code.

CI matrixes both conformance roles over both revisions. --spec-version is
cumulative across the dated revisions, so 2025-11-25 covers the two before
it; 2026-07-28 is not cumulative with them and needs its own run against its
own baseline. The runner is pinned so a PR only goes red for reasons in the
PR, and conformance-weekly tracks the moving target instead - on both
revisions, since the draft scenarios ship only on the alpha dist-tag.

The Inspector cannot reach a modern-lifecycle server (it opens with
initialize), so the example is covered by integration tests instead: one
drives it with hand-built HTTP the way a conforming client would, one drives
it with this SDK's client, and one drives the single endpoint from both eras.
2026-08-19 03:24:17 +02:00
Christopher Hertel
8c06dcecc6
[Client] Speak the 2026-07-28 lifecycle (#456)
* [Client] Speak the 2026-07-28 lifecycle

On that revision the client opens with server/discover instead of
initialize, stamps each request's _meta with the protocol version, its own
capabilities and client info, and sends the standard Mcp-Method / Mcp-Name /
Mcp-Param-* headers an intermediary routes on. ToolCatalog is what knows,
from the tool list, which arguments a call has to mirror.

An input_required result is answered rather than surfaced: InputRequestResolver
asks the host's elicitation, sampling and roots handlers and retries the same
request carrying inputResponses and the requestState the server sent.

McpHeader holds the header names and the =?base64?…?= sentinel, so the two
sides no longer keep their own copies of the same rules.

* Fix CI baseline drift and address Copilot review findings

Drop stale expected-failure entries the new client fixture now passes.

Copilot review: fix McpHeader round-trip for a wrapper-shaped literal,
encode empty/numeric-keyed inputResponses as a JSON object, stop the
incident example repeating side effects on MRTR retry, and reset
ToolCatalog on reconnect instead of carrying the previous server's
verdicts.

* Skip DualEraExampleTestCase on PHP 8.1

PHP_CLI_SERVER_WORKERS does not reliably fork multiple php -S
workers there (php/php-src#9400), reproducing the single-worker
deadlock the extra workers exist to avoid.
2026-08-19 02:41:02 +02:00
Christopher Hertel
cbceef06eb
[Server] Serve both protocol eras from one endpoint (#455)
* [Server] Serve both protocol eras from one endpoint

The two lifecycles share a transport, not a dispatcher. StreamableHttpTransport
classifies each request - a 2026-07-28 envelope, an initialize handshake, or a
session-bound follow-up - through InboundClassifier and routes it to the
dispatcher that owns it, so one URL answers a modern client and a handshake-era
one alike. Builder::build() carries both; withoutModernEra() opts out and
setModernVersions() narrows what the modern leg answers for.

InputRequiredShim lets a handler written for multi round-trip requests also
serve a handshake-era client, by turning each ask into the request/response
exchange that era has - so a handler is written once rather than twice.

The conformance fixture collapses into one server for the same reason: both
legs now hit the same URL.

* Trim redundant comments, fix stale in-memory claim in conformance fixture

* Drop stale json-schema-2020-12 baseline entries

The new fixture tool makes this scenario pass in both eras now.

* Reject handshake revisions in setModernVersions(), warn on unsafe custom middleware

Copilot review: setModernVersions() accepted revisions InboundClassifier
would never route to the modern leg. StreamableHttpTransport now warns
when a custom middleware list carries ProtocolVersionMiddleware, since
it runs before era classification and rejects modern-era traffic by
default. Also fixed a misleading docblock claim on the same middleware.
2026-08-19 01:36:56 +02:00
Christopher Hertel
b97389325f
[Server] Serve an extension's methods under the modern lifecycle (#454)
* [Server] Serve an extension's methods under the modern lifecycle

The modern dispatcher takes the method-to-extension map the builder collects,
so a method belonging to an extension this server does not serve is answered
-32601 naming the extension instead of a bare "no handler found". It is still
an unknown method - the server genuinely does not implement it - but the
caller can now act on the answer.

* [Server] Reject two extensions claiming the same RPC method

The message factory resolves a contested method to whichever class
registered first, while extensionMethods kept the last one — so error
messages could name the wrong extension. Also fixes a test that
claimed to prove a method gets named by its extension while asserting
the opposite; the case it meant to cover (an enabled extension with no
handler for one of its methods) had no coverage at all.
2026-08-19 01:02:22 +02:00
Christopher Hertel
7e38c94954
[Server] Enforce x-mcp-header where a request uses it (SEP-2243) (#453)
* [Server] Enforce x-mcp-header where a request uses it

Only top-level properties were inspected, so an annotation on a nested
property was silently unenforced - the header could say anything. The walk
now follows a chain of properties keys to any depth and reads the argument
at that exact path.

Integer values compare numerically, so a client writing 42.0 for a body
value of 42 is no longer rejected.

* [Server] Stop comparing numeric-looking strings as numbers

is_numeric() and loose == both treat a string like "042" as equal to
"42", so a string argument could pass header validation against a
header that doesn't match it exactly. Gate the numeric comparison on
the argument's actual type and a decimal-only header instead.
2026-08-19 00:42:40 +02:00
Christopher Hertel
be9fad4115
Add badge linebreak and v1.0 roadmap items (#461) 2026-08-19 00:30:10 +02:00
Christopher Hertel
855011b8dc
[Server] Carry trace context through a request (#452)
traceparent, tracestate and baggage reach handlers through RequestContext
and are echoed onto the notifications the request causes, so a span is not
broken by the response stream. Passed through verbatim: what they mean is
the tracing library's business, and no dependency is taken for it.
2026-08-19 00:20:32 +02:00
Christopher Hertel
f6a995bc44
[Tests] Fix resources-templates-read to return a resolved URI (#460)
The fixture returned the literal template pattern test://template/{id}/data
instead of substituting id, failing the wire-schema uri format check. Also
switches the json-schema-2020-12 baseline entry to a bare scenario name: the
latest (0.1.x) conformance CLI doesn't parse scenario:check-id syntax, only
alpha (0.2.x) does, and the two need to agree since conformance-weekly runs
2025-11-25 against latest while pipeline.yaml pins alpha.

Verified locally against both dist-tags and both spec revisions.
2026-08-19 00:14:46 +02:00
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
78a8926f17
[CI] Version both baseline files and split conformance badges per spec (#459)
- 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
2026-08-18 23:42:36 +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
7ffc739fe7
[CI] Matrix conformance jobs over both spec revisions (#458)
* [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.
2026-08-18 23:30:57 +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
Christopher Hertel
60711951d4
[Schema][Server] Add the extensions framework SEP-2133 defines (#443)
* [Schema][Server] Add the extensions framework SEP-2133 defines

enableExtension() took any string and advertised it, with no way for an
extension to add a method: MessageFactory could not decode one, so nothing
downstream ever saw it.

Identifiers are now checked against the _meta key naming rules through
ExtensionIdentifier - a prefix is mandatory, since an unprefixed name has no
owner, and the modelcontextprotocol/mcp second labels the specification
reserves are recognised. An extension implementing
MethodProvidingExtensionInterface contributes both its message classes and
the handlers serving them.

MessageFactory::make() takes an $additional list of message classes, and
RequestHandlerInterface's result template is covariant so a handler
declaring a concrete result satisfies a collection typed by the interface.

* Fix stale @see reference in MethodProvidingExtensionInterface docblock

* [Schema][Server][Client] Make ExtensionIdentifier a value object

ExtensionInterface::getId() now returns ExtensionIdentifier, which
validates the SEP-2133 naming rules at construction instead of the
callers checking it. getMessages()/getRequestHandlers() move into
ExtensionInterface itself; AbstractExtension gives them empty
defaults for extensions that only announce a capability.
2026-08-18 20:47:27 +02:00
Christopher Hertel
5e3e69acc4
[Tests] Pin that protected resource metadata advertises no scope of its own (#440)
SEP-2207 says a resource server must not advertise `offline_access` as a
required scope. Nothing in src/ writes it - `scopesSupported` is entirely
operator-supplied - so there was nothing to fix, but also nothing stopping a
future default from quietly starting to advertise one.
2026-08-18 20:20:55 +02:00
Christopher Hertel
605df8771f
[Schema] Refuse an invalid x-mcp-header annotation at definition time (#441)
* [Schema] Refuse an invalid x-mcp-header annotation at definition time

SEP-2243 calls a tool definition invalid when its x-mcp-header annotation is
empty, is not an HTTP field name, collides case-insensitively with another,
or sits on a number, array or object property - none of which can be
mirrored onto a header.

Nothing said so. The earliest place to is where the tool is defined, rather
than on the request that trips over it.

* Clarify x-mcp-header validation only reaches properties

* Make checkHeaderAnnotations() a private instance method

* Make headerAnnotations() a private instance method

* Apply suggestion from @chr-hertel
2026-08-18 20:16:35 +02:00
Christopher Hertel
cf2c4d4ead
[Schema] Stop rejecting elicitation schemas the specification allows (#437)
* [Schema] Stop rejecting elicitation schemas the specification allows

The specification makes `title` optional on every elicitation schema
definition. AbstractSchemaDefinition required it, so a client reading a
conformant server's elicitation/create refused the request - and a server
building one could not omit it either.

`title` is now nullable on all seven definitions, omitted from the
serialized form when absent, and rejected only when present and not a
string.

* Fix stale title docblocks; allow omitting title where safe

* Give Number's title param a default too

* Apply suggestions from code review

Co-authored-by: Christopher Hertel <mail@christopher-hertel.de>
2026-08-18 19:49:15 +02:00
Valeriu Gutu
1961414432
[Schema][Server] Preserve request id in JSON-RPC error responses instead of fabricating id:"" (#379)
* Preserve request id in JSON-RPC error responses instead of fabricating id:""

When the server received input it could not turn into a valid message, the
error response carried a fabricated empty-string id that was never in the
request, breaking JSON-RPC client correlation.

The id default for the error factories was '' and that default reached the
wire unchanged: unrecoverable parse errors emitted id:"", and for invalid-
but-parseable messages MessageFactory discarded the decoded id so it also
fell back to "".

Now unrecoverable parse errors (-32700) return id: null per JSON-RPC 2.0,
and invalid-but-parseable requests (-32600) preserve the original request
id. Error::$id / getId() and the $id parameter of forParseError() /
forInvalidRequest() are widened to string|int|null, and the recoverable id
is threaded through InvalidInputMessageException.

Fixes #333

* Preserve nullable request id consistently across Error factories and fromArray

- Widen the remaining for*() factories (forMethodNotFound, forInvalidParams,
  forInternalError, forServerError, forResourceNotFound) to string|int|null
  defaulting to null, so no error path can fabricate id:"" again. The only
  defaulting call site (ProtocolVersionMiddleware) now emits id:null instead
  of id:"" for a pre-parse rejection, which is the spec-correct value.
- Accept a null id in Error::fromArray(): require the id key to be present
  (JSON-RPC responses must carry it) but allow its value to be null, so a
  spec-compliant id:null error response round-trips and incoming error
  responses with id:null are accepted instead of rejected.
- Widen the @phpstan-type ErrorData id to string|int|null to match the ctor,
  getId(), and jsonSerialize() shape.

* Document intentional id recovery guard in MessageFactory

* Cover id: 0 and string ids in recoverable-id preservation test

* Fixed client handling of null-id error responses
2026-08-18 15:47:27 +02:00
Christopher Hertel
5041caf77a
[Schema] Omit the id of an error response that never had one (#442)
* [Schema] Omit the id of an error response that never had one

An error response whose id could not be read sent `"id": ""`, which claims
the peer issued a request with an empty-string id - a different statement
from "the id could not be read". JSON-RPC spells the latter as a null id,
and a receiver that cannot read the id is exactly the case the code covers.

[BC Break] Error::$id accepts null and getId() may return it. All the for*()
factories default to null, fromArray() accepts a missing or explicitly-null
id, and the member is omitted from the serialized form when absent.

* Guard null response id in Client/Server Protocol handlers

Also fix Error::jsonSerialize()'s phpdoc: data belongs inside error, not beside it.
2026-08-18 15:24:19 +02:00
Christopher Hertel
cfe5e8f570
[Capability] Apply a parameter's complete schema definition (#435)
* [Capability] Apply a parameter's complete schema definition

Schema(definition:) is documented as taking precedence over the individual
keywords, and generate() unwraps it that way at method level. At parameter
level it was merged as an ordinary key instead, in both the plain and the
variadic path, so the property reached clients carrying a literal
"definition" member - not JSON Schema, read by nothing, and everything
inside it lost.

Both paths now replace the schema with the definition. The variadic one
still forces type: array afterwards, since a variadic arrives as an array
however the definition describes it, and the signature default survives a
definition that states none - it is what the handler receives when the
argument is omitted, and default is an annotation rather than a constraint.

* Fix docblock description overwriting definition on variadic params

* Apply suggestion from @chr-hertel
2026-08-18 15:09:58 +02:00
Christopher Hertel
08d332c7ac
[Schema] Serialize a settingless extension as an object (#438)
* [Schema] Serialize a settingless extension as an object

An extension declaring support with no settings was advertised as `[]`, an
empty JSON array, which the wire schema rejects - `extensions` maps an id to
a settings *object*. Both ServerCapabilities and ClientCapabilities now cast
each entry, so it goes out as `{}`.

* Apply suggestion from @chr-hertel
2026-08-18 14:59:12 +02:00
Christopher Hertel
ced762614b
[Capability] Stop a union type losing a branch (#434)
* [Capability] Stop a union type losing a branch

mapPhpTypeToJsonSchemaType() tested for array syntax before splitting the
union, so string[]|int mapped to array alone; applyArrayConstraints() then
overwrote the type with array whenever the union contained one, dropping the
scalar branch a second time. A tool declaring such a parameter rejected a
valid integer argument.

The split now happens first and only at the top level, so array<int|string>
and array{a: int|string} stay one type rather than two that do not exist, and
the array branch keeps its items alongside the others - which is what 2020-12
means by items: a constraint that applies only when the instance is an array.

* Apply suggestion from @chr-hertel

* Isolate the array branch before inferring its element type

* Don't apply items to arrays whose union branches disagree on element type
2026-08-18 14:54:52 +02:00
Christopher Hertel
0751a83658
[Examples][Tests] Make the MCP Apps example work over HTTP, and pin it (#439)
* [Examples][Tests] Make the MCP Apps example work over HTTP, and pin it

It was the only example without a session store, so every request under
`php -S` or php-fpm - each its own process - was answered "Session not found
or has expired". It now uses a FileSessionStore like the others.

Inspector snapshots pin what the extension actually puts on the wire: the
ui:// resource's `text/html;profile=mcp-app` MIME type and `ui` marker, and
the `_meta.ui` on the tool that links the two.

* Apply suggestion from @chr-hertel

* Apply suggestion from @chr-hertel
2026-08-18 14:51:34 +02:00
Christopher Hertel
7407661260
Tighten the 0.8.0 CHANGELOG section (#431)
* Add missing security fixes and bug fixes to 0.8.0 CHANGELOG

* Drop security-fixes bullet from CHANGELOG

* Drop bugfix bullets from CHANGELOG
2026-08-18 02:48:16 +02:00
Christopher Hertel
d5351759d7
[Schema] Drop SDK-only name pattern on resources and resource templates (#430)
* [Schema] Drop SDK-only name pattern on resources and resource templates

* Update src/Schema/ResourceDefinition.php

* Update src/Schema/ResourceTemplate.php
2026-08-18 02:09:07 +02:00
Christopher Hertel
6f58fe596a
[Schema][Client][Server] Close MCP Apps gaps: extension negotiation checks (#429)
* [Schema][Client][Server] Close MCP Apps gaps: extension negotiation checks

Add ClientGateway::supportsExtension(), Client\Builder::enableExtension() and
ClientCapabilities::withExtensions(); introduce side-agnostic ExtensionInterface
and deprecate ServerExtensionInterface; document attribute-based UI linkage.

* Drop ServerExtensionInterface in favour of ExtensionInterface
2026-08-18 01:46:53 +02:00
Christopher Hertel
590afb575b
[Schema][Client][Server] Deprecate Roots, Sampling and Logging (SEP-2577) (#427)
* [Schema][Client][Server] Deprecate Roots, Sampling and Logging (SEP-2577)

* Emit runtime deprecation notices and document the migration (SEP-2596)
2026-08-18 01:23:32 +02:00
Christopher Hertel
1ff9a74521
[Tests] Bump MCP Inspector to 2.2.0 (#426) 2026-08-17 23:26:05 +02:00
Christopher Hertel
f84d8f5aa9
[Schema][Server] Close the 2025-11-25 schema gaps and add the 2026-07-28 surface (#421)
* [Schema][Server] Close the 2025-11-25 schema gaps and add the 2026-07-28 surface

Ports the type definitions this SDK still misses outside of sampling tool use,
which #409 and #420 already cover. Every addition is optional and defaults to
current behaviour, so a connection negotiated on an older revision is
unaffected.

From 2025-11-25, 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. Icon gains `theme` from the same revision, and Implementation gains
the `title` BaseMetadata has carried since 2025-06-18.

From 2026-07-28, schemas loosen where the revision loosens them: SEP-2106
drops the object-only restriction, so Tool::outputSchema may describe any JSON
value and CallToolResult::structuredContent follows.

The same revision defines three error codes (-32020 header mismatch, -32021
missing required client capability, -32022 unsupported protocol version).
ProtocolVersionMiddleware switches 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.

* [Schema] Reject what the 2026-07-28 types cannot represent

`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.

* [Client][Server] Advertise the implementation title through both builders

`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.

* [Schema] Serialize every non-null structuredContent

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.

* [Schema][Server] Make url elicitation reachable

`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.

* [Schema] Add CHANGELOG entry for the 2026-07-28 surface

* [Capability][Schema][Server] Carry the widened structuredContent end to end

Review follow-up on the SEP-2106 widening, which stopped at the type.

`ToolReference::extractStructuredContent()` returned `?array`, so a tool
declaring a scalar `outputSchema` had its result dropped and logged as
unsendable. It returns `mixed` now and keeps a scalar — but only from
2026-07-28 on, and only when the tool declared an outputSchema: without one
the value is already carried in `content`, and advertising it twice is not an
improvement.

`CallToolHandler` only warned about list-shaped `structuredContent` on a
self-built `CallToolResult`, so a scalar reached revisions that require an
object unremarked. The check is on the shape now, not on the list case alone.

`ElicitResult` retained a `content` the spec says is absent from url-mode
results, leaving a malformed response indistinguishable from a valid one.

`Tool::jsonSerialize()` emitted the empty root schema as `[]`, which is not a
schema at all. Sub-schemas already had this treatment.
2026-08-17 23:09:39 +02:00
wWzZb
76778a4d9e
[Schema] Support PHPStan/Psalm integer ranges (#422) 2026-08-16 09:31:28 +02:00
Christopher Hertel
43815e38a8
[Schema][Server] Harden sampling tools against the spec (#420)
* harden sampling tools against the spec

Report tool-flow violations as -32602 instead of dropping the request,
accept resource_link in tool results, gate tools on sampling.tools, and
reject empty content.

* normalize keyed content arrays to lists

array_filter() and friends preserve keys, and a keyed array serializes as a
JSON object instead of the content-block array the schema requires.
2026-08-15 04:13:53 +02:00
Christopher Hertel
becce29c41
[Capability] Harden structuredContent extraction (#417)
* Address review feedback on resource_link

Three follow-ups from review of the resource_link work:

* PromptResultFormatter: add a regression test proving the optional
  fields of a typed resource_link block (title, description, mimeType,
  size, annotations, _meta) survive formatting. The delegation to
  ResourceLink::fromArray() already landed while rebasing onto the
  PromptResultFormatter refactor, but the existing test only supplied
  uri/name and so could not catch a reintroduced field drop.

* ResourceLink::fromArray(): validate optional fields consistently, the
  way the equivalent ResourceDefinition::fromArray() already does.
  description, mimeType and size now raise InvalidArgumentException
  instead of surfacing a TypeError (or silently coercing, for size's
  (int) cast); annotations goes through Annotations::tryFromArray(),
  which carries the is_array() guard that icons already had; icons uses
  Icon::listFromArray() so a non-array entry is reported in context.

* ToolReference::extractStructuredContent(): never emit a list as
  structuredContent. The Content guard added earlier fixed only one
  instance of the real invariant - structuredContent must be a JSON
  object, and a PHP list can never be one. Tool::fromArray() already
  enforces the matching rule by rejecting an outputSchema whose type is
  not "object", so this aligns the runtime path with it. The test
  asserting the opposite for an array-typed outputSchema contradicted
  the phpstan type and the fromArray() check introduced alongside it in
  the same commit, so it is replaced by tests for the two list shapes.

* Apply the structuredContent object rule to object results too

`extractStructuredContent()` guards raw array results against being emitted
as a JSON array, but the object branch handed back whatever `json_decode()`
produced. A `JsonSerializable` returning a list or a scalar slipped straight
through, producing exactly the `structuredContent` the array guard exists to
prevent — and a return value that contradicts the method's own
`array<string, mixed>|null` signature.

Check the decoded value before returning it, and cover the object branch,
which had no tests at all.

* Document structured tool output

`outputSchema` and `structuredContent` were undocumented: the tool return
value docs covered only the `content` side, and the schema generation
section is about tool parameters.

Add a "Structured Output" subsection covering how to declare the schema,
which return values populate `structuredContent`, and why a list has to be
wrapped in a key to get structured output at all.

* Gate structuredContent on the negotiated protocol revision

SEP-2106 (revision 2026-07-28) widens structuredContent to any JSON value;
earlier revisions require an object. Resolve the revision per request and
apply the matching rule, and warn when a declared outputSchema yields none.

* Resolve the protocol revision on RequestContext

Keeps the `_meta`-then-session lookup in one place instead of per handler,
and exposes the negotiated revision to tool handlers.

* Document reading the negotiated revision from RequestContext

* Warn when a self-built CallToolResult carries an invalid structuredContent

Returning a CallToolResult opts out of the extraction rules, so the value is
still sent unchanged — but a JSON array is not valid before SEP-2106.
2026-08-15 04:03:20 +02:00
wWzZb
82aac972c1
[Schema][Server] feat: support sampling with tools (#409)
* feat: support sampling with tools

* address sampling tools review feedback

* tighten sampling tools spec compliance

* drop SamplingStopReason enum in favor of open string

The spec leaves stopReason open for provider-specific values, so upcasting
the four known ones to enum cases makes every future enum addition a silent
BC break for string comparisons.

---------

Co-authored-by: Christopher Hertel <mail@christopher-hertel.de>
2026-08-15 03:28:11 +02:00
Christopher Hertel
d8cc21a345
[Client] Implement setMaxRetries connection retries (#413)
* [Client] Implement setMaxRetries connection retries

* [Client] Reject a negative retry count in Configuration

* [Client] Use a positive init timeout in the retry test

* [Client] Drop redundant docblock note on setMaxRetries

* [Client] Clear the initialized flag on a failed connection

* [Client] Trim comments to the non-obvious ones

* [Client] Cover retries and timeout cleanup with integration tests

The retry fixture counts the processes it was started as, which is what
separates a real retry from a second call into the same server. The timeout
fixture outlives the client's patience and then goes idle, so the next
request runs on a connection the client already gave up on once.
2026-08-15 02:43:50 +02:00
Christopher Hertel
7b12dc4010
[Tests] Introduce client-server integration tests (#419)
* [Tests] Introduce client-server integration tests

* Add an in-process loopback that connects a real client transport to a real
  server transport, draining both Fiber loops in one synchronous pass instead
  of polling. A stalled exchange fails where it happened rather than hanging.
* Cover protocol version negotiation across both implementations, where the
  unit tests only ever drive one side against a canned counter-offer.
* Cover the server-to-client round-trips end to end: elicitation, sampling,
  roots, plus progress and logging notifications.
* Add the `integration` test suite and a `make integration-tests` target.

* [Tests] Run the integration suite in CI

The unit job pins `--testsuite=unit`, so the new suite would never have run on
GitHub. It goes in that job rather than its own: the loopback leans on Fibers,
and the matrix is what covers them down to the PHP 8.1 floor.

* [Tests] Run integration tests against a real server process

Replaces the in-process loopback with the setup from examples/client: the
client spawns a fixture server over the real StdioTransport, so the tests
exercise the shipped transports and no longer nest both sides' Fibers.

Moves the suite into its own CI job, matrixed over PHP 8.1-8.5.
2026-08-15 02:24:48 +02:00
Christopher Hertel
c4b163b4c4
[Server] Add ClientGateway::supportsSampling() (#418)
Sampling was the one client capability a tool could not check before asking.
`supportsRoots()` and `supportsElicitation()` both read the capabilities the
client advertised during the handshake; sampling is stored in that same session
data but had no accessor, so a tool had to issue the request and catch the
refusal.
2026-08-15 02:11:15 +02:00