[Schema][Server] Close the 2025-11-25 schema gaps and add the 2026-07-28 surface (#421)

* [Schema][Server] Close the 2025-11-25 schema gaps and add the 2026-07-28 surface

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

From 2025-11-25, elicitation gains modes. ElicitationMode splits `form` —
build a form from the requested schema — from `url`, which sends the user out
of band and returns only the accept/decline/cancel outcome. That is why
requestedSchema becomes optional and `url` appears beside it.
ClientCapabilities learns the matching sub-capabilities, where an
`elicitation` naming no mode declares form, the only shape that existed
before. Icon gains `theme` from the same revision, and Implementation gains
the `title` BaseMetadata has carried since 2025-06-18.

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

The same revision defines three error codes (-32020 header mismatch, -32021
missing required client capability, -32022 unsupported protocol version).
ProtocolVersionMiddleware switches to the last of them, so a rejected version
carries the supported set as structured data the client can retry from rather
than only as prose.

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

`Implementation::title` reached the typed constructor unchecked, so malformed
wire data raised a TypeError instead of InvalidArgumentException.

`ToolUseContent::input` accepted a list and serialized it as a JSON array,
where the protocol requires an object. The empty array stays exempt: it is
also an empty map and still emits `{}`.

`ToolChoice` and `ElicitRequest` read their mode with isset(), which is false
for an explicit null, so `{"mode": null}` silently became the default instead
of being rejected. Both use array_key_exists() now, letting the existing type
check refuse null.

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

`Implementation::title` could be parsed but never sent: neither
`Client\Builder::setClientInfo()` nor `Server\Builder::setServerInfo()`
accepted one, so every SDK user emitted null.

Both gain a trailing optional `$title`. On the server it sits where the
Implementation constructor already puts it, so existing positional calls keep
their meaning; the client builder forwards it by name, leaving the icons and
websiteUrl slots defaulted.

* [Schema] Serialize every non-null structuredContent

The object-only hydration guard was never that: `!is_array()` admitted
`[1, 2, 3]` and `[]`, which serialize to JSON arrays, while rejecting the
scalars 2026-07-28 permits. The truthiness emission gate was backwards in the
same way — it dropped `[]`, `0`, `false` and `""`, yet emitted lists, strings
and an empty stdClass.

Hydration now accepts any JSON value, and `null` alone means absent, matching
`ToolResultContent` which already carries this field that way. Which values a
given revision permits is a question for version-aware serialization, which
results cannot answer yet.

* [Schema][Server] Make url elicitation reachable

`ElicitRequest::forUrl()` built a request no SDK user could send: the only
public gateway method always constructed form mode from an ElicitationSchema,
and `request()` is private.

`elicitUrl()` joins `elicit()`, and both funnel through one send path that
hydrates the result with the request's own mode. Without that, a url-mode
accept — contentless by design — threw, because ElicitResult requires content
whenever the action is accept. The result carries no discriminator of its own,
so the mode has to come from the request it answers.

`supportsElicitationUrl()` reports whether the client named the mode, reusing
the sub-capability reader the sampling checks already use.

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

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

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

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

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

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

`Tool::jsonSerialize()` emitted the empty root schema as `[]`, which is not a
schema at all. Sub-schemas already had this treatment.
This commit is contained in:
Christopher Hertel 2026-08-17 23:09:39 +02:00 • committed by GitHub
commit f84d8f5aa9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
32 changed files with 1205 additions and 91 deletions

View file

@ -16,6 +16,7 @@ All notable changes to `mcp/sdk` will be documented in this file.
* Add sampling with tools support: sampling requests now accept tools and tool-choice preferences, messages support tool-use/tool-result content blocks and multiple content blocks, and clients can advertise the `sampling.context` and `sampling.tools` capabilities. Adds `ClientGateway::supportsSamplingTools()` / `supportsSamplingContext()` to check the sub-capabilities before sending, and `CreateSamplingMessageRequest::validateToolFlow()`, which asserts the spec's tool-flow rules across the whole message list — the client handler rejects a violating request with `-32602` instead of leaving it unanswered, and the gateway refuses to send one.
* [BC Break] `SamplingMessage::$content` and `CreateSamplingMessageResult::$content` may now hold a list of content blocks instead of a single one, so code reading them directly must handle both. Use the new `getContentBlocks()` on either class to always get a list.
* [BC Break] `CreateSamplingMessageResult` now rejects any role other than `assistant`, and rejects empty content, as the specification requires.
* Close the schema gaps left in `2025-06-18` and `2025-11-25` and add the non-sampling part of `2026-07-28`, all of it optional and defaulting to current behaviour. From `2025-11-25`: url-mode elicitation (`ElicitationMode`, `ElicitRequest::forUrl()`, `ClientGateway::elicitUrl()` and `supportsElicitationUrl()`), whose result carries the user's action alone — `ElicitResult::fromArray()` takes the request's mode, requires content only in form mode and rejects it in url mode; the `elicitation.form` / `elicitation.url` sub-capabilities, where a capability naming no mode declares form; and `Icon::theme`. From `2025-06-18`: `Implementation::title`, now settable through `Client\Builder::setClientInfo()` and `Server\Builder::setServerInfo()`. From `2026-07-28` (SEP-2106): `Tool::outputSchema` and `CallToolResult::structuredContent` accept any JSON value — `ToolReference::extractStructuredContent()` keeps a scalar when the tool declared an outputSchema and the negotiated revision allows it, and `CallToolHandler` warns when a self-built result carries a value the revision does not permit — plus the revision's three error codes (`-32020` header mismatch, `-32021` missing required client capability, `-32022` unsupported protocol version), the last of which `ProtocolVersionMiddleware` returns with the supported set as structured data the client can retry from.
0.7.0
-----

View file

@ -71,11 +71,11 @@ class ToolReference extends ElementReference
* newest handshake revision, whose stricter rule is what
* every revision reachable through `initialize` requires
*
* @return array<array-key, mixed>|null the structured content, or null if not extractable
* @return mixed the structured content, or null if not extractable
*
* @throws \JsonException if JSON encoding fails for non-Content array/object results
*/
public function extractStructuredContent(mixed $toolExecutionResult, ?ProtocolVersion $protocolVersion = null): ?array
public function extractStructuredContent(mixed $toolExecutionResult, ?ProtocolVersion $protocolVersion = null): mixed
{
$objectOnly = ($protocolVersion ?? ProtocolVersion::latestHandshake())->requiresObjectStructuredContent();
@ -111,11 +111,9 @@ class ToolReference extends ElementReference
);
// A plain object always encodes to a JSON object, but `JsonSerializable`
// can hand back anything. A scalar is dropped whatever the revision
// allows: `CallToolResult::$structuredContent` is typed `?array` and
// cannot carry one.
// can hand back anything, scalars included.
if (!\is_array($decoded)) {
return null;
return $this->acceptsScalarStructuredContent($objectOnly) ? $decoded : null;
}
if ($objectOnly && array_is_list($decoded)) {
@ -125,6 +123,20 @@ class ToolReference extends ElementReference
return $decoded;
}
return null;
// A scalar is structured content only from SEP-2106 on, and only when the
// tool declared an outputSchema: without one, every string-returning tool
// would start advertising a duplicate of its own `content`.
return $this->acceptsScalarStructuredContent($objectOnly) && \is_scalar($toolExecutionResult)
? $toolExecutionResult
: null;
}
/**
* Whether the negotiated revision and the tool's own declaration together allow
* a non-object `structuredContent`.
*/
private function acceptsScalarStructuredContent(bool $objectOnly): bool
{
return !$objectOnly && null !== $this->tool->outputSchema;
}
}

View file

@ -30,6 +30,7 @@ final class Builder
private string $name = 'mcp-php-client';
private string $version = '1.0.0';
private ?string $description = null;
private ?string $title = null;
private ?ProtocolVersion $protocolVersion = null;
private ?ClientCapabilities $capabilities = null;
private int $initTimeout = 30;
@ -45,12 +46,15 @@ final class Builder
/**
* Set the client name and version.
*
* @param ?string $title Display name for UI and end-user contexts. Falls back to $name when absent.
*/
public function setClientInfo(string $name, string $version, ?string $description = null): self
public function setClientInfo(string $name, string $version, ?string $description = null, ?string $title = null): self
{
$this->name = $name;
$this->version = $version;
$this->description = $description;
$this->title = $title;
return $this;
}
@ -152,6 +156,7 @@ final class Builder
$this->name,
$this->version,
$this->description,
title: $this->title,
);
$config = new Configuration(

View file

@ -24,9 +24,12 @@ class ClientCapabilities implements \JsonSerializable
* @param ?array<string, mixed> $extensions protocol extensions the client supports (e.g. io.modelcontextprotocol/ui)
* @param ?bool $samplingContext the `sampling.context` sub-capability
* @param ?bool $samplingTools the `sampling.tools` sub-capability
* @param ?bool $elicitationForm The `elicitation.form` sub-capability. Implied by declaring
* `elicitation` without naming any mode.
* @param ?bool $elicitationUrl the `elicitation.url` sub-capability
*
* The two sampling sub-capabilities trail `extensions` rather than sitting next to
* `sampling` so that existing positional calls keep working. Pass them by name.
* The sub-capabilities trail `extensions` rather than sitting next to `sampling` and
* `elicitation` so that existing positional calls keep working. Pass them by name.
*/
public function __construct(
public readonly ?bool $roots = false,
@ -37,6 +40,8 @@ class ClientCapabilities implements \JsonSerializable
public readonly ?array $extensions = null,
public readonly ?bool $samplingContext = null,
public readonly ?bool $samplingTools = null,
public readonly ?bool $elicitationForm = null,
public readonly ?bool $elicitationUrl = null,
) {
}
@ -46,7 +51,7 @@ class ClientCapabilities implements \JsonSerializable
* listChanged?: bool,
* },
* sampling?: array{context?: mixed, tools?: mixed}|object,
* elicitation?: bool,
* elicitation?: array{form?: mixed, url?: mixed}|object|bool,
* experimental?: array<string, mixed>,
* extensions?: array<string, mixed>,
* } $data
@ -78,8 +83,15 @@ class ClientCapabilities implements \JsonSerializable
}
$elicitation = null;
$elicitationForm = null;
$elicitationUrl = null;
if (isset($data['elicitation'])) {
$elicitation = true;
$elicitationUrl = self::namesMode($data['elicitation'], 'url');
// Form mode is the backwards-compatible default: an `elicitation` capability
// naming no mode at all means form, the only shape that existed before `url`.
// Naming any mode is an explicit statement, so `{"url": {}}` is not form.
$elicitationForm = self::namesMode($data['elicitation'], 'form') || !$elicitationUrl;
}
return new self(
@ -91,9 +103,28 @@ class ClientCapabilities implements \JsonSerializable
\is_array($data['extensions'] ?? null) ? $data['extensions'] : null,
$samplingContext,
$samplingTools,
$elicitationForm,
$elicitationUrl,
);
}
/**
* A mode is declared by the presence of a (possibly empty) object, so only the
* key matters — not whatever it holds. A boolean `elicitation` names none.
*/
private static function namesMode(mixed $capability, string $name): bool
{
if (\is_array($capability)) {
return \array_key_exists($name, $capability);
}
if (\is_object($capability)) {
return property_exists($capability, $name);
}
return false;
}
/**
* @return array{
* roots?: object,
@ -123,8 +154,14 @@ class ClientCapabilities implements \JsonSerializable
}
}
if ($this->elicitation) {
if ($this->elicitation || $this->elicitationForm || $this->elicitationUrl) {
$data['elicitation'] = new \stdClass();
if ($this->elicitationForm) {
$data['elicitation']->form = new \stdClass();
}
if ($this->elicitationUrl) {
$data['elicitation']->url = new \stdClass();
}
}
if ($this->experimental) {

View file

@ -28,6 +28,10 @@ final class ToolUseContent extends Content
public readonly array $input,
public readonly ?array $meta = null,
) {
if ([] !== $input && array_is_list($input)) {
throw new InvalidArgumentException('ToolUseContent "input" must be a map of argument names, not a list.');
}
parent::__construct('tool_use');
}
@ -45,12 +49,15 @@ final class ToolUseContent extends Content
if (!isset($data['input']) || !\is_array($data['input'])) {
throw new InvalidArgumentException('Missing or invalid "input" in ToolUseContent data.');
}
if (isset($data['_meta']) && !\is_array($data['_meta'])) {
throw new InvalidArgumentException('Invalid "_meta" in ToolUseContent data.');
}
return new self(
$data['id'],
$data['name'],
$data['input'],
isset($data['_meta']) && \is_array($data['_meta']) ? $data['_meta'] : null,
$data['_meta'] ?? null,
);
}

View file

@ -0,0 +1,31 @@
<?php
/*
* This file is part of the official PHP MCP SDK.
*
* A collaboration between Symfony and the PHP Foundation.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Mcp\Schema\Enum;
/**
* How the client should collect the information an elicitation asks for.
*
* @author Christopher Hertel <mail@christopher-hertel.de>
*/
enum ElicitationMode: string
{
/**
* Present a form built from the requested schema, and return the filled values.
*/
case Form = 'form';
/**
* Send the user to a URL to complete the interaction out of band. The result
* carries no content — only whether the user accepted, declined, or cancelled.
*/
case Url = 'url';
}

View file

@ -0,0 +1,25 @@
<?php
/*
* This file is part of the official PHP MCP SDK.
*
* A collaboration between Symfony and the PHP Foundation.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Mcp\Schema\Enum;
/**
* The background an icon is designed to be displayed against.
*
* When absent, the icon is assumed to work against any background.
*
* @author Christopher Hertel <mail@christopher-hertel.de>
*/
enum IconTheme: string
{
case Light = 'light';
case Dark = 'dark';
}

View file

@ -12,6 +12,7 @@
namespace Mcp\Schema;
use Mcp\Exception\InvalidArgumentException;
use Mcp\Schema\Enum\IconTheme;
/**
* A url pointing to an icon URL or a base64-encoded data URI.
@ -20,6 +21,7 @@ use Mcp\Exception\InvalidArgumentException;
* src: string,
* mimeType?: string,
* sizes?: string[],
* theme?: string,
* }
*
* @author Christopher Hertel <mail@christopher-hertel.de>
@ -27,16 +29,19 @@ use Mcp\Exception\InvalidArgumentException;
class Icon implements \JsonSerializable
{
/**
* @param string $src a standard URI pointing to an icon resource
* @param ?string $mimeType optional override if the server's MIME type is missing or generic
* @param ?string[] $sizes optional array of strings that specify sizes at which the icon can be used.
* Each string should be in WxH format (e.g., `"48x48"`, `"96x96"`) or `"any"` for
* scalable formats like SVG.
* @param string $src a standard URI pointing to an icon resource
* @param ?string $mimeType optional override if the server's MIME type is missing or generic
* @param ?string[] $sizes optional array of strings that specify sizes at which the icon can be used.
* Each string should be in WxH format (e.g., `"48x48"`, `"96x96"`) or `"any"` for
* scalable formats like SVG.
* @param ?IconTheme $theme Optional background this icon is designed for. When omitted, the icon is
* assumed to work against any background.
*/
public function __construct(
public readonly string $src,
public readonly ?string $mimeType = null,
public readonly ?array $sizes = null,
public readonly ?IconTheme $theme = null,
) {
if (empty($src)) {
throw new InvalidArgumentException('Icon "src" must be a non-empty string.');
@ -72,7 +77,14 @@ class Icon implements \JsonSerializable
throw new InvalidArgumentException('Invalid "sizes" in Icon data.');
}
return new self($data['src'], $data['mimeType'] ?? null, $data['sizes'] ?? null);
$theme = null;
if (isset($data['theme'])) {
if (!\is_string($data['theme']) || null === $theme = IconTheme::tryFrom($data['theme'])) {
throw new InvalidArgumentException('Invalid "theme" in Icon data.');
}
}
return new self($data['src'], $data['mimeType'] ?? null, $data['sizes'] ?? null, $theme);
}
/**
@ -114,6 +126,10 @@ class Icon implements \JsonSerializable
$data['sizes'] = $this->sizes;
}
if (null !== $this->theme) {
$data['theme'] = $this->theme->value;
}
return $data;
}
}

View file

@ -24,6 +24,7 @@ class Implementation implements \JsonSerializable
{
/**
* @param ?Icon[] $icons
* @param ?string $title Display name for UI and end-user contexts. Falls back to $name when absent.
*/
public function __construct(
public readonly string $name = 'app',
@ -31,6 +32,7 @@ class Implementation implements \JsonSerializable
public readonly ?string $description = null,
public readonly ?array $icons = null,
public readonly ?string $websiteUrl = null,
public readonly ?string $title = null,
) {
}
@ -41,6 +43,7 @@ class Implementation implements \JsonSerializable
* description?: string,
* icons?: IconData[],
* websiteUrl?: string,
* title?: string,
* } $data
*/
public static function fromArray(array $data): self
@ -66,6 +69,9 @@ class Implementation implements \JsonSerializable
if (isset($data['websiteUrl']) && !\is_string($data['websiteUrl'])) {
throw new InvalidArgumentException('Invalid "websiteUrl" in Implementation data.');
}
if (isset($data['title']) && !\is_string($data['title'])) {
throw new InvalidArgumentException('Invalid "title" in Implementation data.');
}
return new self(
$data['name'],
@ -73,6 +79,7 @@ class Implementation implements \JsonSerializable
$data['description'] ?? null,
$data['icons'] ?? null,
$data['websiteUrl'] ?? null,
$data['title'] ?? null,
);
}
@ -83,6 +90,7 @@ class Implementation implements \JsonSerializable
* description?: string,
* icons?: Icon[],
* websiteUrl?: string,
* title?: string,
* }
*/
public function jsonSerialize(): array
@ -104,6 +112,10 @@ class Implementation implements \JsonSerializable
$data['websiteUrl'] = $this->websiteUrl;
}
if (null !== $this->title) {
$data['title'] = $this->title;
}
return $data;
}
}

View file

@ -12,6 +12,8 @@
namespace Mcp\Schema\JsonRpc;
use Mcp\Exception\InvalidArgumentException;
use Mcp\Schema\ClientCapabilities;
use Mcp\Schema\Enum\ProtocolVersion;
/**
* A response to a request that indicates an error occurred.
@ -36,6 +38,24 @@ class Error implements MessageInterface
public const SERVER_ERROR = -32000;
public const RESOURCE_NOT_FOUND = -32002;
/**
* Values in the HTTP headers contradict the request body, or a required
* header is missing or malformed. Answered with `400 Bad Request`.
*/
public const HEADER_MISMATCH = -32020;
/**
* Handling the request needs a client capability the client never declared.
* Answered with `400 Bad Request`.
*/
public const MISSING_REQUIRED_CLIENT_CAPABILITY = -32021;
/**
* The request's protocol version is unknown to this server, or is a version
* it has chosen not to implement. Answered with `400 Bad Request`.
*/
public const UNSUPPORTED_PROTOCOL_VERSION = -32022;
/**
* @param int $code the error type that occurred
* @param string $message a short description of the error
@ -111,6 +131,42 @@ class Error implements MessageInterface
return new self($id, self::RESOURCE_NOT_FOUND, $message);
}
final public static function forHeaderMismatch(string $message, string|int $id = ''): self
{
return new self($id, self::HEADER_MISMATCH, $message);
}
/**
* @param ClientCapabilities $requiredCapabilities the capabilities the server needs to process the request
*/
final public static function forMissingRequiredClientCapability(
string $message,
ClientCapabilities $requiredCapabilities,
string|int $id = '',
): self {
return new self($id, self::MISSING_REQUIRED_CLIENT_CAPABILITY, $message, [
'requiredCapabilities' => $requiredCapabilities,
]);
}
/**
* The client is expected to pick a mutually supported version out of
* $supported and retry, so the list travels with the error rather than
* only in the message.
*
* @param list<ProtocolVersion> $supported versions this server does support
*/
final public static function forUnsupportedProtocolVersion(
string $requested,
array $supported,
string|int $id = '',
): self {
return new self($id, self::UNSUPPORTED_PROTOCOL_VERSION, 'Unsupported protocol version', [
'requested' => $requested,
'supported' => array_values(array_map(static fn (ProtocolVersion $v): string => $v->value, $supported)),
]);
}
public function getId(): string|int
{
return $this->id;

View file

@ -13,26 +13,56 @@ namespace Mcp\Schema\Request;
use Mcp\Exception\InvalidArgumentException;
use Mcp\Schema\Elicitation\ElicitationSchema;
use Mcp\Schema\Enum\ElicitationMode;
use Mcp\Schema\JsonRpc\Request;
/**
* A request from the server to elicit additional information from the user.
*
* The client will present the message and requested schema to the user, allowing them
* to provide the requested information, decline, or cancel the operation.
* Comes in two modes. In `form` mode the client presents the message alongside
* the requested schema and returns the values the user filled in. In `url` mode
* it sends the user to a URL to complete the interaction out of band, and the
* result carries only the user's action — no content.
*
* @author Johannes Wachter <johannes@sulu.io>
*/
final class ElicitRequest extends Request
{
/**
* @param string $message A human-readable message describing what information is needed
* @param ElicitationSchema $requestedSchema The schema defining the fields to elicit from the user
* @param string $message A human-readable message describing what information is needed
* @param ?ElicitationSchema $requestedSchema The schema defining the fields to elicit; required in form mode
* @param ElicitationMode $mode how the client should collect the information
* @param ?string $url the URL to send the user to; required in url mode
*/
public function __construct(
public readonly string $message,
public readonly ElicitationSchema $requestedSchema,
public readonly ?ElicitationSchema $requestedSchema = null,
public readonly ElicitationMode $mode = ElicitationMode::Form,
public readonly ?string $url = null,
) {
if (ElicitationMode::Form === $mode && null === $requestedSchema) {
throw new InvalidArgumentException('Form elicitation requires a "requestedSchema".');
}
if (ElicitationMode::Url === $mode && (null === $url || '' === $url)) {
throw new InvalidArgumentException('URL elicitation requires a non-empty "url".');
}
}
/**
* Elicit values from the user through a form built from $requestedSchema.
*/
public static function forForm(string $message, ElicitationSchema $requestedSchema): self
{
return new self($message, $requestedSchema);
}
/**
* Send the user to $url to complete the interaction out of band.
*/
public static function forUrl(string $message, string $url): self
{
return new self($message, null, ElicitationMode::Url, $url);
}
public static function getMethod(): string
@ -46,24 +76,48 @@ final class ElicitRequest extends Request
throw new InvalidArgumentException('Missing or invalid "message" parameter for elicitation/create.');
}
// Default `mode` is form - url elicitation is opt-in.
$mode = ElicitationMode::Form;
if (\array_key_exists('mode', $params)) {
if (!\is_string($params['mode']) || null === $mode = ElicitationMode::tryFrom($params['mode'])) {
throw new InvalidArgumentException('Invalid "mode" parameter for elicitation/create.');
}
}
if (ElicitationMode::Url === $mode) {
if (!isset($params['url']) || !\is_string($params['url'])) {
throw new InvalidArgumentException('Missing or invalid "url" parameter for url-mode elicitation/create.');
}
return new self($params['message'], null, $mode, $params['url']);
}
if (!isset($params['requestedSchema']) || !\is_array($params['requestedSchema'])) {
throw new InvalidArgumentException('Missing or invalid "requestedSchema" parameter for elicitation/create.');
}
return new self(
$params['message'],
ElicitationSchema::fromArray($params['requestedSchema']),
);
return new self($params['message'], ElicitationSchema::fromArray($params['requestedSchema']), $mode);
}
/**
* @return array{
* message: string,
* requestedSchema: ElicitationSchema,
* mode?: string,
* requestedSchema?: ElicitationSchema,
* url?: string,
* }
*/
protected function getParams(): array
{
if (ElicitationMode::Url === $this->mode) {
return [
'message' => $this->message,
'mode' => $this->mode->value,
'url' => $this->url,
];
}
// We don't need to send the mode if it's the default (form).
return [
'message' => $this->message,
'requestedSchema' => $this->requestedSchema,

View file

@ -41,13 +41,15 @@ class CallToolResult implements ResultInterface
*
* @param Content[] $content The content of the tool result
* @param bool $isError Whether the tool execution resulted in an error. If not set, this is assumed to be false (the call was successful).
* @param mixed[] $structuredContent JSON content for `structuredContent`
* @param mixed $structuredContent Structured result of the call. Any JSON value — object, array,
* string, number, boolean or null — conforming to the tool's
* outputSchema when one is declared.
* @param array<string, mixed>|null $meta Optional metadata
*/
public function __construct(
public readonly array $content,
public readonly bool $isError = false,
public readonly ?array $structuredContent = null,
public readonly mixed $structuredContent = null,
public readonly ?array $meta = null,
) {
foreach ($this->content as $item) {
@ -84,7 +86,7 @@ class CallToolResult implements ResultInterface
* content: array<mixed>,
* isError?: bool,
* _meta?: array<string, mixed>,
* structuredContent?: array<string, mixed>
* structuredContent?: mixed
* } $data
*/
public static function fromArray(array $data): self
@ -114,9 +116,6 @@ class CallToolResult implements ResultInterface
if (isset($data['isError']) && !\is_bool($data['isError'])) {
throw new InvalidArgumentException('Invalid "isError" in CallToolResult data.');
}
if (isset($data['structuredContent']) && !\is_array($data['structuredContent'])) {
throw new InvalidArgumentException('Invalid "structuredContent" in CallToolResult data.');
}
if (isset($data['_meta']) && !\is_array($data['_meta'])) {
throw new InvalidArgumentException('Invalid "_meta" in CallToolResult data.');
}
@ -133,7 +132,7 @@ class CallToolResult implements ResultInterface
* @return array{
* content: array<mixed>,
* isError: bool,
* structuredContent?: array<mixed>,
* structuredContent?: mixed,
* _meta?: array<string, mixed>,
* }
*/
@ -144,7 +143,7 @@ class CallToolResult implements ResultInterface
'isError' => $this->isError,
];
if ($this->structuredContent) {
if (null !== $this->structuredContent) {
$result['structuredContent'] = $this->structuredContent;
}

View file

@ -13,13 +13,15 @@ namespace Mcp\Schema\Result;
use Mcp\Exception\InvalidArgumentException;
use Mcp\Schema\Enum\ElicitAction;
use Mcp\Schema\Enum\ElicitationMode;
use Mcp\Schema\JsonRpc\ResultInterface;
/**
* The client's response to an elicitation/create request from the server.
*
* Contains the user's action (accept, decline, or cancel) and the content
* they provided when accepting.
* they provided when accepting. Only form elicitation collects content: a url-mode
* interaction happens out of band, so an accepted one carries the action alone.
*
* @author Johannes Wachter <johannes@sulu.io>
*/
@ -36,9 +38,13 @@ final class ElicitResult implements ResultInterface
}
/**
* The result carries no discriminator of its own, so the mode of the request it
* answers has to be passed in: it decides whether an accepted response is
* expected to carry content.
*
* @param array{action: string, content?: array<string, mixed>} $data
*/
public static function fromArray(array $data): self
public static function fromArray(array $data, ElicitationMode $mode = ElicitationMode::Form): self
{
if (!isset($data['action']) || !\is_string($data['action'])) {
throw new InvalidArgumentException('Missing or invalid "action" in ElicitResult data.');
@ -50,8 +56,12 @@ final class ElicitResult implements ResultInterface
$content = isset($data['content']) && \is_array($data['content']) ? $data['content'] : null;
if (ElicitAction::Accept === $action && null === $content) {
throw new InvalidArgumentException('Content must be provided when action is "accept".');
if (ElicitationMode::Form === $mode) {
if (ElicitAction::Accept === $action && null === $content) {
throw new InvalidArgumentException('Content must be provided when action is "accept".');
}
} elseif (isset($data['content'])) {
throw new InvalidArgumentException('Content must not be provided for a url-mode elicitation result.');
}
return new self($action, $content);

View file

@ -25,7 +25,7 @@ use Mcp\Exception\InvalidArgumentException;
* required: string[]|null
* }
* @phpstan-type ToolOutputSchema array{
* type: 'object',
* type?: string,
* properties?: array<string, mixed>|\stdClass,
* required?: string[]|null,
* additionalProperties?: bool|array<string, mixed>|\stdClass,
@ -103,7 +103,9 @@ class Tool implements \JsonSerializable
* @param ?ToolAnnotations $annotations optional additional tool information
* @param ?Icon[] $icons optional icons representing the tool
* @param ?array<string, mixed> $meta Optional metadata
* @param ToolOutputSchema|null $outputSchema optional JSON Schema object (as a PHP array) defining the expected output structure
* @param ToolOutputSchema|null $outputSchema Optional JSON Schema (as a PHP array) describing the tool's
* structuredContent. Unlike $inputSchema its root is unconstrained —
* it may describe an array, a primitive, or a composition.
*/
public function __construct(
public readonly string $name,
@ -142,9 +144,6 @@ class Tool implements \JsonSerializable
$outputSchema = null;
if (isset($data['outputSchema']) && \is_array($data['outputSchema'])) {
if (!isset($data['outputSchema']['type']) || 'object' !== $data['outputSchema']['type']) {
throw new InvalidArgumentException('Tool outputSchema must be of type "object".');
}
$outputSchema = $data['outputSchema'];
}
@ -169,7 +168,7 @@ class Tool implements \JsonSerializable
* annotations?: ToolAnnotations,
* icons?: Icon[],
* _meta?: array<string, mixed>,
* outputSchema?: ToolOutputSchema
* outputSchema?: ToolOutputSchema|\stdClass
* }
*/
public function jsonSerialize(): array
@ -192,7 +191,7 @@ class Tool implements \JsonSerializable
$data['_meta'] = $this->meta;
}
if (null !== $this->outputSchema) {
$data['outputSchema'] = $this->outputSchema;
$data['outputSchema'] = [] === $this->outputSchema ? new \stdClass() : $this->outputSchema;
}
return $data;

View file

@ -29,11 +29,11 @@ final class ToolChoice implements \JsonSerializable
*/
public static function fromArray(array $data): self
{
if (isset($data['mode']) && !\is_string($data['mode'])) {
if (\array_key_exists('mode', $data) && !\is_string($data['mode'])) {
throw new InvalidArgumentException('Invalid "mode" in ToolChoice data.');
}
$mode = isset($data['mode']) ? ToolChoiceMode::tryFrom($data['mode']) : ToolChoiceMode::Auto;
$mode = \array_key_exists('mode', $data) ? ToolChoiceMode::tryFrom($data['mode']) : ToolChoiceMode::Auto;
if (null === $mode) {
throw new InvalidArgumentException(\sprintf('Invalid tool choice mode "%s".', $data['mode']));
}

View file

@ -227,6 +227,7 @@ final class Builder
* Sets the server's identity. Required.
*
* @param ?Icon[] $icons
* @param ?string $title Display name for UI and end-user contexts. Falls back to $name when absent.
*/
public function setServerInfo(
string $name,
@ -234,8 +235,9 @@ final class Builder
?string $description = null,
?array $icons = null,
?string $websiteUrl = null,
?string $title = null,
): self {
$this->serverInfo = new Implementation(trim($name), trim($version), $description, $icons, $websiteUrl);
$this->serverInfo = new Implementation(trim($name), trim($version), $description, $icons, $websiteUrl, $title);
return $this;
}

View file

@ -56,9 +56,9 @@ use Mcp\Server\Session\SessionInterface;
* $client->notify(new ProgressNotification("Starting analysis..."));
*
* // Request LLM sampling from client
* $response = $client->request(new SamplingRequest($text));
* $result = $client->sample($text);
*
* return $response->content->text;
* return $result->content->text;
* }
* ```
*
@ -176,7 +176,7 @@ class ClientGateway
}
/**
* Convenience method for elicitation requests.
* Convenience method for form-mode elicitation requests.
*
* Requests additional information from the user via the client. The user can
* accept (providing the requested data), decline, or cancel the request.
@ -191,15 +191,28 @@ class ClientGateway
*/
public function elicit(string $message, ElicitationSchema $requestedSchema, int $timeout = 120): ElicitResult
{
$request = new ElicitRequest($message, $requestedSchema);
return $this->sendElicitation(ElicitRequest::forForm($message, $requestedSchema), $timeout);
}
$response = $this->request($request, $timeout);
if ($response instanceof Error) {
throw new ClientException($response);
/**
* Convenience method for url-mode elicitation requests.
*
* Sends the user to $url to complete the interaction out of band — an OAuth
* consent screen, a checkout, a form hosted elsewhere. The result carries only
* the user's action; unlike form mode there is no content to read back, so
* whatever the user did there has to be picked up through the URL's own channel.
*
* @throws ClientException if the client request results in an error message
* @throws InvalidArgumentException if the client did not declare url-mode elicitation
*/
public function elicitUrl(string $message, string $url, int $timeout = 120): ElicitResult
{
// URL mode only exists from 2025-11-25 on, and only for clients declaring it
if (!$this->supportsElicitationUrl()) {
throw new InvalidArgumentException('The client did not declare the "elicitation.url" capability, so it cannot be sent a url-mode elicitation.');
}
return ElicitResult::fromArray($response->result);
return $this->sendElicitation(ElicitRequest::forUrl($message, $url), $timeout);
}
/**
@ -291,7 +304,7 @@ class ClientGateway
*/
public function supportsSamplingTools(): bool
{
return $this->hasSamplingSubCapability('tools');
return $this->hasSubCapability('sampling', 'tools');
}
/**
@ -304,20 +317,52 @@ class ClientGateway
*/
public function supportsSamplingContext(): bool
{
return $this->hasSamplingSubCapability('context');
return $this->hasSubCapability('sampling', 'context');
}
private function hasSamplingSubCapability(string $name): bool
/**
* Check if the connected client supports url-mode elicitation.
*
* An `elicitation` capability naming no mode declares form mode — the only shape
* that existed before URL elicitation — so url mode has to be named explicitly.
*
* @return bool True if the client supports url-mode elicitation, false otherwise
*/
public function supportsElicitationUrl(): bool
{
return $this->hasSubCapability('elicitation', 'url');
}
/**
* Sub-capabilities are declared by the presence of a (possibly empty) object, so
* only the key matters — not whatever it holds. The value arrives as an object on
* a live session and as an array once the session has round-tripped through JSON,
* hence both shapes.
*/
private function hasSubCapability(string $capability, string $name): bool
{
$capabilities = (array) $this->session->get('client_capabilities', []);
$sampling = $capabilities['sampling'] ?? null;
$declared = $capabilities[$capability] ?? null;
if (!\is_array($sampling) && !\is_object($sampling)) {
return false;
if (\is_array($declared)) {
return \array_key_exists($name, $declared);
}
// MCP spec: capability presence indicates support (value is typically {} or [])
return \array_key_exists($name, (array) $sampling);
return \is_object($declared) && property_exists($declared, $name);
}
/**
* @throws ClientException if the client request results in an error message
*/
private function sendElicitation(ElicitRequest $request, int $timeout): ElicitResult
{
$response = $this->request($request, $timeout);
if ($response instanceof Error) {
throw new ClientException($response);
}
return ElicitResult::fromArray($response->result, $request->mode);
}
/**

View file

@ -113,16 +113,18 @@ final class CallToolHandler implements RequestHandlerInterface
$result = new CallToolResult($reference->formatResult($result), structuredContent: $structuredContent);
} elseif ($protocolVersion->requiresObjectStructuredContent()
&& \is_array($result->structuredContent)
&& null !== $result->structuredContent
&& [] !== $result->structuredContent
&& array_is_list($result->structuredContent)
&& !self::isJsonObject($result->structuredContent)
) {
// A tool building its own `CallToolResult` bypasses the extraction
// rules on purpose, so the value is sent as it was set — but a JSON
// array is not valid here before SEP-2106 and clients may reject it.
$this->logger->warning('Tool returned a "CallToolResult" whose "structuredContent" is a JSON array, which the negotiated protocol revision does not allow; sending it unchanged.', [
// rules on purpose, so the value is sent as it was set — but before
// SEP-2106 only a JSON object is valid here, whether the value is a
// list or a scalar, and clients may reject it.
$this->logger->warning('Tool returned a "CallToolResult" whose "structuredContent" is not a JSON object, which the negotiated protocol revision requires; sending it unchanged.', [
'name' => $toolName,
'protocol_version' => $protocolVersion->value,
'structured_content_type' => get_debug_type($result->structuredContent),
]);
}
@ -152,4 +154,13 @@ final class CallToolHandler implements RequestHandlerInterface
return Error::forInternalError('Error while executing tool', $request->getId());
}
}
/**
* Whether a `structuredContent` value encodes as a JSON object — the only shape
* revisions predating SEP-2106 accept.
*/
private static function isJsonObject(mixed $value): bool
{
return \is_array($value) && !array_is_list($value);
}
}

View file

@ -50,6 +50,9 @@ final class ProtocolVersionMiddleware implements MiddlewareInterface
/** @var list<string> */
private readonly array $supportedVersions;
/** @var list<ProtocolVersion> */
private readonly array $supported;
/**
* @param list<ProtocolVersion>|null $supportedVersions Versions the server accepts. Defaults to {@see ProtocolVersion::handshakeVersions()}; modern revisions are excluded as their per-request negotiation is not served yet.
* @param ResponseFactoryInterface|null $responseFactory PSR-17 response factory (auto-discovered if null)
@ -61,7 +64,8 @@ final class ProtocolVersionMiddleware implements MiddlewareInterface
?StreamFactoryInterface $streamFactory = null,
) {
$versions = $supportedVersions ?? ProtocolVersion::handshakeVersions();
$this->supportedVersions = array_values(array_map(static fn (ProtocolVersion $v): string => $v->value, $versions));
$this->supported = array_values($versions);
$this->supportedVersions = array_map(static fn (ProtocolVersion $v): string => $v->value, $this->supported);
$this->responseFactory = $responseFactory ?? Psr17FactoryDiscovery::findResponseFactory();
$this->streamFactory = $streamFactory ?? Psr17FactoryDiscovery::findStreamFactory();
}
@ -82,20 +86,11 @@ final class ProtocolVersionMiddleware implements MiddlewareInterface
return $handler->handle($request);
}
$message = '' === $headerValue
? \sprintf(
'Missing %s header; backwards-compat default %s is not accepted. Supported versions: %s.',
StreamableHttpTransport::PROTOCOL_VERSION_HEADER,
$version,
implode(', ', $this->supportedVersions),
)
: \sprintf(
'Unsupported %s header value: %s. Supported versions: %s.',
StreamableHttpTransport::PROTOCOL_VERSION_HEADER,
$headerValue,
implode(', ', $this->supportedVersions),
);
// The client is expected to pick a mutually supported version from the
// error payload and retry, so the supported set travels as structured
// data rather than only inside the message.
$error = Error::forUnsupportedProtocolVersion($version, $this->supported);
return JsonRpcErrorResponse::create($this->responseFactory, $this->streamFactory, 400, Error::forInvalidParams($message));
return JsonRpcErrorResponse::create($this->responseFactory, $this->streamFactory, 400, $error);
}
}

View file

@ -753,6 +753,46 @@ class RegistryTest extends TestCase
};
}
public function testExtractStructuredContentKeepsAScalarFromSep2106On(): void
{
$tool = $this->createValidTool('test_tool', ['type' => 'string']);
$this->registry->registerTool($tool, static fn () => 'sunny');
$toolRef = $this->registry->getTool('test_tool');
$this->assertSame('sunny', $toolRef->extractStructuredContent('sunny', ProtocolVersion::V2026_07_28));
$this->assertNull($toolRef->extractStructuredContent('sunny', ProtocolVersion::V2025_11_25));
}
public function testExtractStructuredContentDropsAScalarWithoutAnOutputSchema(): void
{
$tool = $this->createValidTool('test_tool', null);
$this->registry->registerTool($tool, static fn () => 'sunny');
$toolRef = $this->registry->getTool('test_tool');
// Without a declared schema the value is already carried in `content`;
// advertising it as structured too would duplicate it.
$this->assertNull($toolRef->extractStructuredContent('sunny', ProtocolVersion::V2026_07_28));
}
public function testExtractStructuredContentKeepsAJsonSerializableScalarFromSep2106On(): void
{
$tool = $this->createValidTool('test_tool', ['type' => 'number']);
$result = new class implements \JsonSerializable {
public function jsonSerialize(): float
{
return 22.5;
}
};
$this->registry->registerTool($tool, static fn () => $result);
$toolRef = $this->registry->getTool('test_tool');
$this->assertSame(22.5, $toolRef->extractStructuredContent($result, ProtocolVersion::V2026_07_28));
$this->assertNull($toolRef->extractStructuredContent($result, ProtocolVersion::V2025_11_25));
}
private function createValidTool(string $name, ?array $outputSchema = null): Tool
{
return new Tool(

Some files were not shown because too many files have changed in this diff Show more