Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ All notable changes to `mcp/sdk` will be documented in this file.
* Add `annotations` support to `ImageContent` (constructor, `fromArray()`, `fromFile()`, `fromString()`, `jsonSerialize()`), matching `TextContent` and `AudioContent`.
* Add client-side `roots/list` handler (`ListRootsRequestHandler` + `RootsCallbackInterface`) and `Client::sendRootsListChanged()`, plus server-side `ClientGateway::listRoots()` / `supportsRoots()` and `ListRootsResult::fromArray()`.
* Add `ClientGateway::supportsSampling()`, so a tool can check the client's advertised capabilities before issuing a `sampling/createMessage` request instead of asking and catching the refusal. Matches the existing `supportsRoots()` and `supportsElicitation()`.
* [BC Break] Gate `structuredContent` on the negotiated protocol revision: `ToolReference::extractStructuredContent()` takes an optional `ProtocolVersion` and, for revisions predating SEP-2106 (`2025-11-25` and earlier, where `structuredContent` must be a JSON object), returns `null` for a tool result that is a PHP list or an object serializing to a JSON array. From `2026-07-28` on both are emitted as-is. Objects serializing to a scalar and arrays holding `Content` instances are never emitted, in any revision. `CallToolHandler` resolves the revision from the request's `_meta` (modern era) or the session (handshake era) and falls back to the strictest rule; it logs a warning when a tool declares an `outputSchema` but returns a value that cannot be sent, and when a self-built `CallToolResult` carries a `structuredContent` the revision does not allow (that one is passed through unchanged). Tools returning a list against an older client keep their JSON-encoded value in `content`; they just no longer advertise an invalid `structuredContent`.
* Add `Mcp\Schema\Content\ResourceLink` for the spec's `resource_link` content block (protocol revision 2025-06-18+), letting tool results and prompt messages reference a resource by URI/name without embedding its contents. Accepted anywhere `resource` (`EmbeddedResource`) content is (de)serialized: `CallToolResult::fromArray()`, `PromptMessage::fromArray()`, and `PromptResultFormatter`.
* Negotiate the protocol revision during the `initialize` handshake: the server echoes a revision it supports and counter-offers `ProtocolVersion::latestHandshake()` otherwise (`Builder::setProtocolVersion()` pins it to exactly one), and the client fails the handshake on a counter-offer it cannot speak rather than continuing on an unagreed revision. Adds `Client::getProtocolVersion()`, the `2026-07-28` revision, and the era helpers on `ProtocolVersion` — revisions from `2026-07-28` on have no `initialize`, so they are excluded from negotiation and from `ProtocolVersionMiddleware`'s default supported set.

Expand Down
76 changes: 76 additions & 0 deletions docs/mcp-elements.md
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,82 @@ public function getMultipleContent(): array
}
```

#### Structured Output

Besides the human-readable `content`, a tool result can carry a machine-readable `structuredContent` value. Declare its
shape with `outputSchema`, a JSON Schema of type `object`:

```php
#[McpTool(
name: 'get_weather',
outputSchema: [
'type' => 'object',
'properties' => [
'temperature' => ['type' => 'number'],
'conditions' => ['type' => 'string'],
],
'required' => ['temperature', 'conditions'],
]
)]
public function getWeather(string $city): array
{
// Sent as `structuredContent`, and JSON-encoded into `content` for clients that ignore it
return ['temperature' => 22.5, 'conditions' => 'sunny'];
}
```

The same schema can be passed to manual registration:

```php
$builder->addTool([WeatherHandler::class, 'getWeather'], outputSchema: [/* ... */]);
```

The SDK fills `structuredContent` whenever the return value qualifies — `outputSchema` is what tells clients to expect it
and lets them validate it. What qualifies depends on the protocol revision the call is served under:

| Return value | `structuredContent` |
|---|---|
| Associative array (`['temperature' => 22.5]`) | The array |
| Object (`stdClass`, DTO, `JsonSerializable`) that serializes to a JSON object | Its JSON representation |
| List (`[1, 2, 3]`, `[['id' => 1], ['id' => 2]]`), or an object serializing to one | Omitted before `2026-07-28`, kept from it on |
| Array holding `Content` instances | Omitted (already carried in `content`) |
| Scalars, `null`, `Content` instances | Omitted |

Up to revision `2025-11-25`, `structuredContent` had to be a JSON object, so a PHP list — which serializes to a JSON
array — was not emittable and strict clients rejected the whole tool call over one. [SEP-2106][sep-2106], part of
revision `2026-07-28`, widened `outputSchema` to any JSON Schema 2020-12 and `structuredContent` to any JSON value
conforming to it. The SDK picks the rule from the revision negotiated for the call, so a tool serving both eras needs the
object shape to produce structured output everywhere. Wrap the list in a key for that:

```php
// Structured content only from 2026-07-28 on: a bare list is not a JSON object
public function listUsersFlat(): array
{
return [['id' => 1], ['id' => 2]];
}

#[McpTool(outputSchema: [
'type' => 'object',
'properties' => [
'items' => ['type' => 'array', 'items' => ['type' => 'object']],
],
'required' => ['items']
])]
public function listUsers(): array
{
return ['items' => [['id' => 1], ['id' => 2]]];
}
```

Either way the data reaches the client: a return value with no structured representation is still JSON-encoded into
`content` as a `TextContent`. When a tool declares an `outputSchema` but returns something that cannot be sent as
`structuredContent`, the SDK logs a warning — the value is not silently dropped.

A tool that wants to branch on the revision itself can read it from the injected `RequestContext`, see
[Client Communication](server-client-communication.md#client-gateway).

[sep-2106]: https://modelcontextprotocol.io/specification/2026-07-28/server/tools#structured-content

#### Error Handling

Tool handlers can throw any exception, but the type determines how it's handled:
Expand Down
11 changes: 11 additions & 0 deletions docs/server-client-communication.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,17 @@ class MyService
$context->getClientGateway()->log(...);
```

The same object also carries the protocol revision negotiated for the current request, which is useful when a feature is
only available from a certain revision on:

```php
use Mcp\Schema\Enum\ProtocolVersion;

if ($context->getProtocolVersion()->isAtLeast(ProtocolVersion::V2026_07_28)) {
// e.g. a bare list is only valid as `structuredContent` from this revision on
}
```

## Sampling

With [sampling](https://modelcontextprotocol.io/specification/2025-06-18/client/sampling) servers can request clients to
Expand Down
48 changes: 40 additions & 8 deletions src/Capability/Registry/ToolReference.php
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@

use Mcp\Capability\Formatter\ToolResultFormatter;
use Mcp\Schema\Content\Content;
use Mcp\Schema\Enum\ProtocolVersion;
use Mcp\Schema\Tool;

/**
Expand Down Expand Up @@ -59,22 +60,39 @@ public function formatResult(mixed $toolExecutionResult): array
/**
* Extracts structured content from a tool result using the output schema.
*
* @param mixed $toolExecutionResult the raw value returned by the tool's PHP method
* What may be sent as `structuredContent` depends on the protocol revision in
* use. Up to `2025-11-25` it has to be a JSON object, and `outputSchema` is
* restricted to `type: "object"` to match. From `2026-07-28` on (SEP-2106)
* `outputSchema` is any JSON Schema 2020-12 and `structuredContent` is any JSON
* value conforming to it — a list included.
Comment thread
chr-hertel marked this conversation as resolved.
*
* @param mixed $toolExecutionResult the raw value returned by the tool's PHP method
* @param ?ProtocolVersion $protocolVersion revision the result is produced for; defaults to the
* newest handshake revision, whose stricter rule is what
* every revision reachable through `initialize` requires
*
* @return array<string, mixed>|null the structured content, or null if not extractable
* @return array<array-key, mixed>|null 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): ?array
public function extractStructuredContent(mixed $toolExecutionResult, ?ProtocolVersion $protocolVersion = null): ?array
{
$objectOnly = ($protocolVersion ?? ProtocolVersion::latestHandshake())->requiresObjectStructuredContent();

if (\is_array($toolExecutionResult)) {
// A PHP list serializes to a JSON array, which the revisions predating
// SEP-2106 do not allow as `structuredContent` — strict clients reject
// the whole tool call when one is sent.
if ($objectOnly && array_is_list($toolExecutionResult)) {
return null;
}

foreach ($toolExecutionResult as $item) {
if ($item instanceof Content) {
// Content items are already reflected in the result's `content`
// array; a raw array holding one or more of them isn't
// structured data and, if it were serialized as-is, could
// produce a `structuredContent` value that isn't a JSON object
// (e.g. a list), which the spec doesn't allow.
// array; an array holding one or more of them isn't structured
// data. This holds in every revision — it is a duplication rule,
// not a shape rule.
return null;
}
}
Expand All @@ -88,9 +106,23 @@ public function extractStructuredContent(mixed $toolExecutionResult): ?array
\JSON_PRETTY_PRINT | \JSON_UNESCAPED_SLASHES | \JSON_UNESCAPED_UNICODE | \JSON_THROW_ON_ERROR | \JSON_INVALID_UTF8_SUBSTITUTE
);

return json_decode(
$decoded = json_decode(
$jsonResult, true, 512, \JSON_THROW_ON_ERROR
);

// 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.
if (!\is_array($decoded)) {
return null;
}

if ($objectOnly && array_is_list($decoded)) {
return null;
}

return $decoded;
}

return null;
Expand Down
15 changes: 12 additions & 3 deletions src/Schema/Content/ResourceLink.php
Original file line number Diff line number Diff line change
Expand Up @@ -84,16 +84,25 @@ public static function fromArray(array $data): self
if (isset($data['_meta']) && !\is_array($data['_meta'])) {
throw new InvalidArgumentException('Invalid "_meta" in ResourceLink data.');
}
if (isset($data['description']) && !\is_string($data['description'])) {
throw new InvalidArgumentException('Invalid "description" in ResourceLink data.');
}
if (isset($data['mimeType']) && !\is_string($data['mimeType'])) {
throw new InvalidArgumentException('Invalid "mimeType" in ResourceLink data.');
}
if (isset($data['size']) && !\is_int($data['size'])) {
throw new InvalidArgumentException('Invalid "size" in ResourceLink data; expected an integer.');
}

return new self(
uri: $data['uri'],
name: $data['name'],
title: isset($data['title']) && \is_string($data['title']) ? $data['title'] : null,
description: $data['description'] ?? null,
mimeType: $data['mimeType'] ?? null,
annotations: isset($data['annotations']) ? Annotations::fromArray($data['annotations']) : null,
size: isset($data['size']) ? (int) $data['size'] : null,
icons: isset($data['icons']) && \is_array($data['icons']) ? array_map(Icon::fromArray(...), $data['icons']) : null,
annotations: Annotations::tryFromArray($data['annotations'] ?? null, 'ResourceLink'),
size: $data['size'] ?? null,
icons: isset($data['icons']) && \is_array($data['icons']) ? Icon::listFromArray($data['icons'], 'ResourceLink') : null,
meta: $data['_meta'] ?? null,
);
}
Expand Down
14 changes: 14 additions & 0 deletions src/Schema/Enum/ProtocolVersion.php
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,20 @@ public function isModern(): bool
return $this->isAtLeast(self::FIRST_MODERN_VERSION);
}

/**
* Whether this revision restricts `structuredContent` to a JSON object.
*
* SEP-2106, part of {@see self::V2026_07_28}, widened `outputSchema` to any
* JSON Schema 2020-12 and `structuredContent` to any JSON value conforming to
* it. Up to `2025-11-25` both are restricted to an object.
*
* @see https://modelcontextprotocol.io/specification/2026-07-28/server/tools#structured-content
*/
public function requiresObjectStructuredContent(): bool
{
return !$this->isAtLeast(self::V2026_07_28);
}

/**
* Whether this revision is at least as new as $minimum.
*/
Expand Down
27 changes: 26 additions & 1 deletion src/Server/Handler/Request/CallToolHandler.php
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
use Mcp\Schema\JsonRpc\Response;
use Mcp\Schema\Request\CallToolRequest;
use Mcp\Schema\Result\CallToolResult;
use Mcp\Server\RequestContext;
use Mcp\Server\Session\SessionInterface;
use Psr\Log\LoggerInterface;
use Psr\Log\NullLogger;
Expand Down Expand Up @@ -92,13 +93,37 @@ public function handle(Request $request, SessionInterface $session): Response|Er
$arguments['_session'] = $session;
$arguments['_request'] = $request;

$context = new RequestContext($session, $request);

try {
$result = $this->referenceHandler->handle($reference, $arguments);

$protocolVersion = $context->getProtocolVersion();

$structuredContent = null;
if (!$result instanceof CallToolResult) {
$structuredContent = $reference->extractStructuredContent($result);
$structuredContent = $reference->extractStructuredContent($result, $protocolVersion);

if (null === $structuredContent && null !== $reference->tool->outputSchema) {
$this->logger->warning('Tool declares an "outputSchema" but returned a value that cannot be sent as "structuredContent"; the value is only carried in "content".', [
'name' => $toolName,
'result_type' => get_debug_type($result),
]);
}

$result = new CallToolResult($reference->formatResult($result), structuredContent: $structuredContent);
} elseif ($protocolVersion->requiresObjectStructuredContent()
&& \is_array($result->structuredContent)
&& [] !== $result->structuredContent
&& array_is_list($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.', [
'name' => $toolName,
'protocol_version' => $protocolVersion->value,
]);
}

$this->logger->debug('Tool executed successfully', [
Expand Down
29 changes: 29 additions & 0 deletions src/Server/RequestContext.php
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
namespace Mcp\Server;

use Mcp\Capability\Logger\ClientLogger;
use Mcp\Schema\Enum\ProtocolVersion;
use Mcp\Schema\JsonRpc\Request;
use Mcp\Server\Session\SessionInterface;

Expand All @@ -25,6 +26,14 @@
*/
final class RequestContext
{
/**
* `_meta` key carrying the protocol revision of a single request, introduced
* with the modern era that replaced the `initialize` handshake.
*
* @see https://modelcontextprotocol.io/specification/2026-07-28/basic/versioning
*/
private const PROTOCOL_VERSION_META_KEY = 'io.modelcontextprotocol/protocolVersion';

private ?ClientGateway $clientGateway = null;
private ?ClientLogger $clientLogger = null;

Expand All @@ -44,6 +53,26 @@ public function getSession(): SessionInterface
return $this->session;
}

/**
* The protocol revision this request is served under.
*
* Modern revisions declare it per request in `_meta`, handshake ones negotiate
* it once and keep it on the session. Neither is guaranteed to be present — a
* transport may skip `initialize` entirely — so this falls back to the newest
* handshake revision, whose rules hold for every revision below it too.
*/
public function getProtocolVersion(): ProtocolVersion
{
$requested = $this->request->getMeta()[self::PROTOCOL_VERSION_META_KEY]
?? $this->session->get('protocol_version');

if (!\is_string($requested)) {
return ProtocolVersion::latestHandshake();
}

return ProtocolVersion::tryFrom($requested) ?? ProtocolVersion::latestHandshake();
}

public function getClientGateway(): ClientGateway
{
if (null == $this->clientGateway) {
Expand Down
33 changes: 33 additions & 0 deletions tests/Unit/Capability/Formatter/PromptResultFormatterTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,39 @@ public function testFormatRoleContentArrayWithResourceLinkContent(): void
$this->assertSame('a.png', $result[0]->content->name);
}

public function testFormatTypedResourceLinkContentPreservesOptionalFields(): void
{
$result = (new PromptResultFormatter())->format([
[
'role' => 'user',
'content' => [
'type' => 'resource_link',
'uri' => 'file:///a.png',
'name' => 'a.png',
'title' => 'A picture',
'description' => 'The first picture',
'mimeType' => 'image/png',
'size' => 1024,
'annotations' => ['audience' => ['user'], 'priority' => 0.5],
'_meta' => ['origin' => 'test'],
],
],
]);

$content = $result[0]->content;
$this->assertInstanceOf(ResourceLink::class, $content);
$this->assertSame('file:///a.png', $content->uri);
$this->assertSame('a.png', $content->name);
$this->assertSame('A picture', $content->title);
$this->assertSame('The first picture', $content->description);
$this->assertSame('image/png', $content->mimeType);
$this->assertSame(1024, $content->size);
$this->assertNotNull($content->annotations);
$this->assertSame([Role::User], $content->annotations->audience);
$this->assertSame(0.5, $content->annotations->priority);
$this->assertSame(['origin' => 'test'], $content->meta);
}

public function testFormatUserAssistantShorthand(): void
{
$result = (new PromptResultFormatter())->format([
Expand Down
Loading