* [Capability] Remove CompletionProvider's dead providerClass argument
It always threw on construction, so it never worked in any release. `provider:`
takes the same class-string and is now the first positional argument.
* Reword Changelog
* Expose session ID from HttpTransport
* [Client] State what getSessionId() returns on 2026-07-28
That revision removed protocol-level sessions (SEP-2567), so the getter is always null there. Adds the changelog entry.
---------
Co-authored-by: Christopher Hertel <mail@christopher-hertel.de>
* [Capability] Do not announce an externally loaded registry as changed
`Registry` suppresses its `*ListChangedEvent`s while it is loading — `dispatch()`
returns early on the `loading` guard, so the elements a loader registers are the
registry's initial contents rather than a change to them.
`testListChangedEventsAreSuppressedDuringTheDeferredLoad` pins that.
The guard only covers `Registry::load()`, which needs the loader the constructor
took. A registry the caller built cannot be given one that way, so
`Builder::resolve()` loads it from the outside instead — by calling
`$chainLoader->load($registry)` directly, which never sets the guard. Every
element then dispatches on the way in.
With a notification bus configured that is not quiet. `PublishingEventDispatcher`
turns each event into a published notification, so every `build()` puts one
`list_changed` per element on the bus for a registry that did not change. Under
PHP-FPM, where the server is built per request and `Psr16NotificationBus` is
shared and persistent, every request broadcasts its whole element list to every
open `subscriptions/listen` stream and consumes the 256-entry backlog — after
which a reader that fell behind silently skips real notifications.
Split the guarded body of `load()` into `loadFrom(LoaderInterface $loader)` and
route the custom-registry branch through it.
`loadFrom()` takes only the `loading` guard, not the `loaded` bookkeeping, which
stays with `load()`. The loader it runs belongs to the caller, so it cannot stand
in for the one the registry was constructed with: marking the registry loaded
would retire a constructor loader that never ran, and would make a second
`loadFrom()` — one registry handed to two builders — a silent no-op. Keeping
`loaded` out of it also keeps `load()`'s promise that a transient failure is
retried on the next read, including when the failing loader reads the registry
during its own run.
`RegistryInterface` declares no `load()`, so this stays on the concrete
`Registry`, and a third-party implementation keeps the path it has today.
* Apply batched suggestions from code review
Co-authored-by: Christopher Hertel <mail@christopher-hertel.de>
* Cast variadic tool parameters element-by-element
SchemaGenerator::buildVariadicParameterSchema() already advertises a
variadic parameter (e.g. `int ...$scores`) as a JSON "array" schema, so
a spec-conformant client sends an array for it. But
ReferenceHandler::prepareArguments() never special-cased
ReflectionParameter::isVariadic(): it fell through to the regular
single-value branch and passed the whole array to castArgumentType(),
which tried to cast it as one scalar and failed - e.g. "Cannot cast
value to integer" for `int ...$scores` given [1, 2, 3].
Any tool handler declaring a variadic parameter was therefore
unreachable through the advertised schema, confirmed by direct
reproduction against ReferenceHandler::handle() before this change.
Casts each array element individually via the existing
castArgumentType() (unchanged - it already inspects the variadic
parameter's element type, not "array", per PHP reflection semantics)
and appends to the result in order; variadic is always the last
parameter, so this preserves correct positional ordering. A non-array
value for a variadic argument now fails clearly with an invalid-params
error instead of the confusing cast message.
* Address chr-hertel's review: wrap scalars, reject objects, index cast errors
- Non-array variadic argument is now wrapped as a single-element list
instead of being rejected: MCP prompt arguments are always
Record<string,string> per protocol, so a variadic prompt parameter
legitimately receives a plain string, not an array. Rejecting it
regressed behaviour that worked on main.
- A JSON object (non-list array) is now explicitly rejected instead of
being silently flattened into positional args by array_values().
- Cast-failure errors now include the parameter name and element index,
so a failure inside a large array is actually actionable.
- Trimmed the inline rationale comment; the PR description already
covers it.
Tests updated: replaced the "throws on non-array" test (no longer
correct) with tests for the wrap-as-single-element, reject-object, and
indexed-error-message behaviours.
* Pin argument order for an injectable before a variadic
---------
Co-authored-by: Christopher Hertel <mail@christopher-hertel.de>
* [Capability] Suppress list-changed events during the initial registry load
* Shorten dispatch() comment to a one-liner
* Use multi-line docblock format for dispatch() comment
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.
* [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
* [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>
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.
* [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
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.
* [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.
* [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.
* [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.
* [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.
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.
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.
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.
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.
- 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
* [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.
* [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.
* [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.
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.
* [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
* [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>
* 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
* [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.