* [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.
* [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
* [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
* [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
* [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
* [Schema] Drop SDK-only name pattern on resources and resource templates
* Update src/Schema/ResourceDefinition.php
* Update src/Schema/ResourceTemplate.php
* [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.
* 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.
* 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.
* 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>
* [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.
* [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.
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.
* [Client][Docs][Schema][Server] Implement protocol version negotiation
The `initialize` handler never looked at the version the client asked for:
it always answered with the server's configured version (or the SDK default),
so a client offering a revision the server supports could still be told to
speak a different one. The spec requires echoing back a supported request and
only counter-offering when the requested version cannot be honoured.
Turns ProtocolVersion into the registry the other SDKs converged on
(typescript-sdk's protocolEras.ts, python-sdk's mcp_types/version.py):
`latestHandshake()`, `handshakeVersions()`, `modernVersions()` and ordered
comparison through `isAtLeast()`. Comparison goes through declaration order
rather than string collation, so an unrecognized peer string compares
conservatively instead of accidentally sorting above a known revision. The
one throw the enum can produce is the SDK's own LogicException, so a consumer
catching `Mcp\Exception\ExceptionInterface` does not miss it.
Adds 2026-07-28 as a known revision and splits the set into two eras:
handshake (2025-11-25 and earlier, negotiated via `initialize`) and modern
(2026-07-28+, per-request `_meta`). The lists are kept apart so a modern
revision can never leak into an `initialize` exchange, which has no
`initialize` at all. Serving the modern era is follow-up work; this only
teaches the SDK that the revision exists and must not be mis-negotiated.
Deliberately absent is any "newest revision in any era" accessor, which
would hand back the one revision the handshake must never offer.
Server: negotiates and stores the result on the session. A version pinned in
Configuration still pins the handshake to exactly that revision.
Client: validates the counter-offer and fails the handshake instead of
continuing on a revision it cannot speak; records the negotiated version on
the client state, readable through the new Client::getProtocolVersion().
The HTTP middleware now defaults to the handshake versions rather than every
declared case, so it stops accepting a modern version the server cannot yet
serve. When the header is absent it falls back to
ProtocolVersion::DEFAULT_NEGOTIATED_VERSION (2025-03-26), the revision that
introduced both Streamable HTTP and the header itself.
The negotiation behaviour is one provideNegotiationTable() data provider
whose rows are the documented table -- supported revision echoed back,
unknown or malformed counter-offered, modern counter-offered -- so the docs
and the suite cannot drift apart. The supported-revision rows iterate
handshakeVersions() rather than a literal list, so declaring a new handshake
revision extends the table automatically, and the "never answer with a modern
revision" assertion runs on every row.
Documented in server-builder.md (the enum registry, how the server answers a
handshake, what pinning changes), transports.md (the header check) and
client.md (the client's side), all linking the spec's versioning section.
* [Client][Docs][Schema][Server] Warn when the configured protocol version cannot be negotiated
* Log a warning instead of silently falling back when a modern revision is
configured on the client or pinned on the server builder.
* Document that setProtocolVersion() does not restrict the MCP-Protocol-Version
header check, which validates against a fixed set.
* Pin every revision's era in a test, so a new case declared past the modern
boundary fails instead of dropping out of the handshake set.
* Add CHANGELOG entry.
* [Client][Docs][Schema][Server] Rename version fallback constant and trim comments
* Add resource_link content block
The spec's `resource_link` content type (protocol revision 2025-06-18,
still present in 2025-11-25) had no corresponding schema class: it lets a
tool result or prompt message reference a resource by URI/name without
embedding its full contents, the way `EmbeddedResource` does. Without it,
tools returning many or large resource references (e.g. search results
over a DAM) are stuck smuggling URIs through text/structured content.
Add `Mcp\Schema\Content\ResourceLink`, mirroring the spec's `Resource`
shape (uri, name, title, description, mimeType, annotations, size, icons,
_meta) with a fixed `type: "resource_link"`, following the conventions of
the sibling `EmbeddedResource`/`ResourceDefinition` classes.
Register it everywhere `resource` content blocks are (de)serialized by
type per the spec's `ContentBlock` union:
- `CallToolResult::fromArray()`
- `PromptMessage` (constructor type, `fromArray()`, phpstan types)
- `PromptResultFormatter` (typed-array and direct-instance paths)
`SamplingMessage`/`CreateSamplingMessageResult` are intentionally left
unchanged: the spec's `SamplingMessageContentBlock` does not include
`ResourceLink`.
Fixes#399
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Add resource_link example coverage to discovery-userprofile
Add a lookup_user tool to the discovery-userprofile example: given a
userId, it returns a short text summary plus a resource_link pointing
at the existing user://{userId}/profile resource template, instead of
embedding the profile. This mirrors the PR's motivating use case (a
tool referencing many/large resources by URI rather than inlining
them) and exercises resource_link through the example's own inspector
snapshot tests (tools_call-lookup_user[_not_found], tools_list).
Wiring this up surfaced a real bug: ToolReference::extractStructuredContent()
passed any raw array return value straight through as structuredContent,
even when it held Content instances (e.g. the TextContent/ResourceLink
pair above). That serializes to a JSON list, which fails the spec's
requirement that structuredContent be an object - the inspector's MCP
client rejected the tool call outright until fixed. Skip structuredContent
extraction whenever the raw result contains a Content item, since those
are already represented in `content` and were never meant to double as
structured data.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Alex Rothberg <51059+cancan101@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* [Client][Server] Add Roots support
Wire up the MCP "roots" capability on both sides:
Client (php-sdk as client):
- RootsCallbackInterface + ListRootsRequestHandler answer server roots/list
requests, mirroring the Sampling handler.
- Client::sendRootsListChanged() emits notifications/roots/list_changed.
Server (php-sdk as server):
- ClientGateway::listRoots() requests the client's roots, and
supportsRoots() reports whether the client advertised the capability,
normalizing the stored capabilities to an array so an empty
capabilities object (\stdClass) doesn't cause a TypeError.
- Add ListRootsResult::fromArray() (used by listRoots()).
- ListRootsRequestHandler forwards RootsException messages to the server
(new Mcp\Exception\RootsException), mirroring SamplingRequestHandler.
- Client::sendRootsListChanged() throws when the client did not advertise
the roots.listChanged capability.
Examples, docs and tests:
- examples/README/docs advertise
`new ClientCapabilities(roots: true, rootsListChanged: true)` before
sending notifications/roots/list_changed.
- CHANGELOG entry for Roots (unreleased 0.7.0 section at the time).
- Unit tests for capability serialization/round-trip, sendRootsListChanged
gating, and RootsException message forwarding.
phpunit, php-cs-fixer and phpstan green.
* [Client][Server] Address review: schema validation, changelog section, connection guard, working example
Responds to chr-hertel's review of the Roots support:
- ListRootsResult::fromArray() rejects non-array root elements instead of
accepting malformed entries silently.
- CHANGELOG entry moved to the next unreleased section as the release
train advanced past it (0.7.0 -> 0.8.0).
- Client::sendRootsListChanged() went straight to
Protocol::sendNotification(), which sends via
`$this->transport?->send()` -- on a client that is not connected the
notification was silently dropped. It now throws a ConnectionException
like sendRequest() does.
- The stdio example only advertised the roots capability, so the
roots/list handler never actually ran. The client-communication demo
server gets an `inspect_workspace_roots` tool that calls
ClientGateway::supportsRoots()/listRoots(); the example calls it, so
the server actually issues roots/list and the client answers it.
Dropped the try/catch wrapper from the example.
- ClientTest's connected-client mock now goes through setState() so
isConnected() reflects a real handshake instead of a stubbed flag.
phpunit, php-cs-fixer and phpstan green.
* Preserve optional content fields in PromptResultFormatter
The formatter's typed-content path rebuilt each Content object from only
its required fields, silently dropping everything else a prompt returned
(annotations on every type, _meta and explicit mimeType on embedded
resource contents). Delegate to the schema classes' fromArray() instead,
keeping the historical mimeType defaulting and RuntimeException-with-
index error contract.
ImageContent was the only content class without annotations support, so
it gains the same constructor/fromArray()/jsonSerialize() handling as
TextContent and AudioContent.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Reword CHANGELOG entry to be user-facing
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
HttpTransport looked for an event boundary with strpos($buffer, "\n\n") and
split event lines on "\n". Servers that terminate SSE lines with CRLF therefore
never produced a recognised event: the response accumulated in the buffer, no
message was ever dispatched, and connect() failed with "Initialization failed:
Request timed out".
This affects every MCP server built on the Python SDK. FastMCP's
streamable_http_app() returns sse_starlette's EventSourceResponse without a sep
argument, and its DEFAULT_SEPARATOR is "\r\n". The bug is invisible from within
this repository because the PHP server implementation emits LF, so client and
server agree in the test suite while disagreeing with most servers in the wild.
The SSE specification requires a parser to accept CRLF, LF or CR, so the
previous behaviour was non-compliant rather than merely unlucky.
Event extraction now accepts all three terminators, choosing whichever appears
first so a buffer split mid-sequence is handled correctly, and line splitting
uses the same set. A stream that ends without a trailing blank line now
dispatches its final event instead of discarding it.
Adds the first tests for src/Client/Transport/, covering LF, CRLF and CR
framing, an id field, a comment preamble, and a missing trailing blank line.
Without the fix four of the six fail with the timeout above.
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
* Never let a throwable escape Protocol::processInput()
* Validate payload types in the schema hydration methods
* Stop leaking handler exception messages to the client
* [Schema] Always emit {} for empty tool schema properties
Normalize empty properties arrays (including nested object schemas and
outputSchema) in Tool::__construct so tools/list never serializes
invalid JSON Schema properties: [].
Fixes#405
* [Schema] Normalize every empty sub-schema, not only properties
Walking into `items` and `additionalProperties` without normalizing an empty
sub-schema at those positions left the `[]`-instead-of-`{}` bug intact one
level down. Most notably it regressed #151 on a `Tool::fromArray()`
round-trip: `SchemaGenerator` emits `items: {}` for untyped array parameters,
which decodes to `[]` and was re-serialized as `[]` — the exact schema strict
clients reject.
Empty sub-schemas are now replaced with a `\stdClass` at every schema
position, and the keyword list is completed to cover draft-07 through
2020-12: `$defs`, `definitions`, `dependentSchemas`, `patternProperties`,
`propertyNames`, `contains`, `not`, `if`/`then`/`else`, `additionalItems`,
and `unevaluatedItems`/`unevaluatedProperties`. Keywords holding plain JSON
arrays (`allOf: []`, `enum: []`, `dependentRequired`) keep encoding as `[]`.
---------
Co-authored-by: Christopher Hertel <mail@christopher-hertel.de>
The constructor guarded its unwritable-directory case with the global
`\RuntimeException`, the only throw in `src/` still outside
`Mcp\Exception\ExceptionInterface`. A consumer wrapping server setup in a
`catch (ExceptionInterface)` missed it and got an unhandled SPL exception on
a misconfigured session directory instead.
`Mcp\Exception\RuntimeException` extends `\RuntimeException`, so this is
additive: existing `catch (\RuntimeException)` blocks keep working.
The constructor had no test coverage at all, so this adds one for the throw
plus the directory-creation and write/read paths around it.
processInput() called fgets($this->input) with no length argument. PHP
fgets() reads until a newline or EOF, so a peer that streams bytes
without ever sending a newline forced the whole stream into a single
allocation and exhausted the process memory limit (GHSA-vm75-qjc7-xh6w).
Bound each read with fgets($this->input, $this->maxLineBytes) (default
4 MiB, configurable via the constructor). When a read fills the cap with
no trailing newline the line exceeds the limit: discard it and keep
discarding one bounded chunk per tick until its terminating newline,
rather than buffering it. The discard is stateful so the run loop keeps
servicing fibers and outgoing messages instead of blocking on a drain
loop, and the over-length line is logged once.
Reimplements tonghuaroot's advisory fix: makes the cap configurable
rather than a hardcoded constant, drains the over-length line without an
inner blocking loop (responsive run loop, single log line), and adds
regression tests.
Co-authored-by: tonghuaroot <23011166+tonghuaroot@users.noreply.github.com>
processInput() appended every 8 KiB stdout chunk to $inputBuffer and only
drained it on a "\n". A spawned server that streams stdout without ever
emitting a newline grew the buffer without bound, exhausting client
memory (GHSA-6vrr-g4hw-f3hj).
Cap the buffer (default 4 MiB, configurable via the constructor). When
appending the next chunk would exceed the cap before a newline is seen,
abort the read and resolve the in-flight request with an error
immediately, so the caller fails fast instead of spinning until its
request timeout.
Based on localden's suggested fix in the advisory: makes the cap
configurable, but resolves the pending request with an error (keeping
runRequest's Response|Error contract) instead of throwing a
TransportException out of the read loop, and leaves connection teardown
to the caller. Adds regression tests.
Co-authored-by: localden <53200638+localden@users.noreply.github.com>
processSSEStream() appended every 4 KiB chunk to $sseBuffer and only
flushed on the "\n\n" SSE event delimiter. A server streaming data
without ever sending the delimiter grew the buffer without bound,
exhausting client memory (GHSA-7m52-jw36-44r3).
Cap the buffer (default 8 MiB, configurable via the constructor). When
appending the next chunk would exceed the cap before a complete event is
seen, abort the stream and resolve the in-flight request with an error
immediately, so the caller fails fast instead of spinning until its
request timeout.
Reimplements tonghuaroot's advisory fix: makes the cap configurable
rather than a hardcoded constant, fails the pending request fast instead
of relying on the timeout, and adds regression tests.
Co-authored-by: tonghuaroot <23011166+tonghuaroot@users.noreply.github.com>
* [Server] Expose OAuth protected resource metadata as a reusable request handler
* docs: show handler DI wiring in Symfony/Laravel controller examples
* Focus PR on transport refactor: drop docs and example changes
* docs: add changelog entry for ProtectedResourceMetadataHandler
* [Tests] Bump MCP Inspector to 0.22.0 for 2025-11-25 protocol
The pinned inspector 0.17.2 predates the protocol version bump to
2025-11-25 (v0.6.0) and rejects the handshake with "Server's protocol
version is not supported", failing every inspector snapshot test. 0.22.0
negotiates the current protocol; the full inspector suite passes against
it (95 tests, 7 pre-existing skips).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* [Examples] Demo pre-built instance handler in combined-registration
Extends the combined-registration example with a third handler style:
a pre-built object instance `[$instance, 'method']`, alongside the
existing discovered and manual class-string handlers. PreconfiguredGreeter
takes a scalar constructor argument neither the container-less
`new $className()` fallback nor the auto-wiring container can build, so it
can only be registered as an already-constructed instance — exercising the
resolver path added in the parent branch.
Adds an inspector snapshot case (tools/call instance_greeter) and updates
the tools/list snapshot.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Array tool parameters without an inferable element type generated `{"type":"array"}` with no `items`, which strict MCP clients (VS Code / Copilot) reject. Always emit `items` (`{}` when the element type is unknown) and recover the element type for nullable typed arrays such as `string[]|null`.
Fixes#151
Builder::build() runs all loaders eagerly and snapshots capabilities from
the resulting registry. Both are computed once, when the server is built.
Under a persistent runtime (e.g. FrankenPHP worker mode) the server is
built a single time, so a loader whose data source is not yet ready at
that moment (cold cache, un-warmed metadata) leaves the registry empty for
the whole process — tools/list stays empty while tools/call still works.
Make deferred loading the default behavior of Registry itself: it takes a
loader and runs it on the first read (has*/get*), moving loading to
request time when the application is initialized. The load runs once; the
loaded flag is set only after a successful load, so a transient failure is
retried on the next read. A re-entrancy guard lets loaders that read the
registry during their own run (e.g. discovery's identity check) observe
the partial state instead of recursing. Registrations made before the
first read survive it.
Expose the switch on Builder via setLazyLoading() (default on) so callers
can opt back into eager loading at build time. In lazy mode capabilities
are advertised from the configured sources so the initialize handshake
does not force a load; in eager mode (or for a registry supplied via
setRegistry(), which is always loaded eagerly) they are read from the
loaded registry.
* [Client] Add client-side elicitation support
The SDK could send elicitation/create requests from a server tool via
ClientGateway::elicit(), but the client could not receive them: there was
no request handler, and ElicitRequest was not registered in MessageFactory,
so incoming elicitation/create messages failed to parse.
This adds the client half, mirroring the existing Sampling pattern:
- Register ElicitRequest in MessageFactory so elicitation/create parses.
- ElicitationRequestHandler wraps a user callback and returns an
ElicitResult (accept/decline/cancel) or an Error.
- ElicitationCallbackInterface defines the callback contract.
- ElicitationException forwards a specific message to the server; any
other throwable returns a generic error.
Includes unit tests, a runnable client example against the elicitation
demo server, and client docs/README updates.
* [Client] Simplify elicitation example labelFor() per review
Type the parameter as AbstractSchemaDefinition and read ->title directly
instead of matching on each concrete definition type. title is a required
non-null string on the base class, so the match and the $name fallback were
redundant.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
* [Schema] Fix Implementation::fromArray rejecting falsy-but-valid name/version
`empty()` treats the string "0" as empty, so a valid clientInfo/serverInfo
with `"version": "0"` (or `"name": "0"`) was rejected with an
InvalidArgumentException. During initialize this aborted request parsing and
surfaced as a misleading "A valid session id is REQUIRED" error.
Replace the `empty()` guards with explicit isset/is_string/empty-string checks
so genuinely missing, non-string, and empty values are still rejected while
"0" is accepted. Adds ImplementationTest covering the regression and the
existing validation paths.
Fixes#392
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* [Schema] Suppress PHPStan argument.type on invalid-input tests
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* Allow pre-built instance handlers in element registration
`HandlerResolver::resolve()` rejected `[$instance, 'methodName']` array
handlers even though the `Handler` type alias and the runtime invoker
already support them, so `Server::builder()->addTool(handler: [$obj, 'bar'])`
threw "Invalid array handler format" at build time.
Relax the array-shape guard to accept an object in slot 0 and normalize it
to its class name for reflection (the same idiom getHandlerDescription()
already uses). This unblocks dependency-injected handler objects that the
container-less `new $className()` fallback cannot build.
Fixes#369
* Use 0.6.1 changelog heading and test instance handler end-to-end
This is a bugfix with no new public API or BC break, so the changelog
entry belongs under 0.6.1 rather than a new 0.7.0 section.
Add an end-to-end test that registers a pre-built instance handler with a
constructor dependency the container-less new $className() fallback cannot
satisfy, then drives a real tools/call through CallToolHandler and asserts
the injected instance is the one invoked.
* Address review: reject Closure array handlers, fix abstract-method message
- HandlerResolver: extract the array-handler guard into a readable
$hasValidTarget check that accepts strings and objects but excludes
\Closure, so [$closure, 'method'] falls through to the format error
instead of the misleading "method not found in class Closure".
- Fix the abstract-method rejection message ("must be abstract" ->
"must not be abstract"), a pre-existing typo in the same branch.
- Tighten the CHANGELOG entry wording.
- Cover Closure rejection with a regression test.
* Move instance-handler entry under 0.7.0 changelog heading
Per review: registering pre-built instance handlers is a feature, not a
patch, so it belongs in a minor release heading.
Record ADR 0001: the MCP server is an OAuth 2.1 Resource Server that MAY
delegate to an upstream authorization server, and will NOT issue tokens or
act as an Identity Provider. Gives a single citable ruling to decline the
recurring "native authorization server" PRs (e.g. #373).
Also remove the surface signal that invites them: reframe the
OAuthProxyMiddleware class docblock to lead with its delegation intent and
state explicit non-goals (never mints/signs/stores/rotates tokens), and add
a "Scope" section to docs/authorization.md describing the three OAuth roles
and what the SDK does not do.
No behavior change — docblock, docs, and ADR only.
* fix: harden JSON-RPC input parsing against malformed input and DoS
MessageFactory::create() detected batches via $input[0] === '{', which
mishandled valid-but-non-object JSON: a scalar ("5") or leading-whitespace
object silently returned [] (with a PHP warning) or could reach a foreach
over a non-array. It also had no bound on batch size, letting one small
request expand into arbitrarily many operations (amplification).
- Detect single-vs-batch from the decoded JSON type (object vs list array)
instead of the raw first byte; reject scalars and empty payloads as
InvalidInputMessageException rather than crashing or no-oping.
- Cap batch size (maxBatchSize, default 100) and reject oversized batches.
- Reject non-object batch elements as InvalidInputMessageException instead
of letting them hit a TypeError.
- Bound the StreamableHttpTransport POST body (maxBodyBytes, default 4 MiB),
returning 413, with an incremental read so unknown-size/chunked bodies
cannot exhaust memory.
* docs: document input-hardening limits in CHANGELOG and transports guide
Add CHANGELOG entries and docs/transports.md sections for the JSON-RPC
batch size cap (maxBatchSize) and the StreamableHttpTransport request
body size cap (maxBodyBytes), plus the type-based single-vs-batch
message detection.
A non-template resource resolves to a ResourceReference, which has no completionProviders property; only prompts and resource templates do. Read providers from those instead, returning an empty completion result when a resource has none.
Wrap-up for the v0.6.0 release: capture features/BC breaks merged since
v0.5.0 that were missing from documentation.
- CHANGELOG: add the `title` field on `Resource`/`ResourceTemplate` (#301),
a feature plus three positional-argument BC breaks.
- mcp-elements.md: add the missing `title` parameter to the McpTool,
McpResource, McpResourceTemplate and McpPrompt attribute reference
sections; fix a missing closing paren in an `#[McpPrompt]` example.
- server-builder.md: add `title?` to the `addPrompt()` row of the method
reference table (stale since v0.5.0).
- client.md: bump the protocol-version example to `V2025_11_25` to match
the new default.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat: add MCP Apps extension (io.modelcontextprotocol/ui) support
Adds support for the MCP Apps protocol extension, which lets servers
expose interactive HTML UIs as resources rendered by spec-conforming
clients (e.g. Goose) in sandboxed iframes.
- Generic extension plumbing: ServerExtensionInterface and
Builder::enableExtension(), with extensions advertised under
capabilities.extensions during the initialize handshake.
ClientCapabilities/ServerCapabilities gain an extensions field.
- Schema DTOs under Mcp\Schema\Extension\Apps: the McpApps marker,
UiToolMeta, ToolVisibility enum, UiResourceContentMeta,
UiResourceCsp, and UiResourcePermissions (with empty-object
presence markers per spec).
- A worked weather example under examples/server/mcp-apps/ with a
minimal spec-conforming view (ui/initialize handshake, postMessage
JSONRPCMessage objects, and ResizeObserver-driven size reporting).
- docs/extensions.md guide with pointers from index.md and
examples.md to the ext-apps repository for the TypeScript SDK and
richer view-side patterns.
- Unit and inspector snapshot test coverage.
Spec: https://github.com/modelcontextprotocol/ext-apps
* Type UiToolMeta visibility as ToolVisibility enum
Also fix UiResourceContentMeta::jsonSerialize() return phpdoc.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* Address review nitpicks for MCP Apps extension
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* Apply PR review feedback for MCP Apps extension
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* Add regression tests and CHANGELOG entry for capabilities extensions
- Pin the is_array() guards on extensions in ServerCapabilities and
ClientCapabilities::fromArray() with explicit non-array payload tests.
- Pin the isset()-rejects-null contract in UiResourcePermissions::fromArray().
- Surface the new extensions field on both capability classes in CHANGELOG.
- Inline the ?: idiom in Builder::build() to match the surrounding style.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* [Server] feat: allow runtime handlers
Adds Mcp\Server\Handler\RunTimeHandlerInterface so integrators
(e.g. configuration-driven element sources) can register handlers
whose arguments and execution are resolved at runtime rather than
via PHP reflection of a callable.
ReferenceHandler::handle() dispatches instanceof RunTimeHandler
ahead of the callable/array/string branches, calls filterArguments
to narrow the argument map, and forwards execute() a ClientGateway
built from the active session.
The Registry-layer surface (ElementReference and the four
*Reference subclasses, RegistryInterface, Registry::register*,
ArrayLoader's handler-description helper) is widened to accept the
new type. The Builder/ArrayLoader path keeps its narrow callable
union; exposing runtime handlers there requires reworking
HandlerResolver and is left for a follow-up.
* feat(server): add runtime handler metadata API
Extend RunTimeHandlerInterface with four nullable metadata
accessors (getInputSchema, getOutputSchema, getPromptArguments,
getCompletionProviders) so the loader can obtain element
metadata without reflection.
Add RunTimeHandlerTrait providing null defaults for all four
accessors so single-purpose handlers can override only the
methods relevant to their element kind.
* feat(server): expose runtime handlers via Builder
Widen Builder::addTool, addResource, addResourceTemplate, and
addPrompt to accept RunTimeHandlerInterface instances alongside
the existing closure/array/string handler forms.
Branch ArrayLoader::load() on instanceof RunTimeHandlerInterface
in each of the four registration loops so runtime handlers skip
the reflection-based HandlerResolver/HandlerSchema path and pull
metadata from the interface accessors instead. Require explicit
name and description for runtime handlers and raise
ConfigurationException when missing or when a runtime tool has
no input schema source.
Replace direct $data['handler'] use in error log context with
getHandlerDescription() to avoid serializing large runtime
handler object graphs into log records.
Document the runtime handler API in docs/server-builder.md with a
new Runtime Handlers subsection and add a cross-reference from
docs/mcp-elements.md.
* test(server): cover runtime handler Builder path
Add ArrayLoaderRunTimeHandlerTest covering:
- successful tool registration with handler-supplied input schema,
with kwarg input schema (precedence over the handler), and the
same precedence rule for output schema
- ConfigurationException for tools missing name, description, or
any input schema source
- successful resource registration via Builder kwargs and the
matching missing-name failure mode
- successful resource template registration with completion
providers sourced from the handler and the missing-description
failure mode
- successful prompt registration with arguments and completion
providers sourced from the handler, plus the missing-name
failure mode
- a trait-only fixture verifying the four metadata accessors
return null by default
Update existing ReferenceHandlerTest anonymous classes to use
RunTimeHandlerTrait so they satisfy the widened interface.
* refactor(server): split runtime handler interface
Splits RunTimeHandlerInterface into a minimal base contract (only
execute()) plus element-specific subtypes: RunTimeToolHandlerInterface,
RunTimePromptHandlerInterface, and
RunTimeResourceTemplateHandlerInterface. Each subtype declares only
the metadata accessors relevant to its element kind, so a tool
handler no longer has to no-op out prompt or template methods.
Drops the now-redundant RunTimeHandlerTrait and removes
filterArguments; execute() receives the full argument map directly.
Updates ArrayLoader, ReferenceHandler, and Builder type signatures
to use the per-element interfaces, and rewrites the Runtime
Handlers documentation accordingly.
* refactor(server): rename runtime handler interfaces to Runtime casing
Renames RunTimeHandlerInterface and its element-specific subtypes to
Runtime casing, adds a dedicated RuntimeResourceHandlerInterface so
addResource() rejects mismatched runtime handlers at the type level,
and strips _session and _request from arguments forwarded to runtime
handlers. Hardens ArrayLoader to re-throw ConfigurationException
without wrapping, and extracts inline test fixtures into
tests/Fixtures/Runtime/.
* chore: please the linting gods
* test(server): use Uuid::v4 for symfony/uid 5.4 compatibility
Uuid::v7() was added in symfony/uid 6.2 and breaks the Symfony 5.4
matrix job. Match the convention used elsewhere in the suite.
* refactor(server): split ArrayLoader registration
Extract per-element preparation into dedicated private prepare*
methods for tools, resources, templates, and prompts. Factor the
shared required-field assertion and reflected name/description
resolution into helpers.
* refactor(server): non-null runtime handler types
Drop nullable return types on RuntimeToolHandlerInterface::
getInputSchema(), RuntimePromptHandlerInterface::
getPromptArguments()/getCompletionProviders(), and
RuntimeResourceTemplateHandlerInterface::
getCompletionProviders() in favor of plain arrays.
ArrayLoader no longer needs the null-coalescing
fallbacks, and NullSchemaToolHandler plus its
ConfigurationException test become redundant.
* doc: update docs/server-builder.md
Co-authored-by: Christopher Hertel <mail@christopher-hertel.de>
* feat: peer revie feedback
Introduce a new `Builder::add(...)` pattern for a more generic runtime
handler.
* refactor(server): apply PR 294 review feedback
Move per-interface handler dispatch from ReferenceHandler into
ExplicitElementLoader by wrapping each ElementHandlerInterface in a
Closure that satisfies the existing callable contract, revert the
*Reference / Registry handler-type widening PR 294 introduced, throw
Mcp\Exception\InvalidArgumentException (not \TypeError) on mismatched
Builder::add() pairings, and rename two classes for clarity:
* Mcp\Capability\Registry\Loader\ArrayLoader -> ReflectedElementLoader
* Mcp\Schema\Resource -> Mcp\Schema\ResourceDefinition (no alias)
ReferenceHandler::prepareArguments() now injects ClientGateway by type
and binds an `array $arguments` / `array $variables` closure parameter
from the leftover inbound arguments. This addresses Antoine's coupling
concern (ReferenceHandler no longer knows about the four handler
interfaces) and Chris's package-exception preference, and clears the
phpdoc reserved-word collision Antoine flagged on the resource schema
class. Existing tests are re-targeted to exercise the closure path
through ExplicitElementLoader / ReferenceHandler.
* doc: address PR 294 review feedback
Apply Chris's line-level suggestions on docs/server-builder.md
(rewrap to 120 chars, smoothed Drupal mcp module name-drop in the
explicit-element registration section, exception name reads
Mcp\Exception\InvalidArgumentException), rewrap docs/mcp-elements.md
to 120 chars, and add 0.6.0 CHANGELOG entries covering the
Builder::add() entry point, the new handler interfaces, the new
ExplicitElementLoader, plus three BC breaks (Resource ->
ResourceDefinition rename, ArrayLoader -> ReflectedElementLoader
rename, mismatched-pairing exception swap).
* refactor: address self-review feedback
* CHANGELOG: drop spurious [BC Break] entry for the exception
type Builder::add() raises (method is new in this PR).
* docs/server-builder: revert wrap-only edits to pre-existing
main content; keep 120ch wrapping only for new sections.
* ExplicitElementLoader: trim verbose class docblock.
* ReferenceHandler: rename SPLAT_PARAMETER_NAMES constant to
ARGUMENT_BAG_PARAMETERS.
* refactor: rename CallableHandler to Handler
* style: fix phpdoc param alignment
* refactor: inline $reference->handler access
* fix: maintainer review feedback
docs: mention ExplicitElementLoader in comment
docs: move Drupal reference to README list
docs: drop implementation-only changelog entry
docs: revert rewrap noise from mcp-elements.md
* style: fix phpdoc param alignment
---------
Co-authored-by: Christopher Hertel <mail@christopher-hertel.de>
Co-authored-by: soyuka <soyuka@users.noreply.github.com>
* [Server] Add CORS, DNS rebinding and protocol version middleware
Introduce three PSR-15 middleware for `StreamableHttpTransport` exposed
through a public `StreamableHttpTransport::defaultMiddleware()` factory
composed automatically when no middleware is passed.
- `CorsMiddleware`: secure-by-default (no `Access-Control-Allow-Origin`),
configurable allowlist, reflects matching origin with `Vary: Origin`
to protect shared caches.
- `DnsRebindingProtectionMiddleware`: validates `Origin`/`Host` against
a hostname allowlist (localhost-only by default).
- `ProtocolVersionMiddleware`: rejects requests carrying an unsupported
`Mcp-Protocol-Version` header with `400 Bad Request`.
The transport no longer applies CORS via an `instanceof + array_unshift`
post-hook; the middleware parameter is nullable — `null` installs the
secure defaults, `[]` disables them, and users compose by spreading
`StreamableHttpTransport::defaultMiddleware()`. `SESSION_HEADER` and
`PROTOCOL_VERSION_HEADER` are promoted to public constants so middleware
can reuse them.
BC breaks:
- The `corsHeaders` constructor parameter is removed; the `middleware`
parameter shifts one position. Positional callers passing the old
`corsHeaders` argument must switch to named arguments or drop it.
- Default `Access-Control-Allow-Origin` is no longer `*`.
Addresses #260 (DNS rebinding), #277 (CORS extraction) and #306
(protocol version validation).
* [Server] Address review feedback on middleware defaults
- CorsMiddleware: emit Access-Control-Allow-Methods/Headers only on
preflight per CORS spec; tokenize Vary header to avoid substring
false-positives; add allowCredentials flag that emits
Access-Control-Allow-Credentials and throws when combined with
wildcard origin.
- DnsRebindingProtectionMiddleware: return plain-text 403 body
(HTTP-level rejection, not JSON-RPC); use parse_url(..., PHP_URL_HOST)
for origin parsing; drop redundant unbracketed '::1' from default
allowlist.
- ProtocolVersionMiddleware: use Error::forInvalidParams instead of
forInvalidRequest -- JSON-RPC -32602 fits header-value rejection
better.
- JsonRpcErrorResponse::create now accepts a JsonRpc\Error so callers
choose the error code.
- StreamableHttpTransport: emit a warning log when an explicit empty
middleware list is passed, so operators can spot accidental bypass
of the default security stack.
* [Server] Fix CI: switch warning-log tests to PHPUnit mocks
Replace the anonymous AbstractLogger fixture in
StreamableHttpTransportTest with `$this->createMock(LoggerInterface::class)`
so the test does not declare a `log()` signature at all. That sidesteps
both the psr/log v1 LSP incompatibility (untyped parameter) and the
phpstan `missingType.parameter` complaint in one move, and matches how
the rest of the suite (CallToolHandlerTest etc.) fakes loggers.
* Update src/Server/Transport/Http/Middleware/CorsMiddleware.php
Co-authored-by: Antoine Bluchet <soyuka@users.noreply.github.com>
---------
Co-authored-by: Antoine Bluchet <soyuka@users.noreply.github.com>
- Add a shields.io badge row to the README header (version, CI, PHP
version, license, conformance scores, protocol/spec) to match the
other official MCP SDKs.
- Expand "PHP Libraries Using the MCP SDK" into a curated, alphabetically
sorted list of downstream projects.
- Add tests/Conformance/bin/score.php, a Symfony Console command that
turns a conformance run's --output-dir into a shields.io endpoint JSON.
- Extend the weekly conformance workflow to score each run and publish
the client/server pass-rate to the orphan `badges` branch that the
README badges read.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
#301 added the `title` field to the `Resource`/`ResourceTemplate` schema
classes and the `McpResource`/`McpResourceTemplate` attributes, but the
manual registration methods on `Builder` were never updated — so manual
registration could not set a resource title, unlike tools and prompts.
Add a `$title` parameter between `$name` and `$description` (matching
`addTool()`/`addPrompt()`) and propagate it through `ArrayLoader` when
constructing the `Resource`/`ResourceTemplate` objects.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The MCP specification (2025-11-25) defines an optional title field for
Resource and ResourceTemplate as a human-readable display label, distinct
from name (a short identifier). Tool and Prompt already implement this
field; Resource and ResourceTemplate were missing it.
Changes:
- Add title parameter to Resource and ResourceTemplate constructors
- Add title to fromArray(), jsonSerialize(), and PHPStan type arrays
- Add title to McpResource and McpResourceTemplate attributes
- Pass title through Discoverer when constructing schema objects
- Add tests for deserialization, serialization, and null omission
Fixes#296
Drop the isManual flag from ElementReference and the discovery-state methods
from RegistryInterface. The Registry becomes a flat last-write-wins map;
DiscoveryLoader owns its own owned-set bookkeeping so re-running discovery
only removes elements it itself contributed. Adds ChainLoader for explicit
composition. Manual-over-discovered precedence is preserved via loader
ordering, not a flag on the reference.
The PR pipeline only catches conformance changes when someone opens a
PR. Upstream `@modelcontextprotocol/conformance` releases between PRs
go untested. This adds a weekly cron + `workflow_dispatch` trigger
pinned to `@latest`, and opens a tracking issue on scheduled failures.
Make session GC probability configurable via gcProbability and gcDivisor
parameters, mirroring PHP's session.gc_probability/session.gc_divisor.
Exposed through both SessionManager constructor and Builder::setSession().
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
* fix: allow URIs without double slash in Resource and ResourceTemplate (RFC 3986)
The URI regex required "://" after the scheme, rejecting valid URIs
like urn:isbn:123, mailto:user@example.com, config:key, and data:
URIs. RFC 3986 only requires ":" after the scheme; the "//" is part
of the authority component which is optional.
Changed both Resource.URI_PATTERN and ResourceTemplate.URI_TEMPLATE_PATTERN
to require ":" instead of "://".
Adds data-provider tests for URIs without double slash in both classes.
Fixes#293
* Reword unit tests
---------
Co-authored-by: Christopher Hertel <mail@christopher-hertel.de>
* Add optional namePatterns capability to discovery so that the default scan for `*.php` files can be overridden.
* add default to namePatterns docs
* add an error check if we manage to pass in an empty list of patterns
* add unit tests (and a static entry to classmap for composer.json so it autoloads from a non .php file for the test)
* run php-cs-fixer
* change how default namePatterns are set and make sure all the annotations are correct
* Fix bad whitespace
Co-authored-by: Christopher Hertel <mail@christopher-hertel.de>
* switch to a const default instead of nullable with a check in the function to convert null to a default
* make sure we handle empty arrays
* formatting fixes
* Apply suggestion from @chr-hertel
Co-authored-by: Christopher Hertel <mail@christopher-hertel.de>
* Add changelog entry
---------
Co-authored-by: Christopher Hertel <mail@christopher-hertel.de>
2026-05-08 09:48:29 +02:00
470 changed files with 37216 additions and 4779 deletions
existing=$(gh issue list --label conformance-weekly --state open --json number --jq '.[0].number // empty')
if [ -n "$existing" ]; then
gh issue comment "$existing" --body "New failure on $(date -u +%FT%TZ): $RUN_URL"
else
gh issue create \
--title '[conformance] Weekly conformance run failed' \
--label conformance-weekly \
--body "Conformance against the newest published \`@modelcontextprotocol/conformance\` failed.
- Run:$RUN_URL
- Trigger:$EVENT_NAME
- Date:$(date -u +%FT%TZ)
Upstream likely published a release whose scenarios the SDK does not satisfy. Either fix the SDK, update the conformance fixtures, or add the new failure to the matching \`tests/Conformance/conformance-baseline-<spec-version>.yml\`."
All notable changes to `mcp/sdk` will be documented in this file.
0.9.0
-----
* [BC Break] Remove the `providerClass` argument of `#[CompletionProvider]`. Use `provider:`, which takes the same class-string and is now the first positional argument.
* Add `HttpTransport::getSessionId()` to read the server-minted `Mcp-Session-Id`: a request-scoped caller can persist it and pass it back through the constructor's `$headers` on a later transport. Always `null` on `2026-07-28`, which removed protocol-level sessions.
* Fix OIDC discovery rejecting issuers with a trailing slash (e.g. Authentik, Auth0).
0.8.0
-----
* Support for MCP spec version 2026-07-28 incl. stateless HTTP transport
* Negotiate the protocol revision during `initialize`: the server counter-offers a revision it supports and the client fails the handshake instead of continuing on an unagreed one. Adds `Client::getProtocolVersion()`.
* Serve both protocol eras from one endpoint: `StreamableHttpTransport` classifies each request (`InboundClassifier`) and routes it to the matching dispatcher, so one URL answers modern and handshake-era clients alike. `Builder::withoutModernEra()` opts out, `Builder::setModernVersions()` narrows the modern leg.
* Speak the lifecycle from the client: `Client` opens with `server/discover`, stamps `_meta` with version, capabilities and client info, and sends the `Mcp-Method`/`Mcp-Name`/`Mcp-Param-*` headers (via `Client\Stateless\ToolCatalog`). `Schema\Wire\McpHeader` holds the shared header names.
* Add multi round-trip requests (SEP-2322): a handler returning `InputRequiredResult` yields `resultType: "input_required"` with an opaque, signed `requestState` (`RequestStateCodec`, key via `Builder::setRequestState()`); the client retries with `inputResponses`, read through `RequestContext::getInputContext()` and its typed `elicitResult()`/`samplingResult()`/`rootsResult()`. On the client, `InputRequestResolver` answers such results automatically from the host's elicitation, sampling and roots handlers.
* Serve `ClientGateway::elicit()`/`elicitUrl()` on every revision: where the client cannot be asked mid-request, `Server\Stateless\ElicitationReplay` turns the ask into `input_required` and resumes once re-sent — the handler is entered once per ask. `InputRequiredShim` does the reverse for handshake-era clients. `sample()`/`listRoots()` raise a `LogicException` on `2026-07-28`, which removed them.
* Run stateless handlers in a fiber so `$gateway->progress()`/`log()` stream over SSE when the handler emits something and the client accepts `text/event-stream`; honour `io.modelcontextprotocol/logLevel` (SEP-2575). Adds `LoggingLevel::severity()`/`isAtLeast()`.
* Deliver notifications on `subscriptions/listen` (SEP-2575) via `NotificationBusInterface` — `InMemoryNotificationBus` for persistent runtimes, `Psr16NotificationBus` for PHP-FPM — set with `Builder::setNotificationBus()`; `Builder::setSubscriptionLifetime()` replaces the hard-coded 30s ceiling.
* Validate the standard request headers (SEP-2243) with `StandardHeaderValidator` (`Builder::setHeaderValidator()`), answering `-32020` when they contradict the body.
* Add `Wire\CachePolicy` (`Builder::setCachePolicy()`) for SEP-2549 caching hints; defaults to `ttlMs: 0, cacheScope: private`. A `ReadResourceResult` may override with its own values.
* Carry W3C trace context (SEP-414): `traceparent`/`tracestate`/`baggage` from `_meta` are exposed via `RequestContext::getTraceContext()` and echoed onto the request's notifications.
* Close the schema gaps for `2025-06-18`/`2025-11-25` and add the `2026-07-28` surface (SEP-2106): url-mode elicitation (`ClientGateway::elicitUrl()`/`supportsElicitationUrl()`), `Implementation::title`, and `outputSchema`/`structuredContent` accepting any JSON value.
* Deprecate Roots, Sampling and Logging (SEP-2577, earliest removal `2027-07-28`); they keep working but trigger a deprecation notice.
* [BC Break] Answer a not-found subject with `-32602` instead of `-32002` (SEP-2164): `resources/read` picks the code by revision (`-32602` from `2026-07-28` on), `prompts/get`, `completion/complete` and `tools/call` switch on every revision. Adds `ProtocolVersion::usesInvalidParamsForResourceNotFound()`.
* [BC Break] A list-shaped tool result is only sent as `structuredContent` on `2026-07-28`+; older revisions keep the JSON-encoded value in `content`.
* [BC Break] Add the extensions framework (SEP-2133) MCP Apps sits on: `ExtensionInterface::getId()` returns an `ExtensionIdentifier` value object, and the interface gains `getMessages()`/`getRequestHandlers()` (extend `AbstractExtension` to skip both). `MessageFactory::make()` takes an `$additional` message list; `RequestHandlerInterface`'s result template is covariant. `ServerExtensionInterface` is replaced by the side-agnostic `Schema\Extension\ExtensionInterface`.
* Add sampling-with-tools: sampling requests can carry tools and tool-choice preferences, messages support tool-use/tool-result blocks, and clients advertise `sampling.context`/`sampling.tools` (`ClientGateway::supportsSamplingTools()`/`supportsSamplingContext()`). Requests violating the tool-flow rules are rejected with a JSON-RPC error.
* [BC Break] `SamplingMessage::$content` and `CreateSamplingMessageResult::$content` may be a list of content blocks — use `getContentBlocks()`. `CreateSamplingMessageResult` rejects roles other than `assistant` and empty content.
* Add client-side Roots support (`RootsCallbackInterface`, `Client::sendRootsListChanged()`) and server-side `ClientGateway::listRoots()`/`supportsRoots()`/`supportsSampling()`.
* Add `Schema\Content\ResourceLink` to reference a resource by URI in tool results and prompt messages.
* [BC Break] `Schema\JsonRpc\Error` accepts `null` as `$id`; an unreadable id now omits the member instead of sending `"id": ""`. `MessageFactory` decodes a missing or null id as an id-less error.
* Preserve the request `id` on an invalid-but-parseable message (`-32600`) via `InvalidInputMessageException::getRequestId()`.
* [BC Break] Drop the SDK-only name pattern on `ResourceDefinition`/`ResourceTemplate``$name`; the spec allows any string.
* Log expected tool failures (`ToolCallException`) at debug level instead of error.
* Add `annotations` to `ImageContent`.
* Fix empty tool/resource schemas serializing as `[]` instead of `{}`.
* Fix `PromptResultFormatter` dropping `annotations`, `_meta` and `mimeType` for plain-array content.
0.7.0
-----
* Add client-side elicitation support: `ElicitationCallbackInterface`, `ElicitationRequestHandler`, and `ElicitationException` let clients respond to server elicitation requests.
* Defer element loading to the first registry read: loaders now run at request time (first `has*`/`get*` call) instead of eagerly at `Builder::build()`, fixing empty registries under persistent runtimes (e.g. FrankenPHP worker mode) where a loader's data source is not ready at build time. Adds `Builder::setLazyLoading()` (default on), a public `Registry::load()`, and an optional `LoaderInterface` constructor argument on `Registry`.
* [BC Break] Element loading is lazy by default: loader failures now surface on the first request rather than at `Builder::build()`, and `initialize` advertises capabilities from the configured sources rather than the loaded registry. Call `Builder::setLazyLoading(false)` to restore eager build-time loading.
* Allow `[$instance, 'methodName']` as an element handler in `Builder::addTool()`, `addResource()`, `addResourceTemplate()`, and `addPrompt()`. Unblocks handlers with constructor dependencies that the container-less `new $className()` fallback cannot build.
* Always emit an `items` schema for array tool parameters: untyped arrays get `items: {}` and nullable typed arrays (e.g. `string[]|null`) keep their element type. Fixes strict clients rejecting tools with "array type must have items" (#151).
* Harden JSON-RPC input parsing: single-message vs batch is now decided from the decoded JSON type (object → single, list array → batch) instead of the raw first byte. Scalars, empty payloads, and non-object batch elements are surfaced as `InvalidInputMessageException` entries instead of triggering warnings or a `TypeError`.
* Add `maxBatchSize` (default `100`) to `MessageFactory` — oversized JSON-RPC batches are rejected before any message is constructed, guarding against amplification.
* Add `maxBodyBytes` (default 4 MiB) to `StreamableHttpTransport` — POST bodies exceeding the cap are rejected with `413`. Unknown-size/chunked bodies are read incrementally and stopped at the cap so they cannot exhaust memory.
* Reject malformed `Mcp-Session-Id` headers with a `400` response: a repeated header or a value that is not a valid UUID is now rejected up front instead of surfacing as an uncaught `Uuid::fromString()` error.
* Extract RFC 9728 metadata serving into `ProtectedResourceMetadataHandler`, a transport-neutral PSR-15 `RequestHandlerInterface` that can be mounted directly as a Symfony/Laravel controller; `ProtectedResourceMetadataMiddleware` now delegates to it (no BC break).
0.6.0
-----
* Add `Builder::add(Tool|ResourceDefinition|ResourceTemplate|Prompt $definition, ElementHandlerInterface $handler)` for explicit registration of elements whose schema is only known at runtime.
* Add handler interfaces `ToolHandlerInterface`, `ResourceHandlerInterface`, `ResourceTemplateHandlerInterface`, `PromptHandlerInterface`, and the `ElementHandlerInterface` marker.
* [BC Break] Renamed `Mcp\Schema\Resource` to `Mcp\Schema\ResourceDefinition`. No alias.
* [BC Break] Renamed `Mcp\Capability\Registry\Loader\ArrayLoader` to `Mcp\Capability\Registry\Loader\ReflectedElementLoader`.
* [BC Break] Bump default protocol version to `2025-11-25`
* Add support for MCP Apps extension in schema and server
* Add `extensions` to `ServerCapabilities` and `ClientCapabilities` and `Builder::enableExtension()`
* Allow overriding the default name pattern for Discovery
* Add `RegistryInterface::hasTool()`, `hasResource()`, `hasResourceTemplate()`, `hasPrompt()` — by-name existence checks.
* `DiscoveryLoader` now refreshes only its own previously written entries; manual registrations (via `Builder::addTool()` etc. or runtime `$registry->registerTool()` calls) survive rediscovery, and a same-name manual registration takes precedence over discovery on collision.
* [BC Break] Removed `ElementReference::$isManual` public property and the `bool $isManual` parameter from all `*Reference` constructors. Origin tracking is no longer carried on the element; manual-over-discovered precedence is encoded by loader execution order.
* [BC Break] `RegistryInterface::registerTool()`, `registerResource()`, `registerResourceTemplate()`, `registerPrompt()` lost their trailing `bool $isManual = false` parameter. Callers using positional arguments must drop the flag.
* [BC Break] Removed `RegistryInterface::clear()`, `getDiscoveryState()`, `setDiscoveryState()`. Rediscovery now goes through `DiscoveryLoader::load()` directly.
* `Registry::register*()` semantics changed to plain last-write-wins (overwrites silently) and the methods now return the stored `*Reference`. The previous "discovered registration is ignored when a manual one already exists" precedence rule still applies, but is now enforced by `DiscoveryLoader` via reference-identity tracking — and still emits a debug log when a discovery is skipped due to a conflicting registration.
* Add optional `title` parameter to `Builder::addResource()` and `Builder::addResourceTemplate()` for MCP spec compliance
* [BC Break] `Builder::addResource()` signature changed — `$title` parameter added between `$name` and `$description`. Callers using positional arguments must switch to named arguments.
* [BC Break] `Builder::addResourceTemplate()` signature changed — `$title` parameter added between `$name` and `$description`. Callers using positional arguments must switch to named arguments.
* Add `CorsMiddleware`, `DnsRebindingProtectionMiddleware`, and `ProtocolVersionMiddleware` for `StreamableHttpTransport`, composed automatically as the default stack via `StreamableHttpTransport::defaultMiddleware()`
* [BC BREAK] `StreamableHttpTransport` constructor: `$corsHeaders` parameter removed; CORS is now configured via `CorsMiddleware`. The `$middleware` parameter is nullable — `null` (or omitted) installs the default stack; `[]` disables all defaults. Default `Access-Control-Allow-Origin` is no longer set (was `*`).
* [BC Break] `ResourceDefinition::__construct()` signature changed — `$title` parameter added between `$name` and `$description`. Callers using positional arguments must switch to named arguments.
* [BC Break] `ResourceTemplate::__construct()` signature changed — `$title` parameter added between `$name` and `$description`. Callers using positional arguments must switch to named arguments.
* [BC Break] `McpResource` and `McpResourceTemplate` attribute signatures changed — `$title` parameter added between `$name` and `$description`. Callers using positional arguments must switch to named arguments.
DOCS_RUN= uv run --no-project --with-requirements requirements-docs.txt --
deps-stable:
composer update --prefer-stable
@ -18,6 +29,9 @@ tests:
unit-tests:
vendor/bin/phpunit --testsuite=unit
integration-tests:
vendor/bin/phpunit --testsuite=integration
inspector-tests:
vendor/bin/phpunit --testsuite=inspector
@ -27,11 +41,33 @@ conformance-server:
docker compose -f tests/Conformance/Fixtures/docker-compose.yml up -d
@echo "Waiting for server to start..."
@sleep 5
cd tests/Conformance && npx @modelcontextprotocol/conformance server --url http://localhost:8000/ ||true
rm -rf tests/Conformance/results
cd tests/Conformance &&$(CONFORMANCE) server --url http://localhost:8000/ --suite all --spec-version 2025-11-25 --expected-failures conformance-baseline-2025-11-25.yml --output-dir results ||true
php tests/Conformance/score.php server 2025-11-25
docker compose -f tests/Conformance/Fixtures/docker-compose.yml down
conformance-client:
cd tests/Conformance && npx @modelcontextprotocol/conformance client --command "php $(CURDIR)/tests/Conformance/client.php" --suite all --expected-failures conformance-baseline.yml ||true
rm -rf tests/Conformance/results
cd tests/Conformance &&$(CONFORMANCE) client --command "php $(CURDIR)/tests/Conformance/client.php" --suite all --spec-version 2025-11-25 --expected-failures conformance-baseline-2025-11-25.yml --output-dir results ||true
docker compose -f tests/Conformance/Fixtures/docker-compose.yml up -d
@echo "Waiting for server to start..."
@sleep 5
rm -rf tests/Conformance/results-2026-07-28
cd tests/Conformance &&$(CONFORMANCE) server --url http://localhost:8000/ --suite all --spec-version 2026-07-28 --expected-failures conformance-baseline-2026-07-28.yml --output-dir results-2026-07-28 ||true
php tests/Conformance/score.php server 2026-07-28 results-2026-07-28
docker compose -f tests/Conformance/Fixtures/docker-compose.yml down
conformance-draft-client:
rm -rf tests/Conformance/results-2026-07-28
cd tests/Conformance &&$(CONFORMANCE) client --command "php $(CURDIR)/tests/Conformance/client.php" --suite all --spec-version 2026-07-28 --expected-failures conformance-baseline-2026-07-28.yml --output-dir results-2026-07-28 ||true
The official PHP SDK for the Model Context Protocol (MCP). It provides a framework-agnostic API for implementing MCP
servers and clients in PHP — tools, resources, prompts, STDIO and HTTP transports, sessions, authorization, and both
protocol eras (the `initialize` handshake and the stateless `2026-07-28` revision).
This project represents a collaboration between [the PHP Foundation](https://thephp.foundation/) and the [Symfony project](https://symfony.com/). It adopts
development practices and standards from the Symfony project, including [Coding Standards](https://symfony.com/doc/current/contributing/code/standards.html) and the
@ -10,18 +16,16 @@ development practices and standards from the Symfony project, including [Coding
Until the first major release, this SDK is considered [experimental](https://symfony.com/doc/current/contributing/code/experimental.html), please see the [roadmap](./ROADMAP.md) for
planned next steps and features.
## Table of Contents
## Protocol Conformance
- [Installation](#installation)
- [Overview](#overview)
- [Server SDK](#server-sdk)
- [Client SDK](#client-sdk)
- [Documentation](#documentation)
- [External Resources](#external-resources)
- [PHP Libraries Using the MCP SDK](#php-libraries-using-the-mcp-sdk)
- [Contributing](#contributing)
- [Credits](#credits)
- [License](#license)
Measured weekly against the [MCP Conformance Test Framework](https://github.com/modelcontextprotocol/conformance),
as passing checks out of total. The `2026-07-28` scores run against the framework's `alpha` releases, so they move as
Building something on top of the SDK? Open a pull request to add it to this list.
## Contributing
We are passionate about supporting contributors of all levels of experience and would love to see you get involved in the project.
See the [Contributing Guide](CONTRIBUTING.md) to get started before you [report issues](https://github.com/modelcontextprotocol/php-sdk/issues) and [send pull requests](https://github.com/modelcontextprotocol/php-sdk/pulls).
We are passionate about supporting contributors of all levels of experience and would love to see you get involved in
the project. Start by [reporting issues](https://github.com/modelcontextprotocol/php-sdk/issues) or
[sending pull requests](https://github.com/modelcontextprotocol/php-sdk/pulls). See
[CONTRIBUTING.md](CONTRIBUTING.md) for development setup, coding standards, and what to run before
| Delegation / proxy to an upstream AS | Forwards `/authorize` and `/token` to your existing IdP | Shipped (`OAuthProxyMiddleware`) | **IN scope — delegation ONLY** |
| Authorization Server / Identity Provider (IdP) | Mints its own tokens, registers clients, runs login and consent | Absent | **OUT of scope** |
The SDK repeatedly receives pull requests that move it toward becoming a full OAuth 2.1
**authorization server** — asking the MCP server to mint its own access and refresh tokens,
register clients, and run login and consent flows, that is, to become an Identity Provider.
The most explicit example is [#373](https://github.com/modelcontextprotocol/php-sdk/pull/373)
("[Server] Add native OAuth 2.1 authorization server", ~3,400 lines across 50+ files), but the
direction has crept in incrementally rather than in one PR.
A contributing factor is that the SDK already exposes the *authorization-server endpoints in
proxy form*, and that surface has grown:
[#221](https://github.com/modelcontextprotocol/php-sdk/pull/221) added the OAuth resource-server
middleware, and [#269](https://github.com/modelcontextprotocol/php-sdk/pull/269) added Dynamic
Client Registration (RFC 7591). Today `OAuthProxyMiddleware` answers `/authorize`, `/token`, and
`/.well-known/oauth-authorization-server`, and DCR endpoints exist. The shape of that surface
invites contributors to "finish the job" by backing those endpoints with a real token issuer.
It is not scaffolding to be completed — it is a delegating proxy, and that is the whole of its
intent.
## Decision
**The MCP server is an OAuth 2.1 Resource Server that MAY delegate to an upstream
authorization server. It will NOT issue tokens or act as an Identity Provider.**
Concretely:
- The SDK validates bearer tokens, serves RFC 9728 Protected Resource Metadata, and emits
`WWW-Authenticate` challenges. This is the Resource Server role and is fully supported.
- The SDK MAY delegate `/authorize` and `/token` to an upstream authorization server via
`OAuthProxyMiddleware`. This is delegation only: the middleware redirects the browser to
the upstream `/authorize` endpoint and proxies `/token` requests to the upstream token
endpoint. It never mints, signs, stores, or rotates tokens of its own.
- The SDK will NOT implement an authorization server: no token issuance, no token signing or
key management, no login UI, no consent UI, no authorization-code or refresh-token storage,
no first-party Dynamic Client Registration acting as an issuer.
Pull requests that add authorization-server / IdP behavior are declined by reference to this
ADR.
## Rationale
- **Security liability.** Issuing tokens means owning signing-key generation, storage, and
rotation; authorization-code and refresh-token persistence; refresh-token rotation and
replay detection; and consent. A defect in any of these is a credential-issuance
vulnerability affecting every consumer of the SDK. This is precisely the surface an MCP SDK
should not own.
- **RFC footprint.** A correct authorization server must implement and keep current with
Check out [`examples/server/custom-method-handlers/server.php`](https://github.com/modelcontextprotocol/php-sdk/blob/main/examples/server/custom-method-handlers/server.php) for a complete example showing how to implement
custom `tools/list` and `tools/call` request handlers independently of the registry.
The MCP SDK provides a PSR-14 compatible event system that allows you to hook into the server's lifecycle. Events enable request/response modification, and other user-defined behaviors.
## Setup
Configure an event dispatcher when building your server:
```php
use Mcp\Event\RequestEvent;
use Mcp\Server;
use Symfony\Component\EventDispatcher\EventDispatcher;
$dispatcher = new EventDispatcher();
// Register your listeners
$dispatcher->addListener(RequestEvent::class, function (RequestEvent $event) {
// Handle any incoming request
if ($event->getMethod() === 'tools/call') {
// Handle tool call requests specifically
}
});
$server = Server::builder()
->setEventDispatcher($dispatcher)
->build();
```
## Protocol Events
The SDK dispatches 4 broad event types at the protocol level, allowing you to observe and modify all server operations:
### RequestEvent
**Dispatched**: When any request is received from the client, before it's processed by handlers.
**Properties**:
- `getRequest(): Request` - The incoming request
- `setRequest(Request $request): void` - Modify the request before processing
- `getSession(): SessionInterface` - The current session
- `getMethod(): string` - Convenience method to get the request method
### ResponseEvent
**Dispatched**: When a successful response is ready to be sent to the client, after handler execution.
**Properties**:
- `getResponse(): Response` - The response being sent
- `setResponse(Response $response): void` - Modify the response before sending
- `getRequest(): Request` - The original request
- `getSession(): SessionInterface` - The current session
- `getMethod(): string` - Convenience method to get the request method
### ErrorEvent
**Dispatched**: When an error occurs during request processing.
**Properties**:
- `getError(): Error` - The error being sent
- `setError(Error $error): void` - Modify the error before sending
- `getRequest(): Request` - The original request. Messages that fail to parse are rejected before this event, so a listener never sees them.
- `getThrowable(): ?\Throwable` - The exception that caused the error (if any)
- `getSession(): SessionInterface` - The current session
### NotificationEvent
**Dispatched**: When a notification is received from the client, before it's processed by handlers.
**Properties**:
- `getNotification(): Notification` - The incoming notification
- `setNotification(Notification $notification): void` - Modify the notification before processing
- `getSession(): SessionInterface` - The current session
- `getMethod(): string` - Convenience method to get the notification method
## List Change Events
These events are dispatched when the lists of available capabilities change: