[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.
|
* 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] `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.
|
* [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
|
0.7.0
|
||||||
-----
|
-----
|
||||||
|
|
|
||||||
|
|
@ -71,11 +71,11 @@ class ToolReference extends ElementReference
|
||||||
* newest handshake revision, whose stricter rule is what
|
* newest handshake revision, whose stricter rule is what
|
||||||
* every revision reachable through `initialize` requires
|
* 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
|
* @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();
|
$objectOnly = ($protocolVersion ?? ProtocolVersion::latestHandshake())->requiresObjectStructuredContent();
|
||||||
|
|
||||||
|
|
@ -111,11 +111,9 @@ class ToolReference extends ElementReference
|
||||||
);
|
);
|
||||||
|
|
||||||
// A plain object always encodes to a JSON object, but `JsonSerializable`
|
// A plain object always encodes to a JSON object, but `JsonSerializable`
|
||||||
// can hand back anything. A scalar is dropped whatever the revision
|
// can hand back anything, scalars included.
|
||||||
// allows: `CallToolResult::$structuredContent` is typed `?array` and
|
|
||||||
// cannot carry one.
|
|
||||||
if (!\is_array($decoded)) {
|
if (!\is_array($decoded)) {
|
||||||
return null;
|
return $this->acceptsScalarStructuredContent($objectOnly) ? $decoded : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($objectOnly && array_is_list($decoded)) {
|
if ($objectOnly && array_is_list($decoded)) {
|
||||||
|
|
@ -125,6 +123,20 @@ class ToolReference extends ElementReference
|
||||||
return $decoded;
|
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 $name = 'mcp-php-client';
|
||||||
private string $version = '1.0.0';
|
private string $version = '1.0.0';
|
||||||
private ?string $description = null;
|
private ?string $description = null;
|
||||||
|
private ?string $title = null;
|
||||||
private ?ProtocolVersion $protocolVersion = null;
|
private ?ProtocolVersion $protocolVersion = null;
|
||||||
private ?ClientCapabilities $capabilities = null;
|
private ?ClientCapabilities $capabilities = null;
|
||||||
private int $initTimeout = 30;
|
private int $initTimeout = 30;
|
||||||
|
|
@ -45,12 +46,15 @@ final class Builder
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Set the client name and version.
|
* 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->name = $name;
|
||||||
$this->version = $version;
|
$this->version = $version;
|
||||||
$this->description = $description;
|
$this->description = $description;
|
||||||
|
$this->title = $title;
|
||||||
|
|
||||||
return $this;
|
return $this;
|
||||||
}
|
}
|
||||||
|
|
@ -152,6 +156,7 @@ final class Builder
|
||||||
$this->name,
|
$this->name,
|
||||||
$this->version,
|
$this->version,
|
||||||
$this->description,
|
$this->description,
|
||||||
|
title: $this->title,
|
||||||
);
|
);
|
||||||
|
|
||||||
$config = new Configuration(
|
$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 ?array<string, mixed> $extensions protocol extensions the client supports (e.g. io.modelcontextprotocol/ui)
|
||||||
* @param ?bool $samplingContext the `sampling.context` sub-capability
|
* @param ?bool $samplingContext the `sampling.context` sub-capability
|
||||||
* @param ?bool $samplingTools the `sampling.tools` 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
|
* The sub-capabilities trail `extensions` rather than sitting next to `sampling` and
|
||||||
* `sampling` so that existing positional calls keep working. Pass them by name.
|
* `elicitation` so that existing positional calls keep working. Pass them by name.
|
||||||
*/
|
*/
|
||||||
public function __construct(
|
public function __construct(
|
||||||
public readonly ?bool $roots = false,
|
public readonly ?bool $roots = false,
|
||||||
|
|
@ -37,6 +40,8 @@ class ClientCapabilities implements \JsonSerializable
|
||||||
public readonly ?array $extensions = null,
|
public readonly ?array $extensions = null,
|
||||||
public readonly ?bool $samplingContext = null,
|
public readonly ?bool $samplingContext = null,
|
||||||
public readonly ?bool $samplingTools = 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,
|
* listChanged?: bool,
|
||||||
* },
|
* },
|
||||||
* sampling?: array{context?: mixed, tools?: mixed}|object,
|
* sampling?: array{context?: mixed, tools?: mixed}|object,
|
||||||
* elicitation?: bool,
|
* elicitation?: array{form?: mixed, url?: mixed}|object|bool,
|
||||||
* experimental?: array<string, mixed>,
|
* experimental?: array<string, mixed>,
|
||||||
* extensions?: array<string, mixed>,
|
* extensions?: array<string, mixed>,
|
||||||
* } $data
|
* } $data
|
||||||
|
|
@ -78,8 +83,15 @@ class ClientCapabilities implements \JsonSerializable
|
||||||
}
|
}
|
||||||
|
|
||||||
$elicitation = null;
|
$elicitation = null;
|
||||||
|
$elicitationForm = null;
|
||||||
|
$elicitationUrl = null;
|
||||||
if (isset($data['elicitation'])) {
|
if (isset($data['elicitation'])) {
|
||||||
$elicitation = true;
|
$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(
|
return new self(
|
||||||
|
|
@ -91,9 +103,28 @@ class ClientCapabilities implements \JsonSerializable
|
||||||
\is_array($data['extensions'] ?? null) ? $data['extensions'] : null,
|
\is_array($data['extensions'] ?? null) ? $data['extensions'] : null,
|
||||||
$samplingContext,
|
$samplingContext,
|
||||||
$samplingTools,
|
$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{
|
* @return array{
|
||||||
* roots?: object,
|
* roots?: object,
|
||||||
|
|
@ -123,8 +154,14 @@ class ClientCapabilities implements \JsonSerializable
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($this->elicitation) {
|
if ($this->elicitation || $this->elicitationForm || $this->elicitationUrl) {
|
||||||
$data['elicitation'] = new \stdClass();
|
$data['elicitation'] = new \stdClass();
|
||||||
|
if ($this->elicitationForm) {
|
||||||
|
$data['elicitation']->form = new \stdClass();
|
||||||
|
}
|
||||||
|
if ($this->elicitationUrl) {
|
||||||
|
$data['elicitation']->url = new \stdClass();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($this->experimental) {
|
if ($this->experimental) {
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,10 @@ final class ToolUseContent extends Content
|
||||||
public readonly array $input,
|
public readonly array $input,
|
||||||
public readonly ?array $meta = null,
|
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');
|
parent::__construct('tool_use');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -45,12 +49,15 @@ final class ToolUseContent extends Content
|
||||||
if (!isset($data['input']) || !\is_array($data['input'])) {
|
if (!isset($data['input']) || !\is_array($data['input'])) {
|
||||||
throw new InvalidArgumentException('Missing or invalid "input" in ToolUseContent data.');
|
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(
|
return new self(
|
||||||
$data['id'],
|
$data['id'],
|
||||||
$data['name'],
|
$data['name'],
|
||||||
$data['input'],
|
$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;
|
namespace Mcp\Schema;
|
||||||
|
|
||||||
use Mcp\Exception\InvalidArgumentException;
|
use Mcp\Exception\InvalidArgumentException;
|
||||||
|
use Mcp\Schema\Enum\IconTheme;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A url pointing to an icon URL or a base64-encoded data URI.
|
* A url pointing to an icon URL or a base64-encoded data URI.
|
||||||
|
|
@ -20,6 +21,7 @@ use Mcp\Exception\InvalidArgumentException;
|
||||||
* src: string,
|
* src: string,
|
||||||
* mimeType?: string,
|
* mimeType?: string,
|
||||||
* sizes?: string[],
|
* sizes?: string[],
|
||||||
|
* theme?: string,
|
||||||
* }
|
* }
|
||||||
*
|
*
|
||||||
* @author Christopher Hertel <mail@christopher-hertel.de>
|
* @author Christopher Hertel <mail@christopher-hertel.de>
|
||||||
|
|
@ -27,16 +29,19 @@ use Mcp\Exception\InvalidArgumentException;
|
||||||
class Icon implements \JsonSerializable
|
class Icon implements \JsonSerializable
|
||||||
{
|
{
|
||||||
/**
|
/**
|
||||||
* @param string $src a standard URI pointing to an icon resource
|
* @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 $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.
|
* @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
|
* Each string should be in WxH format (e.g., `"48x48"`, `"96x96"`) or `"any"` for
|
||||||
* scalable formats like SVG.
|
* 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 function __construct(
|
||||||
public readonly string $src,
|
public readonly string $src,
|
||||||
public readonly ?string $mimeType = null,
|
public readonly ?string $mimeType = null,
|
||||||
public readonly ?array $sizes = null,
|
public readonly ?array $sizes = null,
|
||||||
|
public readonly ?IconTheme $theme = null,
|
||||||
) {
|
) {
|
||||||
if (empty($src)) {
|
if (empty($src)) {
|
||||||
throw new InvalidArgumentException('Icon "src" must be a non-empty string.');
|
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.');
|
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;
|
$data['sizes'] = $this->sizes;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (null !== $this->theme) {
|
||||||
|
$data['theme'] = $this->theme->value;
|
||||||
|
}
|
||||||
|
|
||||||
return $data;
|
return $data;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,7 @@ class Implementation implements \JsonSerializable
|
||||||
{
|
{
|
||||||
/**
|
/**
|
||||||
* @param ?Icon[] $icons
|
* @param ?Icon[] $icons
|
||||||
|
* @param ?string $title Display name for UI and end-user contexts. Falls back to $name when absent.
|
||||||
*/
|
*/
|
||||||
public function __construct(
|
public function __construct(
|
||||||
public readonly string $name = 'app',
|
public readonly string $name = 'app',
|
||||||
|
|
@ -31,6 +32,7 @@ class Implementation implements \JsonSerializable
|
||||||
public readonly ?string $description = null,
|
public readonly ?string $description = null,
|
||||||
public readonly ?array $icons = null,
|
public readonly ?array $icons = null,
|
||||||
public readonly ?string $websiteUrl = null,
|
public readonly ?string $websiteUrl = null,
|
||||||
|
public readonly ?string $title = null,
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -41,6 +43,7 @@ class Implementation implements \JsonSerializable
|
||||||
* description?: string,
|
* description?: string,
|
||||||
* icons?: IconData[],
|
* icons?: IconData[],
|
||||||
* websiteUrl?: string,
|
* websiteUrl?: string,
|
||||||
|
* title?: string,
|
||||||
* } $data
|
* } $data
|
||||||
*/
|
*/
|
||||||
public static function fromArray(array $data): self
|
public static function fromArray(array $data): self
|
||||||
|
|
@ -66,6 +69,9 @@ class Implementation implements \JsonSerializable
|
||||||
if (isset($data['websiteUrl']) && !\is_string($data['websiteUrl'])) {
|
if (isset($data['websiteUrl']) && !\is_string($data['websiteUrl'])) {
|
||||||
throw new InvalidArgumentException('Invalid "websiteUrl" in Implementation data.');
|
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(
|
return new self(
|
||||||
$data['name'],
|
$data['name'],
|
||||||
|
|
@ -73,6 +79,7 @@ class Implementation implements \JsonSerializable
|
||||||
$data['description'] ?? null,
|
$data['description'] ?? null,
|
||||||
$data['icons'] ?? null,
|
$data['icons'] ?? null,
|
||||||
$data['websiteUrl'] ?? null,
|
$data['websiteUrl'] ?? null,
|
||||||
|
$data['title'] ?? null,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -83,6 +90,7 @@ class Implementation implements \JsonSerializable
|
||||||
* description?: string,
|
* description?: string,
|
||||||
* icons?: Icon[],
|
* icons?: Icon[],
|
||||||
* websiteUrl?: string,
|
* websiteUrl?: string,
|
||||||
|
* title?: string,
|
||||||
* }
|
* }
|
||||||
*/
|
*/
|
||||||
public function jsonSerialize(): array
|
public function jsonSerialize(): array
|
||||||
|
|
@ -104,6 +112,10 @@ class Implementation implements \JsonSerializable
|
||||||
$data['websiteUrl'] = $this->websiteUrl;
|
$data['websiteUrl'] = $this->websiteUrl;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (null !== $this->title) {
|
||||||
|
$data['title'] = $this->title;
|
||||||
|
}
|
||||||
|
|
||||||
return $data;
|
return $data;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,8 @@
|
||||||
namespace Mcp\Schema\JsonRpc;
|
namespace Mcp\Schema\JsonRpc;
|
||||||
|
|
||||||
use Mcp\Exception\InvalidArgumentException;
|
use Mcp\Exception\InvalidArgumentException;
|
||||||
|
use Mcp\Schema\ClientCapabilities;
|
||||||
|
use Mcp\Schema\Enum\ProtocolVersion;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A response to a request that indicates an error occurred.
|
* 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 SERVER_ERROR = -32000;
|
||||||
public const RESOURCE_NOT_FOUND = -32002;
|
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 int $code the error type that occurred
|
||||||
* @param string $message a short description of the error
|
* @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);
|
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
|
public function getId(): string|int
|
||||||
{
|
{
|
||||||
return $this->id;
|
return $this->id;
|
||||||
|
|
|
||||||
|
|
@ -13,26 +13,56 @@ namespace Mcp\Schema\Request;
|
||||||
|
|
||||||
use Mcp\Exception\InvalidArgumentException;
|
use Mcp\Exception\InvalidArgumentException;
|
||||||
use Mcp\Schema\Elicitation\ElicitationSchema;
|
use Mcp\Schema\Elicitation\ElicitationSchema;
|
||||||
|
use Mcp\Schema\Enum\ElicitationMode;
|
||||||
use Mcp\Schema\JsonRpc\Request;
|
use Mcp\Schema\JsonRpc\Request;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A request from the server to elicit additional information from the user.
|
* 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
|
* Comes in two modes. In `form` mode the client presents the message alongside
|
||||||
* to provide the requested information, decline, or cancel the operation.
|
* 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>
|
* @author Johannes Wachter <johannes@sulu.io>
|
||||||
*/
|
*/
|
||||||
final class ElicitRequest extends Request
|
final class ElicitRequest extends Request
|
||||||
{
|
{
|
||||||
/**
|
/**
|
||||||
* @param string $message A human-readable message describing what information is needed
|
* @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 ?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 function __construct(
|
||||||
public readonly string $message,
|
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
|
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.');
|
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'])) {
|
if (!isset($params['requestedSchema']) || !\is_array($params['requestedSchema'])) {
|
||||||
throw new InvalidArgumentException('Missing or invalid "requestedSchema" parameter for elicitation/create.');
|
throw new InvalidArgumentException('Missing or invalid "requestedSchema" parameter for elicitation/create.');
|
||||||
}
|
}
|
||||||
|
|
||||||
return new self(
|
return new self($params['message'], ElicitationSchema::fromArray($params['requestedSchema']), $mode);
|
||||||
$params['message'],
|
|
||||||
ElicitationSchema::fromArray($params['requestedSchema']),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return array{
|
* @return array{
|
||||||
* message: string,
|
* message: string,
|
||||||
* requestedSchema: ElicitationSchema,
|
* mode?: string,
|
||||||
|
* requestedSchema?: ElicitationSchema,
|
||||||
|
* url?: string,
|
||||||
* }
|
* }
|
||||||
*/
|
*/
|
||||||
protected function getParams(): array
|
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 [
|
return [
|
||||||
'message' => $this->message,
|
'message' => $this->message,
|
||||||
'requestedSchema' => $this->requestedSchema,
|
'requestedSchema' => $this->requestedSchema,
|
||||||
|
|
|
||||||
|
|
@ -41,13 +41,15 @@ class CallToolResult implements ResultInterface
|
||||||
*
|
*
|
||||||
* @param Content[] $content The content of the tool result
|
* @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 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
|
* @param array<string, mixed>|null $meta Optional metadata
|
||||||
*/
|
*/
|
||||||
public function __construct(
|
public function __construct(
|
||||||
public readonly array $content,
|
public readonly array $content,
|
||||||
public readonly bool $isError = false,
|
public readonly bool $isError = false,
|
||||||
public readonly ?array $structuredContent = null,
|
public readonly mixed $structuredContent = null,
|
||||||
public readonly ?array $meta = null,
|
public readonly ?array $meta = null,
|
||||||
) {
|
) {
|
||||||
foreach ($this->content as $item) {
|
foreach ($this->content as $item) {
|
||||||
|
|
@ -84,7 +86,7 @@ class CallToolResult implements ResultInterface
|
||||||
* content: array<mixed>,
|
* content: array<mixed>,
|
||||||
* isError?: bool,
|
* isError?: bool,
|
||||||
* _meta?: array<string, mixed>,
|
* _meta?: array<string, mixed>,
|
||||||
* structuredContent?: array<string, mixed>
|
* structuredContent?: mixed
|
||||||
* } $data
|
* } $data
|
||||||
*/
|
*/
|
||||||
public static function fromArray(array $data): self
|
public static function fromArray(array $data): self
|
||||||
|
|
@ -114,9 +116,6 @@ class CallToolResult implements ResultInterface
|
||||||
if (isset($data['isError']) && !\is_bool($data['isError'])) {
|
if (isset($data['isError']) && !\is_bool($data['isError'])) {
|
||||||
throw new InvalidArgumentException('Invalid "isError" in CallToolResult data.');
|
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'])) {
|
if (isset($data['_meta']) && !\is_array($data['_meta'])) {
|
||||||
throw new InvalidArgumentException('Invalid "_meta" in CallToolResult data.');
|
throw new InvalidArgumentException('Invalid "_meta" in CallToolResult data.');
|
||||||
}
|
}
|
||||||
|
|
@ -133,7 +132,7 @@ class CallToolResult implements ResultInterface
|
||||||
* @return array{
|
* @return array{
|
||||||
* content: array<mixed>,
|
* content: array<mixed>,
|
||||||
* isError: bool,
|
* isError: bool,
|
||||||
* structuredContent?: array<mixed>,
|
* structuredContent?: mixed,
|
||||||
* _meta?: array<string, mixed>,
|
* _meta?: array<string, mixed>,
|
||||||
* }
|
* }
|
||||||
*/
|
*/
|
||||||
|
|
@ -144,7 +143,7 @@ class CallToolResult implements ResultInterface
|
||||||
'isError' => $this->isError,
|
'isError' => $this->isError,
|
||||||
];
|
];
|
||||||
|
|
||||||
if ($this->structuredContent) {
|
if (null !== $this->structuredContent) {
|
||||||
$result['structuredContent'] = $this->structuredContent;
|
$result['structuredContent'] = $this->structuredContent;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -13,13 +13,15 @@ namespace Mcp\Schema\Result;
|
||||||
|
|
||||||
use Mcp\Exception\InvalidArgumentException;
|
use Mcp\Exception\InvalidArgumentException;
|
||||||
use Mcp\Schema\Enum\ElicitAction;
|
use Mcp\Schema\Enum\ElicitAction;
|
||||||
|
use Mcp\Schema\Enum\ElicitationMode;
|
||||||
use Mcp\Schema\JsonRpc\ResultInterface;
|
use Mcp\Schema\JsonRpc\ResultInterface;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The client's response to an elicitation/create request from the server.
|
* The client's response to an elicitation/create request from the server.
|
||||||
*
|
*
|
||||||
* Contains the user's action (accept, decline, or cancel) and the content
|
* 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>
|
* @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
|
* @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'])) {
|
if (!isset($data['action']) || !\is_string($data['action'])) {
|
||||||
throw new InvalidArgumentException('Missing or invalid "action" in ElicitResult data.');
|
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;
|
$content = isset($data['content']) && \is_array($data['content']) ? $data['content'] : null;
|
||||||
|
|
||||||
if (ElicitAction::Accept === $action && null === $content) {
|
if (ElicitationMode::Form === $mode) {
|
||||||
throw new InvalidArgumentException('Content must be provided when action is "accept".');
|
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);
|
return new self($action, $content);
|
||||||
|
|
|
||||||
|
|
@ -25,7 +25,7 @@ use Mcp\Exception\InvalidArgumentException;
|
||||||
* required: string[]|null
|
* required: string[]|null
|
||||||
* }
|
* }
|
||||||
* @phpstan-type ToolOutputSchema array{
|
* @phpstan-type ToolOutputSchema array{
|
||||||
* type: 'object',
|
* type?: string,
|
||||||
* properties?: array<string, mixed>|\stdClass,
|
* properties?: array<string, mixed>|\stdClass,
|
||||||
* required?: string[]|null,
|
* required?: string[]|null,
|
||||||
* additionalProperties?: bool|array<string, mixed>|\stdClass,
|
* additionalProperties?: bool|array<string, mixed>|\stdClass,
|
||||||
|
|
@ -103,7 +103,9 @@ class Tool implements \JsonSerializable
|
||||||
* @param ?ToolAnnotations $annotations optional additional tool information
|
* @param ?ToolAnnotations $annotations optional additional tool information
|
||||||
* @param ?Icon[] $icons optional icons representing the tool
|
* @param ?Icon[] $icons optional icons representing the tool
|
||||||
* @param ?array<string, mixed> $meta Optional metadata
|
* @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 function __construct(
|
||||||
public readonly string $name,
|
public readonly string $name,
|
||||||
|
|
@ -142,9 +144,6 @@ class Tool implements \JsonSerializable
|
||||||
|
|
||||||
$outputSchema = null;
|
$outputSchema = null;
|
||||||
if (isset($data['outputSchema']) && \is_array($data['outputSchema'])) {
|
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'];
|
$outputSchema = $data['outputSchema'];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -169,7 +168,7 @@ class Tool implements \JsonSerializable
|
||||||
* annotations?: ToolAnnotations,
|
* annotations?: ToolAnnotations,
|
||||||
* icons?: Icon[],
|
* icons?: Icon[],
|
||||||
* _meta?: array<string, mixed>,
|
* _meta?: array<string, mixed>,
|
||||||
* outputSchema?: ToolOutputSchema
|
* outputSchema?: ToolOutputSchema|\stdClass
|
||||||
* }
|
* }
|
||||||
*/
|
*/
|
||||||
public function jsonSerialize(): array
|
public function jsonSerialize(): array
|
||||||
|
|
@ -192,7 +191,7 @@ class Tool implements \JsonSerializable
|
||||||
$data['_meta'] = $this->meta;
|
$data['_meta'] = $this->meta;
|
||||||
}
|
}
|
||||||
if (null !== $this->outputSchema) {
|
if (null !== $this->outputSchema) {
|
||||||
$data['outputSchema'] = $this->outputSchema;
|
$data['outputSchema'] = [] === $this->outputSchema ? new \stdClass() : $this->outputSchema;
|
||||||
}
|
}
|
||||||
|
|
||||||
return $data;
|
return $data;
|
||||||
|
|
|
||||||
|
|
@ -29,11 +29,11 @@ final class ToolChoice implements \JsonSerializable
|
||||||
*/
|
*/
|
||||||
public static function fromArray(array $data): self
|
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.');
|
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) {
|
if (null === $mode) {
|
||||||
throw new InvalidArgumentException(\sprintf('Invalid tool choice mode "%s".', $data['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.
|
* Sets the server's identity. Required.
|
||||||
*
|
*
|
||||||
* @param ?Icon[] $icons
|
* @param ?Icon[] $icons
|
||||||
|
* @param ?string $title Display name for UI and end-user contexts. Falls back to $name when absent.
|
||||||
*/
|
*/
|
||||||
public function setServerInfo(
|
public function setServerInfo(
|
||||||
string $name,
|
string $name,
|
||||||
|
|
@ -234,8 +235,9 @@ final class Builder
|
||||||
?string $description = null,
|
?string $description = null,
|
||||||
?array $icons = null,
|
?array $icons = null,
|
||||||
?string $websiteUrl = null,
|
?string $websiteUrl = null,
|
||||||
|
?string $title = null,
|
||||||
): self {
|
): 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;
|
return $this;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -56,9 +56,9 @@ use Mcp\Server\Session\SessionInterface;
|
||||||
* $client->notify(new ProgressNotification("Starting analysis..."));
|
* $client->notify(new ProgressNotification("Starting analysis..."));
|
||||||
*
|
*
|
||||||
* // Request LLM sampling from client
|
* // 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
|
* Requests additional information from the user via the client. The user can
|
||||||
* accept (providing the requested data), decline, or cancel the request.
|
* 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
|
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);
|
/**
|
||||||
|
* Convenience method for url-mode elicitation requests.
|
||||||
if ($response instanceof Error) {
|
*
|
||||||
throw new ClientException($response);
|
* 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
|
public function supportsSamplingTools(): bool
|
||||||
{
|
{
|
||||||
return $this->hasSamplingSubCapability('tools');
|
return $this->hasSubCapability('sampling', 'tools');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -304,20 +317,52 @@ class ClientGateway
|
||||||
*/
|
*/
|
||||||
public function supportsSamplingContext(): bool
|
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', []);
|
$capabilities = (array) $this->session->get('client_capabilities', []);
|
||||||
$sampling = $capabilities['sampling'] ?? null;
|
$declared = $capabilities[$capability] ?? null;
|
||||||
|
|
||||||
if (!\is_array($sampling) && !\is_object($sampling)) {
|
if (\is_array($declared)) {
|
||||||
return false;
|
return \array_key_exists($name, $declared);
|
||||||
}
|
}
|
||||||
|
|
||||||
// MCP spec: capability presence indicates support (value is typically {} or [])
|
return \is_object($declared) && property_exists($declared, $name);
|
||||||
return \array_key_exists($name, (array) $sampling);
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @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);
|
$result = new CallToolResult($reference->formatResult($result), structuredContent: $structuredContent);
|
||||||
} elseif ($protocolVersion->requiresObjectStructuredContent()
|
} elseif ($protocolVersion->requiresObjectStructuredContent()
|
||||||
&& \is_array($result->structuredContent)
|
&& null !== $result->structuredContent
|
||||||
&& [] !== $result->structuredContent
|
&& [] !== $result->structuredContent
|
||||||
&& array_is_list($result->structuredContent)
|
&& !self::isJsonObject($result->structuredContent)
|
||||||
) {
|
) {
|
||||||
// A tool building its own `CallToolResult` bypasses the extraction
|
// A tool building its own `CallToolResult` bypasses the extraction
|
||||||
// rules on purpose, so the value is sent as it was set — but a JSON
|
// rules on purpose, so the value is sent as it was set — but before
|
||||||
// array is not valid here before SEP-2106 and clients may reject it.
|
// SEP-2106 only a JSON object is valid here, whether the value is a
|
||||||
$this->logger->warning('Tool returned a "CallToolResult" whose "structuredContent" is a JSON array, which the negotiated protocol revision does not allow; sending it unchanged.', [
|
// 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,
|
'name' => $toolName,
|
||||||
'protocol_version' => $protocolVersion->value,
|
'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());
|
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> */
|
/** @var list<string> */
|
||||||
private readonly array $supportedVersions;
|
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 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)
|
* @param ResponseFactoryInterface|null $responseFactory PSR-17 response factory (auto-discovered if null)
|
||||||
|
|
@ -61,7 +64,8 @@ final class ProtocolVersionMiddleware implements MiddlewareInterface
|
||||||
?StreamFactoryInterface $streamFactory = null,
|
?StreamFactoryInterface $streamFactory = null,
|
||||||
) {
|
) {
|
||||||
$versions = $supportedVersions ?? ProtocolVersion::handshakeVersions();
|
$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->responseFactory = $responseFactory ?? Psr17FactoryDiscovery::findResponseFactory();
|
||||||
$this->streamFactory = $streamFactory ?? Psr17FactoryDiscovery::findStreamFactory();
|
$this->streamFactory = $streamFactory ?? Psr17FactoryDiscovery::findStreamFactory();
|
||||||
}
|
}
|
||||||
|
|
@ -82,20 +86,11 @@ final class ProtocolVersionMiddleware implements MiddlewareInterface
|
||||||
return $handler->handle($request);
|
return $handler->handle($request);
|
||||||
}
|
}
|
||||||
|
|
||||||
$message = '' === $headerValue
|
// The client is expected to pick a mutually supported version from the
|
||||||
? \sprintf(
|
// error payload and retry, so the supported set travels as structured
|
||||||
'Missing %s header; backwards-compat default %s is not accepted. Supported versions: %s.',
|
// data rather than only inside the message.
|
||||||
StreamableHttpTransport::PROTOCOL_VERSION_HEADER,
|
$error = Error::forUnsupportedProtocolVersion($version, $this->supported);
|
||||||
$version,
|
|
||||||
implode(', ', $this->supportedVersions),
|
|
||||||
)
|
|
||||||
: \sprintf(
|
|
||||||
'Unsupported %s header value: %s. Supported versions: %s.',
|
|
||||||
StreamableHttpTransport::PROTOCOL_VERSION_HEADER,
|
|
||||||
$headerValue,
|
|
||||||
implode(', ', $this->supportedVersions),
|
|
||||||
);
|
|
||||||
|
|
||||||
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
|
private function createValidTool(string $name, ?array $outputSchema = null): Tool
|
||||||
{
|
{
|
||||||
return new 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