* [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.
* 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>
* 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.
* Never let a throwable escape Protocol::processInput()
* Validate payload types in the schema hydration methods
* Stop leaking handler exception messages to the client
- Add ElicitAction enum (accept/decline/cancel)
- Add schema definitions for elicitation fields (string, number, boolean, enum)
- Add ElicitationSchema wrapper for requestedSchema
- Add ElicitRequest and ElicitResult for elicitation/create method
- Update ClientCapabilities to support elicitation capability
- Add elicit() method to ClientGateway
- Store client capabilities in session during initialization
- Add comprehensive unit tests
- Add elicitation example server with book_restaurant, confirm_action, and collect_feedback tools