Skip to content
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
-----
Expand Down
26 changes: 19 additions & 7 deletions src/Capability/Registry/ToolReference.php
Original file line number Diff line number Diff line change
Expand Up @@ -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<array-key, mixed>|null the structured content, or null if not extractable
* @return mixed the structured content, or null if not extractable
*
* @throws \JsonException if JSON encoding fails for non-Content array/object results
*/
public function extractStructuredContent(mixed $toolExecutionResult, ?ProtocolVersion $protocolVersion = null): ?array
public function extractStructuredContent(mixed $toolExecutionResult, ?ProtocolVersion $protocolVersion = null): mixed
{
$objectOnly = ($protocolVersion ?? ProtocolVersion::latestHandshake())->requiresObjectStructuredContent();

Expand Down Expand Up @@ -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)) {
Expand All @@ -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;
}
}
7 changes: 6 additions & 1 deletion src/Client/Builder.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
}
Expand Down Expand Up @@ -152,6 +156,7 @@ public function build(): Client
$this->name,
$this->version,
$this->description,
title: $this->title,
);

$config = new Configuration(
Expand Down
45 changes: 41 additions & 4 deletions src/Schema/ClientCapabilities.php
Original file line number Diff line number Diff line change
Expand Up @@ -24,9 +24,12 @@ class ClientCapabilities implements \JsonSerializable
* @param ?array<string, mixed> $extensions protocol extensions the client supports (e.g. io.modelcontextprotocol/ui)
* @param ?bool $samplingContext the `sampling.context` sub-capability
* @param ?bool $samplingTools the `sampling.tools` sub-capability
* @param ?bool $elicitationForm The `elicitation.form` sub-capability. Implied by declaring
* `elicitation` without naming any mode.
* @param ?bool $elicitationUrl the `elicitation.url` sub-capability
*
* The two sampling sub-capabilities trail `extensions` rather than sitting next to
* `sampling` so that existing positional calls keep working. Pass them by name.
* The sub-capabilities trail `extensions` rather than sitting next to `sampling` and
* `elicitation` so that existing positional calls keep working. Pass them by name.
*/
public function __construct(
public readonly ?bool $roots = false,
Expand All @@ -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,
) {
}

Expand All @@ -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<string, mixed>,
* extensions?: array<string, mixed>,
* } $data
Expand Down Expand Up @@ -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(
Expand All @@ -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,
Expand Down Expand Up @@ -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) {
Expand Down
10 changes: 9 additions & 1 deletion src/Schema/Content/ToolUseContent.php
Original file line number Diff line number Diff line change
Expand Up @@ -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');
}

Expand All @@ -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,
);
}

Expand Down
29 changes: 29 additions & 0 deletions src/Schema/Enum/ElicitationMode.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
<?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 changes: 25 additions & 0 deletions src/Schema/Enum/IconTheme.php
Original file line number Diff line number Diff line change
@@ -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';
}
28 changes: 22 additions & 6 deletions src/Schema/Icon.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -20,23 +21,27 @@
* src: string,
* mimeType?: string,
* sizes?: string[],
* theme?: string,
* }
*
* @author Christopher Hertel <mail@christopher-hertel.de>
*/
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.');
Expand Down Expand Up @@ -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);
}

/**
Expand Down Expand Up @@ -114,6 +126,10 @@ public function jsonSerialize(): array
$data['sizes'] = $this->sizes;
}

if (null !== $this->theme) {
$data['theme'] = $this->theme->value;
}

return $data;
}
}
12 changes: 12 additions & 0 deletions src/Schema/Implementation.php
Original file line number Diff line number Diff line change
Expand Up @@ -24,13 +24,15 @@ 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',
public readonly string $version = 'dev',
public readonly ?string $description = null,
public readonly ?array $icons = null,
public readonly ?string $websiteUrl = null,
public readonly ?string $title = null,
) {
}

Expand All @@ -41,6 +43,7 @@ public function __construct(
* description?: string,
* icons?: IconData[],
* websiteUrl?: string,
* title?: string,
* } $data
*/
public static function fromArray(array $data): self
Expand All @@ -66,13 +69,17 @@ 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'],
$data['version'],
$data['description'] ?? null,
$data['icons'] ?? null,
$data['websiteUrl'] ?? null,
$data['title'] ?? null,
);
}

Expand All @@ -83,6 +90,7 @@ public static function fromArray(array $data): self
* description?: string,
* icons?: Icon[],
* websiteUrl?: string,
* title?: string,
* }
*/
public function jsonSerialize(): array
Expand All @@ -104,6 +112,10 @@ public function jsonSerialize(): array
$data['websiteUrl'] = $this->websiteUrl;
}

if (null !== $this->title) {
$data['title'] = $this->title;
}

return $data;
}
}
Loading