From 844c0bf14973524e891c545d1bfb498cef020e41 Mon Sep 17 00:00:00 2001 From: Christopher Hertel Date: Mon, 27 Jul 2026 16:10:29 +0200 Subject: [PATCH 1/7] [Schema][Server] Close the 2025-11-25 schema gaps and add the 2026-07-28 surface MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/Schema/ClientCapabilities.php | 45 +++++- src/Schema/Enum/ElicitationMode.php | 29 ++++ src/Schema/Enum/IconTheme.php | 25 +++ src/Schema/Icon.php | 28 +++- src/Schema/Implementation.php | 9 ++ src/Schema/JsonRpc/Error.php | 56 +++++++ src/Schema/Request/ElicitRequest.php | 77 ++++++++-- src/Schema/Result/CallToolResult.php | 18 ++- src/Schema/Tool.php | 12 +- .../Middleware/ProtocolVersionMiddleware.php | 25 ++- tests/Unit/Schema/ClientCapabilitiesTest.php | 66 ++++++++ tests/Unit/Schema/ElicitationModeTest.php | 130 ++++++++++++++++ tests/Unit/Schema/IconTest.php | 16 ++ tests/Unit/Schema/JsonRpc/ErrorCodesTest.php | 78 ++++++++++ .../Unit/Schema/NonObjectOutputSchemaTest.php | 145 ++++++++++++++++++ 15 files changed, 715 insertions(+), 44 deletions(-) create mode 100644 src/Schema/Enum/ElicitationMode.php create mode 100644 src/Schema/Enum/IconTheme.php create mode 100644 tests/Unit/Schema/ElicitationModeTest.php create mode 100644 tests/Unit/Schema/JsonRpc/ErrorCodesTest.php create mode 100644 tests/Unit/Schema/NonObjectOutputSchemaTest.php diff --git a/src/Schema/ClientCapabilities.php b/src/Schema/ClientCapabilities.php index 5eab9e66..b2a953e8 100644 --- a/src/Schema/ClientCapabilities.php +++ b/src/Schema/ClientCapabilities.php @@ -24,9 +24,12 @@ class ClientCapabilities implements \JsonSerializable * @param ?array $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 @@ public function __construct( 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 @@ public function __construct( * listChanged?: bool, * }, * sampling?: array{context?: mixed, tools?: mixed}|object, - * elicitation?: bool, + * elicitation?: array{form?: mixed, url?: mixed}|object|bool, * experimental?: array, * extensions?: array, * } $data @@ -78,8 +83,15 @@ public static function fromArray(array $data): self } $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 @@ public static function fromArray(array $data): self \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 @@ public function jsonSerialize(): array|object } } - 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) { diff --git a/src/Schema/Enum/ElicitationMode.php b/src/Schema/Enum/ElicitationMode.php new file mode 100644 index 00000000..c4c80ea7 --- /dev/null +++ b/src/Schema/Enum/ElicitationMode.php @@ -0,0 +1,29 @@ + + */ +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'; +} diff --git a/src/Schema/Enum/IconTheme.php b/src/Schema/Enum/IconTheme.php new file mode 100644 index 00000000..abfac9e2 --- /dev/null +++ b/src/Schema/Enum/IconTheme.php @@ -0,0 +1,25 @@ + + */ +enum IconTheme: string +{ + case Light = 'light'; + case Dark = 'dark'; +} diff --git a/src/Schema/Icon.php b/src/Schema/Icon.php index 13929e03..27513435 100644 --- a/src/Schema/Icon.php +++ b/src/Schema/Icon.php @@ -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 @@ * src: string, * mimeType?: string, * sizes?: string[], + * theme?: string, * } * * @author Christopher Hertel @@ -27,16 +29,19 @@ 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 @@ public static function fromArray(array $data): self 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 @@ public function jsonSerialize(): array $data['sizes'] = $this->sizes; } + if (null !== $this->theme) { + $data['theme'] = $this->theme->value; + } + return $data; } } diff --git a/src/Schema/Implementation.php b/src/Schema/Implementation.php index 214a38f8..8aba2a79 100644 --- a/src/Schema/Implementation.php +++ b/src/Schema/Implementation.php @@ -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 @@ public function __construct( public readonly ?string $description = null, public readonly ?array $icons = null, public readonly ?string $websiteUrl = null, + public readonly ?string $title = null, ) { } @@ -41,6 +43,7 @@ public function __construct( * description?: string, * icons?: IconData[], * websiteUrl?: string, + * title?: string, * } $data */ public static function fromArray(array $data): self @@ -73,6 +76,7 @@ public static function fromArray(array $data): self $data['description'] ?? null, $data['icons'] ?? null, $data['websiteUrl'] ?? null, + $data['title'] ?? null, ); } @@ -83,6 +87,7 @@ public static function fromArray(array $data): self * description?: string, * icons?: Icon[], * websiteUrl?: string, + * title?: string, * } */ public function jsonSerialize(): array @@ -104,6 +109,10 @@ public function jsonSerialize(): array $data['websiteUrl'] = $this->websiteUrl; } + if (null !== $this->title) { + $data['title'] = $this->title; + } + return $data; } } diff --git a/src/Schema/JsonRpc/Error.php b/src/Schema/JsonRpc/Error.php index 532e406d..683ed0dc 100644 --- a/src/Schema/JsonRpc/Error.php +++ b/src/Schema/JsonRpc/Error.php @@ -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 @@ final public static function forResourceNotFound(string $message, string|int $id 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 $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; diff --git a/src/Schema/Request/ElicitRequest.php b/src/Schema/Request/ElicitRequest.php index ab56e10c..880e0113 100644 --- a/src/Schema/Request/ElicitRequest.php +++ b/src/Schema/Request/ElicitRequest.php @@ -13,26 +13,56 @@ 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 */ 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,51 @@ protected static function fromParams(?array $params): static throw new InvalidArgumentException('Missing or invalid "message" parameter for elicitation/create.'); } + // `mode` is absent on requests from servers predating URL elicitation, + // where the form shape was the only one available. + $mode = ElicitationMode::Form; + if (isset($params['mode'])) { + 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, + ]; + } + + // `mode` is left off for form elicitation: it is optional on that shape, + // and omitting it keeps the request readable by clients predating the + // mode discriminator. return [ 'message' => $this->message, 'requestedSchema' => $this->requestedSchema, diff --git a/src/Schema/Result/CallToolResult.php b/src/Schema/Result/CallToolResult.php index c6618395..1e04d9f0 100644 --- a/src/Schema/Result/CallToolResult.php +++ b/src/Schema/Result/CallToolResult.php @@ -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|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 @@ public static function error(array $content, ?array $meta = null): self * content: array, * isError?: bool, * _meta?: array, - * structuredContent?: array + * structuredContent?: mixed * } $data */ public static function fromArray(array $data): self @@ -114,6 +116,9 @@ public static function fromArray(array $data): self if (isset($data['isError']) && !\is_bool($data['isError'])) { throw new InvalidArgumentException('Invalid "isError" in CallToolResult data.'); } + // Kept object-only on the wire although the property itself now holds any + // JSON value, mirroring the emission gate below: no revision this SDK + // negotiates sends a scalar here, so one is still type-confused input. if (isset($data['structuredContent']) && !\is_array($data['structuredContent'])) { throw new InvalidArgumentException('Invalid "structuredContent" in CallToolResult data.'); } @@ -133,7 +138,7 @@ public static function fromArray(array $data): self * @return array{ * content: array, * isError: bool, - * structuredContent?: array, + * structuredContent?: mixed, * _meta?: array, * } */ @@ -144,6 +149,11 @@ public function jsonSerialize(): array 'isError' => $this->isError, ]; + // Deliberately a truthiness check rather than `null !==`. SEP-2106 lets + // structuredContent be any JSON value, but only from 2026-07-28 onward: + // every version this SDK currently negotiates still requires an object, + // and emitting `[]`, `0` or `false` to those clients is a protocol error. + // Emission widens once results serialize per negotiated version. if ($this->structuredContent) { $result['structuredContent'] = $this->structuredContent; } diff --git a/src/Schema/Tool.php b/src/Schema/Tool.php index c4062cfc..c0ddf266 100644 --- a/src/Schema/Tool.php +++ b/src/Schema/Tool.php @@ -25,7 +25,7 @@ * required: string[]|null * } * @phpstan-type ToolOutputSchema array{ - * type: 'object', + * type?: string, * properties?: array|\stdClass, * required?: string[]|null, * additionalProperties?: bool|array|\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 $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, @@ -140,11 +142,11 @@ public static function fromArray(array $data): self throw new InvalidArgumentException('Tool inputSchema must be of type "object".'); } + // Unlike inputSchema — whose root must stay an object, because tool + // arguments are always a JSON object — outputSchema may describe any + // JSON value, including arrays and primitives. $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']; } diff --git a/src/Server/Transport/Http/Middleware/ProtocolVersionMiddleware.php b/src/Server/Transport/Http/Middleware/ProtocolVersionMiddleware.php index a4fb76b8..e00a8492 100644 --- a/src/Server/Transport/Http/Middleware/ProtocolVersionMiddleware.php +++ b/src/Server/Transport/Http/Middleware/ProtocolVersionMiddleware.php @@ -50,6 +50,9 @@ final class ProtocolVersionMiddleware implements MiddlewareInterface /** @var list */ private readonly array $supportedVersions; + /** @var list */ + private readonly array $supported; + /** * @param list|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 @@ public function __construct( ?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 @@ public function process(ServerRequestInterface $request, RequestHandlerInterface 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); } } diff --git a/tests/Unit/Schema/ClientCapabilitiesTest.php b/tests/Unit/Schema/ClientCapabilitiesTest.php index bee2e588..c2176e9f 100644 --- a/tests/Unit/Schema/ClientCapabilitiesTest.php +++ b/tests/Unit/Schema/ClientCapabilitiesTest.php @@ -12,6 +12,7 @@ namespace Mcp\Tests\Unit\Schema; use Mcp\Schema\ClientCapabilities; +use PHPUnit\Framework\Attributes\TestDox; use PHPUnit\Framework\TestCase; class ClientCapabilitiesTest extends TestCase @@ -111,4 +112,69 @@ public function testSamplingSubCapabilitiesAreHydratedFromObject(): void $this->assertTrue($capabilities->samplingContext); $this->assertTrue($capabilities->samplingTools); } + + #[TestDox('reads the elicitation sub-capabilities')] + public function testReadsElicitationSubCapabilities(): void + { + $capabilities = ClientCapabilities::fromArray([ + 'elicitation' => ['form' => [], 'url' => []], + ]); + + $this->assertTrue($capabilities->elicitation); + $this->assertTrue($capabilities->elicitationForm); + $this->assertTrue($capabilities->elicitationUrl); + } + + #[TestDox('an elicitation capability naming no mode declares form')] + public function testElicitationWithoutModeImpliesForm(): void + { + $capabilities = ClientCapabilities::fromArray(['elicitation' => []]); + + $this->assertTrue($capabilities->elicitationForm); + $this->assertFalse($capabilities->elicitationUrl); + } + + #[TestDox('naming url alone does not declare form')] + public function testElicitationUrlAloneIsNotForm(): void + { + $capabilities = ClientCapabilities::fromArray(['elicitation' => ['url' => []]]); + + $this->assertFalse($capabilities->elicitationForm); + $this->assertTrue($capabilities->elicitationUrl); + } + + #[TestDox('an absent elicitation capability declares no mode either way')] + public function testAbsentElicitationLeavesModesNull(): void + { + $capabilities = ClientCapabilities::fromArray([]); + + $this->assertNull($capabilities->elicitation); + $this->assertNull($capabilities->elicitationForm); + $this->assertNull($capabilities->elicitationUrl); + } + + #[TestDox('round-trips the elicitation sub-capabilities')] + public function testRoundTripPreservesElicitationSubCapabilities(): void + { + $capabilities = new ClientCapabilities(elicitation: true, elicitationForm: true, elicitationUrl: true); + + $encoded = json_encode($capabilities) ?: ''; + $this->assertStringContainsString('"elicitation":{"form":{},"url":{}}', $encoded); + + $restored = ClientCapabilities::fromArray(json_decode($encoded, true)); + + $this->assertTrue($restored->elicitationForm); + $this->assertTrue($restored->elicitationUrl); + } + + #[TestDox('declaring only a mode still advertises elicitation')] + public function testElicitationModeImpliesParent(): void + { + $capabilities = new ClientCapabilities(elicitationUrl: true); + + $encoded = json_decode(json_encode($capabilities) ?: '', true); + + $this->assertArrayHasKey('elicitation', $encoded); + $this->assertArrayHasKey('url', $encoded['elicitation']); + } } diff --git a/tests/Unit/Schema/ElicitationModeTest.php b/tests/Unit/Schema/ElicitationModeTest.php new file mode 100644 index 00000000..c4cd3263 --- /dev/null +++ b/tests/Unit/Schema/ElicitationModeTest.php @@ -0,0 +1,130 @@ +createSchema()); + + $params = $this->paramsOf($request); + + $this->assertArrayNotHasKey('mode', $params); + $this->assertArrayHasKey('requestedSchema', $params); + $this->assertSame(ElicitationMode::Form, $request->mode); + } + + #[TestDox('url elicitation carries mode and url, and no schema')] + public function testUrlCarriesModeAndUrl(): void + { + $request = ElicitRequest::forUrl('Finish setup', 'https://example.com/setup'); + + $params = $this->paramsOf($request); + + $this->assertSame('url', $params['mode']); + $this->assertSame('https://example.com/setup', $params['url']); + $this->assertArrayNotHasKey('requestedSchema', $params); + $this->assertNull($request->requestedSchema); + } + + #[TestDox('a request without mode is still read as form elicitation')] + public function testAbsentModeDefaultsToForm(): void + { + $request = $this->requestFromParams([ + 'message' => 'Your name?', + 'requestedSchema' => [ + 'type' => 'object', + 'properties' => ['name' => ['type' => 'string', 'title' => 'Name']], + ], + ]); + + $this->assertSame(ElicitationMode::Form, $request->mode); + $this->assertNotNull($request->requestedSchema); + } + + #[TestDox('parses an explicit url-mode request')] + public function testParsesUrlMode(): void + { + $request = $this->requestFromParams([ + 'message' => 'Finish setup', + 'mode' => 'url', + 'url' => 'https://example.com/setup', + ]); + + $this->assertSame(ElicitationMode::Url, $request->mode); + $this->assertSame('https://example.com/setup', $request->url); + } + + #[TestDox('url mode without a url is rejected')] + public function testUrlModeRequiresUrl(): void + { + $this->expectException(InvalidArgumentException::class); + + $this->requestFromParams(['message' => 'Finish setup', 'mode' => 'url']); + } + + #[TestDox('form mode without a schema is rejected')] + public function testFormModeRequiresSchema(): void + { + $this->expectException(InvalidArgumentException::class); + + $this->requestFromParams(['message' => 'Your name?']); + } + + #[TestDox('an unknown mode is rejected rather than silently treated as a form')] + public function testUnknownModeRejected(): void + { + $this->expectException(InvalidArgumentException::class); + + $this->requestFromParams(['message' => 'hi', 'mode' => 'telepathy']); + } + + private function createSchema(): ElicitationSchema + { + return new ElicitationSchema(['name' => new StringSchemaDefinition('Name')], ['name']); + } + + /** + * @param array $params + */ + private function requestFromParams(array $params): ElicitRequest + { + return ElicitRequest::fromArray([ + 'jsonrpc' => MessageInterface::JSONRPC_VERSION, + 'id' => 'request-1', + 'method' => ElicitRequest::getMethod(), + 'params' => $params, + ]); + } + + /** + * @return array + */ + private function paramsOf(ElicitRequest $request): array + { + /** @var array{params?: array} $encoded */ + $encoded = json_decode(json_encode($request->withId('request-1')) ?: '', true); + + return $encoded['params'] ?? []; + } +} diff --git a/tests/Unit/Schema/IconTest.php b/tests/Unit/Schema/IconTest.php index 500a81b3..65e00e24 100644 --- a/tests/Unit/Schema/IconTest.php +++ b/tests/Unit/Schema/IconTest.php @@ -12,6 +12,7 @@ namespace Mcp\Tests\Unit\Schema; use Mcp\Exception\InvalidArgumentException; +use Mcp\Schema\Enum\IconTheme; use Mcp\Schema\Icon; use PHPUnit\Framework\TestCase; @@ -85,4 +86,19 @@ public function testValidDataUriSrc(): void $this->assertSame($dataUri, $icon->src); } + + public function testFromArrayReadsTheme(): void + { + $icon = Icon::fromArray(['src' => 'https://example.com/icon.png', 'theme' => 'dark']); + + $this->assertSame(IconTheme::Dark, $icon->theme); + $this->assertSame('dark', $icon->jsonSerialize()['theme']); + } + + public function testFromArrayRejectsUnknownTheme(): void + { + $this->expectException(InvalidArgumentException::class); + + Icon::fromArray(['src' => 'https://example.com/icon.png', 'theme' => 'sepia']); + } } diff --git a/tests/Unit/Schema/JsonRpc/ErrorCodesTest.php b/tests/Unit/Schema/JsonRpc/ErrorCodesTest.php new file mode 100644 index 00000000..464f3472 --- /dev/null +++ b/tests/Unit/Schema/JsonRpc/ErrorCodesTest.php @@ -0,0 +1,78 @@ +assertSame(Error::HEADER_MISMATCH, $error->code); + $this->assertSame(-32020, $error->code); + } + + #[TestDox('missing client capability carries -32021 and the required capabilities')] + public function testMissingClientCapability(): void + { + $error = Error::forMissingRequiredClientCapability( + 'Sampling is required', + new ClientCapabilities(sampling: true), + 'req-1', + ); + + $this->assertSame(-32021, $error->code); + + /** @var array{requiredCapabilities: ClientCapabilities} $data */ + $data = $error->data; + $this->assertInstanceOf(ClientCapabilities::class, $data['requiredCapabilities']); + + $encoded = json_decode(json_encode($error) ?: '', true); + $this->assertArrayHasKey('sampling', $encoded['error']['data']['requiredCapabilities']); + } + + #[TestDox('unsupported version carries -32022 plus the requested and supported versions')] + public function testUnsupportedProtocolVersion(): void + { + $error = Error::forUnsupportedProtocolVersion( + '1900-01-01', + [ProtocolVersion::V2025_11_25, ProtocolVersion::V2025_06_18], + 'req-1', + ); + + $this->assertSame(-32022, $error->code); + $this->assertSame([ + 'requested' => '1900-01-01', + 'supported' => ['2025-11-25', '2025-06-18'], + ], $error->data); + } + + #[TestDox('the supported list survives JSON encoding as a plain array')] + public function testUnsupportedVersionEncoding(): void + { + $error = Error::forUnsupportedProtocolVersion('1900-01-01', ProtocolVersion::handshakeVersions(), 'req-1'); + + $encoded = json_decode(json_encode($error) ?: '', true); + + $this->assertSame( + array_map(static fn (ProtocolVersion $v): string => $v->value, ProtocolVersion::handshakeVersions()), + $encoded['error']['data']['supported'], + ); + } +} diff --git a/tests/Unit/Schema/NonObjectOutputSchemaTest.php b/tests/Unit/Schema/NonObjectOutputSchemaTest.php new file mode 100644 index 00000000..3813ae31 --- /dev/null +++ b/tests/Unit/Schema/NonObjectOutputSchemaTest.php @@ -0,0 +1,145 @@ +, required: string[]|null} + */ + private static function validInputSchema(): array + { + return [ + 'type' => 'object', + 'properties' => ['q' => ['type' => 'string']], + 'required' => null, + ]; + } + + /** + * @param array $outputSchema + */ + #[TestDox('outputSchema accepts a non-object root')] + #[DataProvider('provideNonObjectSchemas')] + public function testAcceptsNonObjectOutputSchema(array $outputSchema): void + { + $tool = Tool::fromArray([ + 'name' => 'demo', + 'inputSchema' => self::validInputSchema(), + 'outputSchema' => $outputSchema, + ]); + + $this->assertSame($outputSchema, $tool->outputSchema); + } + + /** + * @return iterable}> + */ + public static function provideNonObjectSchemas(): iterable + { + yield 'array' => [['type' => 'array', 'items' => ['type' => 'string']]]; + yield 'string' => [['type' => 'string']]; + yield 'number' => [['type' => 'number']]; + yield 'boolean' => [['type' => 'boolean']]; + yield 'composition without a root type' => [['oneOf' => [['type' => 'string'], ['type' => 'number']]]]; + } + + #[TestDox('inputSchema still requires an object root, since tool arguments are always an object')] + public function testInputSchemaStillRequiresObjectRoot(): void + { + $this->expectException(InvalidArgumentException::class); + + /* @phpstan-ignore-next-line argument.type (deliberately invalid: an array root must be rejected) */ + Tool::fromArray([ + 'name' => 'demo', + 'inputSchema' => ['type' => 'array', 'properties' => [], 'required' => null], + ]); + } + + #[TestDox('structuredContent holds any JSON value')] + #[DataProvider('provideStructuredValues')] + public function testStructuredContentHoldsAnyJsonValue(mixed $value): void + { + $result = new CallToolResult([new TextContent('ok')], false, $value); + + $this->assertSame($value, $result->structuredContent); + } + + /** + * @return iterable + */ + public static function provideStructuredValues(): iterable + { + yield 'list' => [[1, 2, 3]]; + yield 'string' => ['hello']; + yield 'int' => [42]; + yield 'float' => [1.5]; + yield 'true' => [true]; + yield 'zero' => [0]; + yield 'false' => [false]; + yield 'empty string' => ['']; + yield 'empty array' => [[]]; + } + + #[TestDox('a truthy non-object value reaches the wire')] + public function testNonObjectValueIsSerialized(): void + { + $result = new CallToolResult([new TextContent('ok')], false, [1, 2, 3]); + + $this->assertSame([1, 2, 3], $result->jsonSerialize()['structuredContent']); + } + + #[TestDox('a null structuredContent is omitted entirely')] + public function testNullStructuredContentOmitted(): void + { + $result = new CallToolResult([new TextContent('ok')]); + + $this->assertArrayNotHasKey('structuredContent', $result->jsonSerialize()); + } + + /** + * Emission of falsy values stays gated until results serialize per negotiated + * protocol version: every version this SDK speaks today still requires + * structuredContent to be an object. + */ + #[TestDox('falsy values are still withheld from the wire pending version-gated serialization')] + public function testFalsyValuesAreNotYetEmitted(): void + { + $result = new CallToolResult([new TextContent('ok')], false, []); + + $this->assertSame([], $result->structuredContent); + $this->assertArrayNotHasKey('structuredContent', $result->jsonSerialize()); + } + + #[TestDox('round-trips a non-object structuredContent through fromArray')] + public function testRoundTrip(): void + { + $result = CallToolResult::fromArray([ + 'content' => [['type' => 'text', 'text' => 'ok']], + 'structuredContent' => ['a', 'b'], + ]); + + $this->assertSame(['a', 'b'], $result->structuredContent); + } +} From ce84f71239889709e6b08b42b3f0029de35d56b1 Mon Sep 17 00:00:00 2001 From: Christopher Hertel Date: Sat, 15 Aug 2026 03:18:39 +0200 Subject: [PATCH 2/7] [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. --- src/Schema/Content/ToolUseContent.php | 10 +++++- src/Schema/Implementation.php | 3 ++ src/Schema/Request/ElicitRequest.php | 6 ++-- src/Schema/ToolChoice.php | 6 ++-- .../Schema/Content/ToolUseContentTest.php | 24 ++++++++++++++ tests/Unit/Schema/ElicitationModeTest.php | 16 ++++++++++ tests/Unit/Schema/ImplementationTest.php | 31 +++++++++++++++++++ tests/Unit/Schema/ToolChoiceTest.php | 9 ++++++ 8 files changed, 100 insertions(+), 5 deletions(-) diff --git a/src/Schema/Content/ToolUseContent.php b/src/Schema/Content/ToolUseContent.php index 6acdc439..e270e117 100644 --- a/src/Schema/Content/ToolUseContent.php +++ b/src/Schema/Content/ToolUseContent.php @@ -28,6 +28,11 @@ public function __construct( public readonly array $input, public readonly ?array $meta = null, ) { + // An empty array is exempt: it is also an empty map, and serializes as `{}` below. + 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 +50,15 @@ public static function fromArray(array $data): self 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, ); } diff --git a/src/Schema/Implementation.php b/src/Schema/Implementation.php index 8aba2a79..b48be544 100644 --- a/src/Schema/Implementation.php +++ b/src/Schema/Implementation.php @@ -69,6 +69,9 @@ public static function fromArray(array $data): self 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'], diff --git a/src/Schema/Request/ElicitRequest.php b/src/Schema/Request/ElicitRequest.php index 880e0113..0b0bd192 100644 --- a/src/Schema/Request/ElicitRequest.php +++ b/src/Schema/Request/ElicitRequest.php @@ -77,9 +77,11 @@ protected static function fromParams(?array $params): static } // `mode` is absent on requests from servers predating URL elicitation, - // where the form shape was the only one available. + // where the form shape was the only one available. Only a genuinely absent + // key defaults, though: an explicit null is not one of the modes, so it is + // rejected like any other unknown value. $mode = ElicitationMode::Form; - if (isset($params['mode'])) { + 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.'); } diff --git a/src/Schema/ToolChoice.php b/src/Schema/ToolChoice.php index 4b55bac3..889a38d1 100644 --- a/src/Schema/ToolChoice.php +++ b/src/Schema/ToolChoice.php @@ -29,11 +29,13 @@ public function __construct( */ public static function fromArray(array $data): self { - if (isset($data['mode']) && !\is_string($data['mode'])) { + // Key presence, not isset(): an explicit `null` is not one of the modes, so it + // has to reach the rejection path rather than fall back to the default. + 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'])); } diff --git a/tests/Unit/Schema/Content/ToolUseContentTest.php b/tests/Unit/Schema/Content/ToolUseContentTest.php index bf7d07b0..686481ba 100644 --- a/tests/Unit/Schema/Content/ToolUseContentTest.php +++ b/tests/Unit/Schema/Content/ToolUseContentTest.php @@ -77,4 +77,28 @@ public function testInvalidDataIsRejected(array $data): void ToolUseContent::fromArray($data); } + + public function testRejectsListInput(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('ToolUseContent "input" must be a map of argument names, not a list.'); + + /* @phpstan-ignore argument.type (deliberately list-shaped) */ + new ToolUseContent('call-1', 'get_weather', ['Berlin']); + } + + public function testRejectsListInputFromArray(): void + { + $this->expectException(InvalidArgumentException::class); + + ToolUseContent::fromArray(['type' => 'tool_use', 'id' => 'call-1', 'name' => 'x', 'input' => [1]]); + } + + public function testEmptyInputIsAccepted(): void + { + $content = ToolUseContent::fromArray(['type' => 'tool_use', 'id' => 'call-1', 'name' => 'ping', 'input' => []]); + + $this->assertSame([], $content->input); + $this->assertSame('{"type":"tool_use","id":"call-1","name":"ping","input":{}}', json_encode($content)); + } } diff --git a/tests/Unit/Schema/ElicitationModeTest.php b/tests/Unit/Schema/ElicitationModeTest.php index c4cd3263..1502e1e0 100644 --- a/tests/Unit/Schema/ElicitationModeTest.php +++ b/tests/Unit/Schema/ElicitationModeTest.php @@ -99,6 +99,22 @@ public function testUnknownModeRejected(): void $this->requestFromParams(['message' => 'hi', 'mode' => 'telepathy']); } + #[TestDox('an explicit null mode is rejected rather than defaulting to form')] + public function testNullModeRejected(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Invalid "mode" parameter'); + + $this->requestFromParams([ + 'message' => 'Your name?', + 'mode' => null, + 'requestedSchema' => [ + 'type' => 'object', + 'properties' => ['name' => ['type' => 'string', 'title' => 'Name']], + ], + ]); + } + private function createSchema(): ElicitationSchema { return new ElicitationSchema(['name' => new StringSchemaDefinition('Name')], ['name']); diff --git a/tests/Unit/Schema/ImplementationTest.php b/tests/Unit/Schema/ImplementationTest.php index 1e19dea7..3268b6b1 100644 --- a/tests/Unit/Schema/ImplementationTest.php +++ b/tests/Unit/Schema/ImplementationTest.php @@ -26,6 +26,7 @@ public function testConstructorDefaults(): void $this->assertNull($implementation->description); $this->assertNull($implementation->icons); $this->assertNull($implementation->websiteUrl); + $this->assertNull($implementation->title); } public function testFromArrayWithMinimalData(): void @@ -148,6 +149,36 @@ public function testFromArrayThrowsOnNonArrayIcons(): void Implementation::fromArray(['name' => 'my-client', 'version' => '1.0.0', 'icons' => 'nope']); } + public function testFromArrayThrowsOnNonStringDescription(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Invalid "description" in Implementation data.'); + + /* @phpstan-ignore argument.type */ + Implementation::fromArray(['name' => 'my-client', 'version' => '1.0.0', 'description' => 42]); + } + + public function testFromArrayThrowsOnNonStringTitle(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Invalid "title" in Implementation data.'); + + /* @phpstan-ignore argument.type */ + Implementation::fromArray(['name' => 'my-client', 'version' => '1.0.0', 'title' => ['nope']]); + } + + public function testFromArrayReadsTitle(): void + { + $implementation = Implementation::fromArray([ + 'name' => 'my-client', + 'version' => '1.0.0', + 'title' => 'My Client', + ]); + + $this->assertSame('My Client', $implementation->title); + $this->assertArrayHasKey('title', $implementation->jsonSerialize()); + } + public function testJsonSerializeRoundTrip(): void { $implementation = Implementation::fromArray([ diff --git a/tests/Unit/Schema/ToolChoiceTest.php b/tests/Unit/Schema/ToolChoiceTest.php index 66ba54da..2b5e7cd3 100644 --- a/tests/Unit/Schema/ToolChoiceTest.php +++ b/tests/Unit/Schema/ToolChoiceTest.php @@ -60,4 +60,13 @@ public function testNonStringModeIsRejected(): void /* @phpstan-ignore argument.type */ ToolChoice::fromArray(['mode' => 1]); } + + public function testExplicitNullModeIsRejected(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Invalid "mode" in ToolChoice data.'); + + /* @phpstan-ignore argument.type (deliberately null, as malformed wire data would be) */ + ToolChoice::fromArray(['mode' => null]); + } } From 3931b9688d52a14bc38ff8d2683d7b564f6ee509 Mon Sep 17 00:00:00 2001 From: Christopher Hertel Date: Sat, 15 Aug 2026 03:18:45 +0200 Subject: [PATCH 3/7] [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. --- src/Client/Builder.php | 7 +++- src/Server/Builder.php | 4 ++- tests/Unit/Client/BuilderTest.php | 53 +++++++++++++++++++++++++++++++ tests/Unit/Server/BuilderTest.php | 40 +++++++++++++++++++++++ 4 files changed, 102 insertions(+), 2 deletions(-) create mode 100644 tests/Unit/Client/BuilderTest.php diff --git a/src/Client/Builder.php b/src/Client/Builder.php index 9a69da17..7c396176 100644 --- a/src/Client/Builder.php +++ b/src/Client/Builder.php @@ -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 @@ public function build(): Client $this->name, $this->version, $this->description, + title: $this->title, ); $config = new Configuration( diff --git a/src/Server/Builder.php b/src/Server/Builder.php index fbce3d9b..dca38ef1 100644 --- a/src/Server/Builder.php +++ b/src/Server/Builder.php @@ -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 @@ public function setServerInfo( ?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; } diff --git a/tests/Unit/Client/BuilderTest.php b/tests/Unit/Client/BuilderTest.php new file mode 100644 index 00000000..cd0bdf78 --- /dev/null +++ b/tests/Unit/Client/BuilderTest.php @@ -0,0 +1,53 @@ +setClientInfo('my-client', '1.0.0', 'A test client', 'My Client') + ->build(); + + $clientInfo = $this->extractConfiguration($client)->clientInfo; + + $this->assertSame('my-client', $clientInfo->name); + $this->assertSame('1.0.0', $clientInfo->version); + $this->assertSame('A test client', $clientInfo->description); + $this->assertSame('My Client', $clientInfo->title); + } + + #[TestDox('setClientInfo() leaves the title absent when it is not given')] + public function testSetClientInfoTitleIsOptional(): void + { + $client = Client::builder() + ->setClientInfo('my-client', '1.0.0') + ->build(); + + $this->assertNull($this->extractConfiguration($client)->clientInfo->title); + } + + private function extractConfiguration(Client $client): Configuration + { + $config = (new \ReflectionClass($client))->getProperty('config')->getValue($client); + $this->assertInstanceOf(Configuration::class, $config); + + return $config; + } +} diff --git a/tests/Unit/Server/BuilderTest.php b/tests/Unit/Server/BuilderTest.php index 255a142e..a29c9f4e 100644 --- a/tests/Unit/Server/BuilderTest.php +++ b/tests/Unit/Server/BuilderTest.php @@ -18,6 +18,7 @@ use Mcp\Exception\LogicException; use Mcp\Schema\Content\TextContent; use Mcp\Schema\Extension\Apps\McpApps; +use Mcp\Schema\Implementation; use Mcp\Schema\JsonRpc\Response; use Mcp\Schema\Request\CallToolRequest; use Mcp\Schema\ServerCapabilities; @@ -206,6 +207,45 @@ public function testEagerLoadingAdvertisesFromLoadedRegistry(): void $this->assertFalse($capabilities->tools); } + #[TestDox('setServerInfo() forwards the title to the advertised serverInfo')] + public function testSetServerInfoForwardsTitle(): void + { + $server = Server::builder() + ->setServerInfo('test', '1.0.0', 'A test server', null, 'https://example.com', 'Test Server') + ->build(); + + $serverInfo = $this->extractServerInfo($server); + + $this->assertSame('test', $serverInfo->name); + $this->assertSame('A test server', $serverInfo->description); + $this->assertSame('https://example.com', $serverInfo->websiteUrl); + $this->assertSame('Test Server', $serverInfo->title); + } + + #[TestDox('setServerInfo() leaves the title absent when it is not given')] + public function testSetServerInfoTitleIsOptional(): void + { + $server = Server::builder() + ->setServerInfo('test', '1.0.0') + ->build(); + + $this->assertNull($this->extractServerInfo($server)->title); + } + + private function extractServerInfo(Server $server): Implementation + { + $protocol = (new \ReflectionClass($server))->getProperty('protocol')->getValue($server); + $requestHandlers = (new \ReflectionClass($protocol))->getProperty('requestHandlers')->getValue($protocol); + + foreach ($requestHandlers as $handler) { + if ($handler instanceof InitializeHandler) { + return $handler->configuration->serverInfo; + } + } + + $this->fail('InitializeHandler not found in request handlers'); + } + private function extractServerCapabilities(Server $server): ServerCapabilities { $protocol = (new \ReflectionClass($server))->getProperty('protocol')->getValue($server); From 9af8a1bb69859e7770b4110f19dd8a808d144c49 Mon Sep 17 00:00:00 2001 From: Christopher Hertel Date: Sat, 15 Aug 2026 03:18:54 +0200 Subject: [PATCH 4/7] [Schema] Serialize every non-null structuredContent MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/Schema/Result/CallToolResult.php | 26 ++++++----- .../Unit/Schema/NonObjectOutputSchemaTest.php | 45 +++++++++++-------- 2 files changed, 41 insertions(+), 30 deletions(-) diff --git a/src/Schema/Result/CallToolResult.php b/src/Schema/Result/CallToolResult.php index 1e04d9f0..13a2acf2 100644 --- a/src/Schema/Result/CallToolResult.php +++ b/src/Schema/Result/CallToolResult.php @@ -116,12 +116,11 @@ public static function fromArray(array $data): self if (isset($data['isError']) && !\is_bool($data['isError'])) { throw new InvalidArgumentException('Invalid "isError" in CallToolResult data.'); } - // Kept object-only on the wire although the property itself now holds any - // JSON value, mirroring the emission gate below: no revision this SDK - // negotiates sends a scalar here, so one is still type-confused input. - if (isset($data['structuredContent']) && !\is_array($data['structuredContent'])) { - throw new InvalidArgumentException('Invalid "structuredContent" in CallToolResult data.'); - } + // `structuredContent` deliberately has no type guard beside its siblings: + // SEP-2106 admits any JSON value there, so every value this method can + // receive is well-typed and there is nothing left to reject. Validating it + // against the tool's outputSchema is the caller's job — the schema isn't + // known here, and rejecting a legal payload would only strand the result. if (isset($data['_meta']) && !\is_array($data['_meta'])) { throw new InvalidArgumentException('Invalid "_meta" in CallToolResult data.'); } @@ -149,12 +148,15 @@ public function jsonSerialize(): array 'isError' => $this->isError, ]; - // Deliberately a truthiness check rather than `null !==`. SEP-2106 lets - // structuredContent be any JSON value, but only from 2026-07-28 onward: - // every version this SDK currently negotiates still requires an object, - // and emitting `[]`, `0` or `false` to those clients is a protocol error. - // Emission widens once results serialize per negotiated version. - if ($this->structuredContent) { + // `null` is the only value that means absent — it is what the constructor, + // success() and error() all use to say "no structured result". A truthiness + // check cannot stand in for that: it drops `0`, `false`, `""` and `[]` while + // still emitting `[1, 2, 3]` or `"text"`, so it neither honours SEP-2106 nor + // shields older peers from the non-object values it does let through. Which + // values a given revision permits is a question for version-aware + // serialization, which results cannot answer yet; until they can, passing + // the caller's value along beats silently discarding half of it. + if (null !== $this->structuredContent) { $result['structuredContent'] = $this->structuredContent; } diff --git a/tests/Unit/Schema/NonObjectOutputSchemaTest.php b/tests/Unit/Schema/NonObjectOutputSchemaTest.php index 3813ae31..ece76f34 100644 --- a/tests/Unit/Schema/NonObjectOutputSchemaTest.php +++ b/tests/Unit/Schema/NonObjectOutputSchemaTest.php @@ -102,12 +102,22 @@ public static function provideStructuredValues(): iterable yield 'empty array' => [[]]; } - #[TestDox('a truthy non-object value reaches the wire')] - public function testNonObjectValueIsSerialized(): void + /** + * `null` is the only value that means absent, so every other JSON value — + * falsy ones included — reaches the wire. This matches the sibling field on + * {@see \Mcp\Schema\Content\ToolResultContent}, which carries the same tool + * result inside a sampling turn. + */ + #[TestDox('every non-null value reaches the wire, falsy ones included')] + #[DataProvider('provideStructuredValues')] + public function testNonNullValueIsSerialized(mixed $value): void { - $result = new CallToolResult([new TextContent('ok')], false, [1, 2, 3]); + $result = new CallToolResult([new TextContent('ok')], false, $value); - $this->assertSame([1, 2, 3], $result->jsonSerialize()['structuredContent']); + $serialized = $result->jsonSerialize(); + + $this->assertArrayHasKey('structuredContent', $serialized); + $this->assertSame($value, $serialized['structuredContent']); } #[TestDox('a null structuredContent is omitted entirely')] @@ -118,28 +128,27 @@ public function testNullStructuredContentOmitted(): void $this->assertArrayNotHasKey('structuredContent', $result->jsonSerialize()); } - /** - * Emission of falsy values stays gated until results serialize per negotiated - * protocol version: every version this SDK speaks today still requires - * structuredContent to be an object. - */ - #[TestDox('falsy values are still withheld from the wire pending version-gated serialization')] - public function testFalsyValuesAreNotYetEmitted(): void + #[TestDox('round-trips any JSON structuredContent through fromArray')] + #[DataProvider('provideStructuredValues')] + public function testRoundTrip(mixed $value): void { - $result = new CallToolResult([new TextContent('ok')], false, []); + $result = CallToolResult::fromArray([ + 'content' => [['type' => 'text', 'text' => 'ok']], + 'structuredContent' => $value, + ]); - $this->assertSame([], $result->structuredContent); - $this->assertArrayNotHasKey('structuredContent', $result->jsonSerialize()); + $this->assertSame($value, $result->structuredContent); } - #[TestDox('round-trips a non-object structuredContent through fromArray')] - public function testRoundTrip(): void + #[TestDox('an object structuredContent still round-trips unchanged')] + public function testObjectRoundTrip(): void { $result = CallToolResult::fromArray([ 'content' => [['type' => 'text', 'text' => 'ok']], - 'structuredContent' => ['a', 'b'], + 'structuredContent' => ['temperature' => 22.5], ]); - $this->assertSame(['a', 'b'], $result->structuredContent); + $this->assertSame(['temperature' => 22.5], $result->structuredContent); + $this->assertSame(['temperature' => 22.5], $result->jsonSerialize()['structuredContent']); } } From 4fced377b94d2d5a8047acd325d7f29b88b35948 Mon Sep 17 00:00:00 2001 From: Christopher Hertel Date: Sat, 15 Aug 2026 03:19:04 +0200 Subject: [PATCH 5/7] [Schema][Server] Make url elicitation reachable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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. --- src/Schema/Result/ElicitResult.php | 12 +- src/Server/ClientGateway.php | 89 ++++++++++++--- tests/Unit/Schema/Result/ElicitResultTest.php | 18 +++ tests/Unit/Server/ClientGatewayTest.php | 105 +++++++++++++++++- 4 files changed, 201 insertions(+), 23 deletions(-) diff --git a/src/Schema/Result/ElicitResult.php b/src/Schema/Result/ElicitResult.php index 70415959..12950d91 100644 --- a/src/Schema/Result/ElicitResult.php +++ b/src/Schema/Result/ElicitResult.php @@ -13,13 +13,15 @@ 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 */ @@ -36,9 +38,13 @@ public function __construct( } /** + * 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} $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,7 +56,7 @@ public static function fromArray(array $data): self $content = isset($data['content']) && \is_array($data['content']) ? $data['content'] : null; - if (ElicitAction::Accept === $action && null === $content) { + if (ElicitationMode::Form === $mode && ElicitAction::Accept === $action && null === $content) { throw new InvalidArgumentException('Content must be provided when action is "accept".'); } diff --git a/src/Server/ClientGateway.php b/src/Server/ClientGateway.php index 445c9e06..9f0f1334 100644 --- a/src/Server/ClientGateway.php +++ b/src/Server/ClientGateway.php @@ -56,9 +56,9 @@ * $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 @@ public function sample(array|Content|string $message, int $maxTokens = 1000, int } /** - * 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,36 @@ public function sample(array|Content|string $message, int $maxTokens = 1000, int */ public function elicit(string $message, ElicitationSchema $requestedSchema, int $timeout = 120): ElicitResult { - $request = new ElicitRequest($message, $requestedSchema); - - $response = $this->request($request, $timeout); + return $this->sendElicitation(ElicitRequest::forForm($message, $requestedSchema), $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. + * + * @param string $message A human-readable message describing what the user is being sent to do + * @param string $url The URL the client should open + * @param int $timeout The timeout in seconds + * + * @return ElicitResult The elicitation response carrying the user's action + * + * @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: + // an `elicitation` capability naming no mode means form mode alone, so a client + // that never named `url` has no way to honour this request. + 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 +312,7 @@ public function supportsSampling(): bool */ public function supportsSamplingTools(): bool { - return $this->hasSamplingSubCapability('tools'); + return $this->hasSubCapability('sampling', 'tools'); } /** @@ -304,20 +325,54 @@ public function supportsSamplingTools(): bool */ public function supportsSamplingContext(): bool { - return $this->hasSamplingSubCapability('context'); + return $this->hasSubCapability('sampling', 'context'); + } + + /** + * 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'); } - private function hasSamplingSubCapability(string $name): bool + /** + * 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); + } + + // The result carries no discriminator of its own, so the mode of the request + // decides whether an accepted response is expected to carry content. + return ElicitResult::fromArray($response->result, $request->mode); } /** diff --git a/tests/Unit/Schema/Result/ElicitResultTest.php b/tests/Unit/Schema/Result/ElicitResultTest.php index 62091a50..4c12669a 100644 --- a/tests/Unit/Schema/Result/ElicitResultTest.php +++ b/tests/Unit/Schema/Result/ElicitResultTest.php @@ -13,6 +13,7 @@ use Mcp\Exception\InvalidArgumentException; use Mcp\Schema\Enum\ElicitAction; +use Mcp\Schema\Enum\ElicitationMode; use Mcp\Schema\Result\ElicitResult; use PHPUnit\Framework\TestCase; @@ -99,6 +100,23 @@ public function testFromArrayWithAcceptActionRequiresContent(): void ElicitResult::fromArray(['action' => 'accept']); } + public function testFromArrayWithUrlModeAllowsContentlessAccept(): void + { + $result = ElicitResult::fromArray(['action' => 'accept'], ElicitationMode::Url); + + $this->assertSame(ElicitAction::Accept, $result->action); + $this->assertNull($result->content); + $this->assertTrue($result->isAccepted()); + } + + public function testFromArrayWithExplicitFormModeRequiresContent(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Content must be provided when action is "accept"'); + + ElicitResult::fromArray(['action' => 'accept'], ElicitationMode::Form); + } + public function testIsAccepted(): void { $acceptResult = new ElicitResult(ElicitAction::Accept, ['name' => 'John']); diff --git a/tests/Unit/Server/ClientGatewayTest.php b/tests/Unit/Server/ClientGatewayTest.php index e6c2cee2..61f5eb02 100644 --- a/tests/Unit/Server/ClientGatewayTest.php +++ b/tests/Unit/Server/ClientGatewayTest.php @@ -12,9 +12,18 @@ namespace Mcp\Tests\Unit\Server; use Mcp\Exception\ClientException; +use Mcp\Exception\InvalidArgumentException; +use Mcp\Schema\ClientCapabilities; +use Mcp\Schema\Elicitation\ElicitationSchema; +use Mcp\Schema\Elicitation\StringSchemaDefinition; +use Mcp\Schema\Enum\ElicitAction; +use Mcp\Schema\Enum\ElicitationMode; use Mcp\Schema\JsonRpc\Error; +use Mcp\Schema\JsonRpc\Request; use Mcp\Schema\JsonRpc\Response; +use Mcp\Schema\Request\ElicitRequest; use Mcp\Schema\Request\ListRootsRequest; +use Mcp\Schema\Result\ElicitResult; use Mcp\Schema\Result\ListRootsResult; use Mcp\Server\ClientGateway; use Mcp\Server\Session\SessionInterface; @@ -63,6 +72,76 @@ public function testSupportsSamplingReturnsFalseWhenNotAdvertised(): void $this->assertFalse($gateway->supportsSampling()); } + public function testSupportsSamplingToolsReflectsTheSubCapability(): void + { + $this->assertTrue($this->gatewayFor(['sampling' => ['tools' => []]])->supportsSamplingTools()); + $this->assertFalse($this->gatewayFor(['sampling' => []])->supportsSamplingTools()); + $this->assertFalse($this->gatewayFor([])->supportsSamplingTools()); + } + + public function testSupportsSamplingToolsReadsTheObjectShapeToo(): void + { + $capabilities = (new ClientCapabilities(samplingTools: true))->jsonSerialize(); + + $this->assertTrue($this->gatewayFor((array) $capabilities)->supportsSamplingTools()); + } + + public function testSupportsElicitationUrlReflectsTheSubCapability(): void + { + $this->assertTrue($this->gatewayFor(['elicitation' => ['url' => []]])->supportsElicitationUrl()); + // An `elicitation` naming no mode declares form mode alone. + $this->assertFalse($this->gatewayFor(['elicitation' => []])->supportsElicitationUrl()); + $this->assertFalse($this->gatewayFor(['elicitation' => ['form' => []]])->supportsElicitationUrl()); + } + + public function testElicitUrlSendsAUrlModeRequest(): void + { + $gateway = $this->gatewayFor(['elicitation' => ['url' => []]]); + + $request = null; + $result = $this->runInFiber( + static fn (): ElicitResult => $gateway->elicitUrl('Authorize the app', 'https://example.com/consent'), + // A url-mode accept carries no content — the interaction happened out of band. + $this->response(['action' => 'accept']), + ElicitRequest::class, + $request, + ); + + $this->assertInstanceOf(ElicitRequest::class, $request); + $this->assertSame(ElicitationMode::Url, $request->mode); + $this->assertSame('https://example.com/consent', $request->url); + $this->assertInstanceOf(ElicitResult::class, $result); + $this->assertSame(ElicitAction::Accept, $result->action); + $this->assertNull($result->content); + } + + public function testElicitUrlRejectsAClientThatOnlySupportsForms(): void + { + $gateway = $this->gatewayFor(['elicitation' => []]); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessageMatches('/elicitation\.url/'); + + $gateway->elicitUrl('Authorize the app', 'https://example.com/consent'); + } + + public function testElicitStillSendsFormMode(): void + { + $gateway = $this->gatewayFor([]); + + $request = null; + $result = $this->runInFiber( + static fn (): ElicitResult => $gateway->elicit('Your name?', new ElicitationSchema(['name' => new StringSchemaDefinition('Name')])), + $this->response(['action' => 'accept', 'content' => ['name' => 'Ada']]), + ElicitRequest::class, + $request, + ); + + $this->assertInstanceOf(ElicitRequest::class, $request); + $this->assertSame(ElicitationMode::Form, $request->mode); + $this->assertSame(['name' => 'Ada'], $result->content); + } + public function testListRootsReturnsRootsFromClient(): void { $session = $this->createMock(SessionInterface::class); @@ -98,19 +177,39 @@ public function testListRootsThrowsClientExceptionOnError(): void } /** - * Runs the gateway call inside a Fiber, asserts it suspends with a roots/list + * @param array $capabilities the client capabilities the session reports + */ + private function gatewayFor(array $capabilities): ClientGateway + { + $session = $this->createMock(SessionInterface::class); + $session->method('getId')->willReturn(Uuid::v4()); + $session->method('get')->willReturnCallback( + static fn (string $key, mixed $default = null): mixed => 'client_capabilities' === $key ? $capabilities : $default, + ); + + return new ClientGateway($session); + } + + /** + * Runs the gateway call inside a Fiber, asserts it suspends with the expected * request, then resumes it with the given client response. * * @param Response>|Error $response + * @param class-string $expectedRequest + * @param ?Request $request receives the request the gateway suspended with + * + * @param-out Request $request */ - private function runInFiber(\Closure $call, Response|Error $response): mixed + private function runInFiber(\Closure $call, Response|Error $response, string $expectedRequest = ListRootsRequest::class, mixed &$request = null): mixed { $fiber = new \Fiber($call); $suspend = $fiber->start(); $this->assertIsArray($suspend); $this->assertSame('request', $suspend['type']); - $this->assertInstanceOf(ListRootsRequest::class, $suspend['request']); + $this->assertInstanceOf($expectedRequest, $suspend['request']); + + $request = $suspend['request']; $fiber->resume($response); From 32fbce420bbb53ea691ccf3f96ec4404786866d5 Mon Sep 17 00:00:00 2001 From: Christopher Hertel Date: Sat, 15 Aug 2026 03:19:23 +0200 Subject: [PATCH 6/7] [Schema] Add CHANGELOG entry for the 2026-07-28 surface --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 67f449b5..2d9aedeb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 ----- From c1a0d3a3278ddc8f1cdc1cee0d55eb3c5fd30ba0 Mon Sep 17 00:00:00 2001 From: Christopher Hertel Date: Sat, 15 Aug 2026 05:01:39 +0200 Subject: [PATCH 7/7] [Capability][Schema][Server] Carry the widened structuredContent end to end MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/Capability/Registry/ToolReference.php | 26 ++++++++---- src/Schema/Result/ElicitResult.php | 11 ++++- src/Schema/Tool.php | 7 +++- .../Handler/Request/CallToolHandler.php | 21 +++++++--- tests/Unit/Capability/RegistryTest.php | 40 +++++++++++++++++++ .../Unit/Schema/NonObjectOutputSchemaTest.php | 12 ++++++ tests/Unit/Schema/Result/ElicitResultTest.php | 11 +++++ 7 files changed, 112 insertions(+), 16 deletions(-) diff --git a/src/Capability/Registry/ToolReference.php b/src/Capability/Registry/ToolReference.php index 04316877..97ace43e 100644 --- a/src/Capability/Registry/ToolReference.php +++ b/src/Capability/Registry/ToolReference.php @@ -71,11 +71,11 @@ public function formatResult(mixed $toolExecutionResult): array * newest handshake revision, whose stricter rule is what * every revision reachable through `initialize` requires * - * @return array|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 @@ public function extractStructuredContent(mixed $toolExecutionResult, ?ProtocolVe ); // 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 @@ public function extractStructuredContent(mixed $toolExecutionResult, ?ProtocolVe 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; } } diff --git a/src/Schema/Result/ElicitResult.php b/src/Schema/Result/ElicitResult.php index 12950d91..6eee42d1 100644 --- a/src/Schema/Result/ElicitResult.php +++ b/src/Schema/Result/ElicitResult.php @@ -56,8 +56,15 @@ public static function fromArray(array $data, ElicitationMode $mode = Elicitatio $content = isset($data['content']) && \is_array($data['content']) ? $data['content'] : null; - if (ElicitationMode::Form === $mode && 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'])) { + // The spec says content is present only for an accepted form: a url-mode + // interaction happens out of band, so there is nothing to submit back and + // anything sent here is malformed rather than extra. + throw new InvalidArgumentException('Content must not be provided for a url-mode elicitation result.'); } return new self($action, $content); diff --git a/src/Schema/Tool.php b/src/Schema/Tool.php index c0ddf266..4c51e501 100644 --- a/src/Schema/Tool.php +++ b/src/Schema/Tool.php @@ -171,7 +171,7 @@ public static function fromArray(array $data): self * annotations?: ToolAnnotations, * icons?: Icon[], * _meta?: array, - * outputSchema?: ToolOutputSchema + * outputSchema?: ToolOutputSchema|\stdClass * } */ public function jsonSerialize(): array @@ -194,7 +194,10 @@ public function jsonSerialize(): array $data['_meta'] = $this->meta; } if (null !== $this->outputSchema) { - $data['outputSchema'] = $this->outputSchema; + // The empty root schema `{}` — "any value" — decodes to `[]` and would + // re-encode as a JSON array, which is not a schema at all. Sub-schemas + // already get this treatment in normalizeSubSchema(). + $data['outputSchema'] = [] === $this->outputSchema ? new \stdClass() : $this->outputSchema; } return $data; diff --git a/src/Server/Handler/Request/CallToolHandler.php b/src/Server/Handler/Request/CallToolHandler.php index 254e8388..3d43d0ac 100644 --- a/src/Server/Handler/Request/CallToolHandler.php +++ b/src/Server/Handler/Request/CallToolHandler.php @@ -113,16 +113,18 @@ public function handle(Request $request, SessionInterface $session): Response|Er $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 @@ public function handle(Request $request, SessionInterface $session): Response|Er 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); + } } diff --git a/tests/Unit/Capability/RegistryTest.php b/tests/Unit/Capability/RegistryTest.php index 9ea4b24e..0b796db6 100644 --- a/tests/Unit/Capability/RegistryTest.php +++ b/tests/Unit/Capability/RegistryTest.php @@ -753,6 +753,46 @@ public function load(RegistryInterface $registry): void }; } + 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( diff --git a/tests/Unit/Schema/NonObjectOutputSchemaTest.php b/tests/Unit/Schema/NonObjectOutputSchemaTest.php index ece76f34..8e0fb0ba 100644 --- a/tests/Unit/Schema/NonObjectOutputSchemaTest.php +++ b/tests/Unit/Schema/NonObjectOutputSchemaTest.php @@ -151,4 +151,16 @@ public function testObjectRoundTrip(): void $this->assertSame(['temperature' => 22.5], $result->structuredContent); $this->assertSame(['temperature' => 22.5], $result->jsonSerialize()['structuredContent']); } + + #[TestDox('the empty root schema serializes as {} rather than []')] + public function testEmptyOutputSchemaSerializesAsObject(): void + { + $tool = Tool::fromArray([ + 'name' => 'demo', + 'inputSchema' => self::validInputSchema(), + 'outputSchema' => [], + ]); + + $this->assertSame('{}', json_encode($tool->jsonSerialize()['outputSchema'])); + } } diff --git a/tests/Unit/Schema/Result/ElicitResultTest.php b/tests/Unit/Schema/Result/ElicitResultTest.php index 4c12669a..b946c770 100644 --- a/tests/Unit/Schema/Result/ElicitResultTest.php +++ b/tests/Unit/Schema/Result/ElicitResultTest.php @@ -177,4 +177,15 @@ public function testJsonSerializeWithCancel(): void 'action' => 'cancel', ], $result->jsonSerialize()); } + + public function testUrlModeRejectsContent(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Content must not be provided for a url-mode elicitation result.'); + + ElicitResult::fromArray( + ['action' => 'accept', 'content' => ['name' => 'Ada']], + ElicitationMode::Url, + ); + } }