[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:
parent
76778a4d9e
commit
f84d8f5aa9
32 changed files with 1205 additions and 91 deletions
|
|
@ -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
|
||||
-----
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
31
src/Schema/Enum/ElicitationMode.php
Normal file
31
src/Schema/Enum/ElicitationMode.php
Normal 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';
|
||||
}
|
||||
25
src/Schema/Enum/IconTheme.php
Normal file
25
src/Schema/Enum/IconTheme.php
Normal 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';
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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']));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
Loading…
Reference in a new issue