[Capability] Harden structuredContent extraction (#417)
* Address review feedback on resource_link Three follow-ups from review of the resource_link work: * PromptResultFormatter: add a regression test proving the optional fields of a typed resource_link block (title, description, mimeType, size, annotations, _meta) survive formatting. The delegation to ResourceLink::fromArray() already landed while rebasing onto the PromptResultFormatter refactor, but the existing test only supplied uri/name and so could not catch a reintroduced field drop. * ResourceLink::fromArray(): validate optional fields consistently, the way the equivalent ResourceDefinition::fromArray() already does. description, mimeType and size now raise InvalidArgumentException instead of surfacing a TypeError (or silently coercing, for size's (int) cast); annotations goes through Annotations::tryFromArray(), which carries the is_array() guard that icons already had; icons uses Icon::listFromArray() so a non-array entry is reported in context. * ToolReference::extractStructuredContent(): never emit a list as structuredContent. The Content guard added earlier fixed only one instance of the real invariant - structuredContent must be a JSON object, and a PHP list can never be one. Tool::fromArray() already enforces the matching rule by rejecting an outputSchema whose type is not "object", so this aligns the runtime path with it. The test asserting the opposite for an array-typed outputSchema contradicted the phpstan type and the fromArray() check introduced alongside it in the same commit, so it is replaced by tests for the two list shapes. * Apply the structuredContent object rule to object results too `extractStructuredContent()` guards raw array results against being emitted as a JSON array, but the object branch handed back whatever `json_decode()` produced. A `JsonSerializable` returning a list or a scalar slipped straight through, producing exactly the `structuredContent` the array guard exists to prevent — and a return value that contradicts the method's own `array<string, mixed>|null` signature. Check the decoded value before returning it, and cover the object branch, which had no tests at all. * Document structured tool output `outputSchema` and `structuredContent` were undocumented: the tool return value docs covered only the `content` side, and the schema generation section is about tool parameters. Add a "Structured Output" subsection covering how to declare the schema, which return values populate `structuredContent`, and why a list has to be wrapped in a key to get structured output at all. * Gate structuredContent on the negotiated protocol revision SEP-2106 (revision 2026-07-28) widens structuredContent to any JSON value; earlier revisions require an object. Resolve the revision per request and apply the matching rule, and warn when a declared outputSchema yields none. * Resolve the protocol revision on RequestContext Keeps the `_meta`-then-session lookup in one place instead of per handler, and exposes the negotiated revision to tool handlers. * Document reading the negotiated revision from RequestContext * Warn when a self-built CallToolResult carries an invalid structuredContent Returning a CallToolResult opts out of the extraction rules, so the value is still sent unchanged — but a JSON array is not valid before SEP-2106.
This commit is contained in:
parent
82aac972c1
commit
becce29c41
14 changed files with 616 additions and 36 deletions
|
|
@ -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 `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 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()`.
|
* 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`.
|
* 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.
|
* 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.
|
||||||
* 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.
|
* 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.
|
||||||
|
|
|
||||||
|
|
@ -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
|
#### Error Handling
|
||||||
|
|
||||||
Tool handlers can throw any exception, but the type determines how it's handled:
|
Tool handlers can throw any exception, but the type determines how it's handled:
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,17 @@ class MyService
|
||||||
$context->getClientGateway()->log(...);
|
$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
|
## Sampling
|
||||||
|
|
||||||
With [sampling](https://modelcontextprotocol.io/specification/2025-11-25/client/sampling) servers can request clients to
|
With [sampling](https://modelcontextprotocol.io/specification/2025-11-25/client/sampling) servers can request clients to
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ namespace Mcp\Capability\Registry;
|
||||||
|
|
||||||
use Mcp\Capability\Formatter\ToolResultFormatter;
|
use Mcp\Capability\Formatter\ToolResultFormatter;
|
||||||
use Mcp\Schema\Content\Content;
|
use Mcp\Schema\Content\Content;
|
||||||
|
use Mcp\Schema\Enum\ProtocolVersion;
|
||||||
use Mcp\Schema\Tool;
|
use Mcp\Schema\Tool;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -59,22 +60,39 @@ class ToolReference extends ElementReference
|
||||||
/**
|
/**
|
||||||
* Extracts structured content from a tool result using the output schema.
|
* 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.
|
||||||
*
|
*
|
||||||
* @return array<string, mixed>|null the structured content, or null if not extractable
|
* @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<array-key, mixed>|null the structured content, or null if not extractable
|
||||||
*
|
*
|
||||||
* @throws \JsonException if JSON encoding fails for non-Content array/object results
|
* @throws \JsonException if JSON encoding fails for non-Content array/object results
|
||||||
*/
|
*/
|
||||||
public function extractStructuredContent(mixed $toolExecutionResult): ?array
|
public function extractStructuredContent(mixed $toolExecutionResult, ?ProtocolVersion $protocolVersion = null): ?array
|
||||||
{
|
{
|
||||||
|
$objectOnly = ($protocolVersion ?? ProtocolVersion::latestHandshake())->requiresObjectStructuredContent();
|
||||||
|
|
||||||
if (\is_array($toolExecutionResult)) {
|
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) {
|
foreach ($toolExecutionResult as $item) {
|
||||||
if ($item instanceof Content) {
|
if ($item instanceof Content) {
|
||||||
// Content items are already reflected in the result's `content`
|
// Content items are already reflected in the result's `content`
|
||||||
// array; a raw array holding one or more of them isn't
|
// array; an array holding one or more of them isn't structured
|
||||||
// structured data and, if it were serialized as-is, could
|
// data. This holds in every revision — it is a duplication rule,
|
||||||
// produce a `structuredContent` value that isn't a JSON object
|
// not a shape rule.
|
||||||
// (e.g. a list), which the spec doesn't allow.
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -88,9 +106,23 @@ class ToolReference extends ElementReference
|
||||||
\JSON_PRETTY_PRINT | \JSON_UNESCAPED_SLASHES | \JSON_UNESCAPED_UNICODE | \JSON_THROW_ON_ERROR | \JSON_INVALID_UTF8_SUBSTITUTE
|
\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
|
$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;
|
return null;
|
||||||
|
|
|
||||||
|
|
@ -84,6 +84,15 @@ class ResourceLink extends Content
|
||||||
if (isset($data['_meta']) && !\is_array($data['_meta'])) {
|
if (isset($data['_meta']) && !\is_array($data['_meta'])) {
|
||||||
throw new InvalidArgumentException('Invalid "_meta" in ResourceLink data.');
|
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(
|
return new self(
|
||||||
uri: $data['uri'],
|
uri: $data['uri'],
|
||||||
|
|
@ -91,9 +100,9 @@ class ResourceLink extends Content
|
||||||
title: isset($data['title']) && \is_string($data['title']) ? $data['title'] : null,
|
title: isset($data['title']) && \is_string($data['title']) ? $data['title'] : null,
|
||||||
description: $data['description'] ?? null,
|
description: $data['description'] ?? null,
|
||||||
mimeType: $data['mimeType'] ?? null,
|
mimeType: $data['mimeType'] ?? null,
|
||||||
annotations: isset($data['annotations']) ? Annotations::fromArray($data['annotations']) : null,
|
annotations: Annotations::tryFromArray($data['annotations'] ?? null, 'ResourceLink'),
|
||||||
size: isset($data['size']) ? (int) $data['size'] : null,
|
size: $data['size'] ?? null,
|
||||||
icons: isset($data['icons']) && \is_array($data['icons']) ? array_map(Icon::fromArray(...), $data['icons']) : null,
|
icons: isset($data['icons']) && \is_array($data['icons']) ? Icon::listFromArray($data['icons'], 'ResourceLink') : null,
|
||||||
meta: $data['_meta'] ?? null,
|
meta: $data['_meta'] ?? null,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -101,6 +101,20 @@ enum ProtocolVersion: string
|
||||||
return $this->isAtLeast(self::FIRST_MODERN_VERSION);
|
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.
|
* Whether this revision is at least as new as $minimum.
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,7 @@ use Mcp\Schema\JsonRpc\Request;
|
||||||
use Mcp\Schema\JsonRpc\Response;
|
use Mcp\Schema\JsonRpc\Response;
|
||||||
use Mcp\Schema\Request\CallToolRequest;
|
use Mcp\Schema\Request\CallToolRequest;
|
||||||
use Mcp\Schema\Result\CallToolResult;
|
use Mcp\Schema\Result\CallToolResult;
|
||||||
|
use Mcp\Server\RequestContext;
|
||||||
use Mcp\Server\Session\SessionInterface;
|
use Mcp\Server\Session\SessionInterface;
|
||||||
use Psr\Log\LoggerInterface;
|
use Psr\Log\LoggerInterface;
|
||||||
use Psr\Log\NullLogger;
|
use Psr\Log\NullLogger;
|
||||||
|
|
@ -92,13 +93,37 @@ final class CallToolHandler implements RequestHandlerInterface
|
||||||
$arguments['_session'] = $session;
|
$arguments['_session'] = $session;
|
||||||
$arguments['_request'] = $request;
|
$arguments['_request'] = $request;
|
||||||
|
|
||||||
|
$context = new RequestContext($session, $request);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$result = $this->referenceHandler->handle($reference, $arguments);
|
$result = $this->referenceHandler->handle($reference, $arguments);
|
||||||
|
|
||||||
|
$protocolVersion = $context->getProtocolVersion();
|
||||||
|
|
||||||
$structuredContent = null;
|
$structuredContent = null;
|
||||||
if (!$result instanceof CallToolResult) {
|
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);
|
$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', [
|
$this->logger->debug('Tool executed successfully', [
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@
|
||||||
namespace Mcp\Server;
|
namespace Mcp\Server;
|
||||||
|
|
||||||
use Mcp\Capability\Logger\ClientLogger;
|
use Mcp\Capability\Logger\ClientLogger;
|
||||||
|
use Mcp\Schema\Enum\ProtocolVersion;
|
||||||
use Mcp\Schema\JsonRpc\Request;
|
use Mcp\Schema\JsonRpc\Request;
|
||||||
use Mcp\Server\Session\SessionInterface;
|
use Mcp\Server\Session\SessionInterface;
|
||||||
|
|
||||||
|
|
@ -25,6 +26,14 @@ use Mcp\Server\Session\SessionInterface;
|
||||||
*/
|
*/
|
||||||
final class RequestContext
|
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 ?ClientGateway $clientGateway = null;
|
||||||
private ?ClientLogger $clientLogger = null;
|
private ?ClientLogger $clientLogger = null;
|
||||||
|
|
||||||
|
|
@ -44,6 +53,26 @@ final class RequestContext
|
||||||
return $this->session;
|
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
|
public function getClientGateway(): ClientGateway
|
||||||
{
|
{
|
||||||
if (null == $this->clientGateway) {
|
if (null == $this->clientGateway) {
|
||||||
|
|
|
||||||
|
|
@ -55,6 +55,39 @@ class PromptResultFormatterTest extends TestCase
|
||||||
$this->assertSame('a.png', $result[0]->content->name);
|
$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
|
public function testFormatUserAssistantShorthand(): void
|
||||||
{
|
{
|
||||||
$result = (new PromptResultFormatter())->format([
|
$result = (new PromptResultFormatter())->format([
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,7 @@ use Mcp\Exception\ResourceNotFoundException;
|
||||||
use Mcp\Exception\ToolNotFoundException;
|
use Mcp\Exception\ToolNotFoundException;
|
||||||
use Mcp\Schema\Content\ResourceLink;
|
use Mcp\Schema\Content\ResourceLink;
|
||||||
use Mcp\Schema\Content\TextContent;
|
use Mcp\Schema\Content\TextContent;
|
||||||
|
use Mcp\Schema\Enum\ProtocolVersion;
|
||||||
use Mcp\Schema\Prompt;
|
use Mcp\Schema\Prompt;
|
||||||
use Mcp\Schema\ResourceDefinition;
|
use Mcp\Schema\ResourceDefinition;
|
||||||
use Mcp\Schema\ResourceTemplate;
|
use Mcp\Schema\ResourceTemplate;
|
||||||
|
|
@ -496,50 +497,142 @@ class RegistryTest extends TestCase
|
||||||
$this->assertEquals(['success' => true, 'message' => 'done'], $toolRef->extractStructuredContent(['success' => true, 'message' => 'done']));
|
$this->assertEquals(['success' => true, 'message' => 'done'], $toolRef->extractStructuredContent(['success' => true, 'message' => 'done']));
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testExtractStructuredContentReturnsArrayDirectlyForArrayOutputSchema(): void
|
/**
|
||||||
|
* @dataProvider provideHandshakeVersions
|
||||||
|
*/
|
||||||
|
public function testExtractStructuredContentDropsListResultsBeforeSep2106(?ProtocolVersion $version): void
|
||||||
{
|
{
|
||||||
// Arrange
|
// Up to 2025-11-25 a PHP list serializes to something `structuredContent`
|
||||||
|
// does not allow — a JSON array — and `Tool::fromArray()` enforces the
|
||||||
|
// matching rule by rejecting any outputSchema whose type is not "object".
|
||||||
$outputSchema = [
|
$outputSchema = [
|
||||||
'type' => 'array',
|
'type' => 'object',
|
||||||
'items' => [
|
'properties' => [
|
||||||
'type' => 'object',
|
'foo' => ['type' => 'string'],
|
||||||
'properties' => [
|
|
||||||
'foo' => [
|
|
||||||
'type' => 'string',
|
|
||||||
'description' => 'A static value',
|
|
||||||
],
|
|
||||||
],
|
|
||||||
'required' => ['foo'],
|
|
||||||
],
|
],
|
||||||
|
'required' => ['foo'],
|
||||||
];
|
];
|
||||||
|
|
||||||
$tool = $this->createValidTool('list_static_data', $outputSchema);
|
$tool = $this->createValidTool('list_static_data', $outputSchema);
|
||||||
$toolReturnValue = [
|
$toolReturnValue = [
|
||||||
['foo' => 'bar'],
|
['foo' => 'bar'],
|
||||||
['foo' => 'bar'],
|
['foo' => 'bar'],
|
||||||
['foo' => 'bar'],
|
|
||||||
['foo' => 'bar'],
|
|
||||||
];
|
];
|
||||||
|
|
||||||
$this->registry->registerTool($tool, static fn () => $toolReturnValue);
|
$this->registry->registerTool($tool, static fn () => $toolReturnValue);
|
||||||
|
|
||||||
// Act
|
|
||||||
$toolRef = $this->registry->getTool('list_static_data');
|
$toolRef = $this->registry->getTool('list_static_data');
|
||||||
$structuredContent = $toolRef->extractStructuredContent($toolReturnValue);
|
$this->assertNull($toolRef->extractStructuredContent($toolReturnValue, $version));
|
||||||
|
}
|
||||||
|
|
||||||
// Assert
|
/**
|
||||||
$this->assertNotNull($structuredContent);
|
* The revision is optional, and omitting it has to keep the strict rule: it is
|
||||||
$this->assertCount(4, $structuredContent);
|
* what every revision reachable through the `initialize` handshake requires.
|
||||||
$this->assertEquals([
|
*
|
||||||
|
* @return iterable<string, array{?ProtocolVersion}>
|
||||||
|
*/
|
||||||
|
public static function provideHandshakeVersions(): iterable
|
||||||
|
{
|
||||||
|
yield 'unspecified' => [null];
|
||||||
|
|
||||||
|
foreach (ProtocolVersion::handshakeVersions() as $version) {
|
||||||
|
yield $version->value => [$version];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testExtractStructuredContentKeepsListResultsFromSep2106On(): void
|
||||||
|
{
|
||||||
|
// SEP-2106 widened `structuredContent` to any JSON value conforming to
|
||||||
|
// `outputSchema`, and `outputSchema` to any JSON Schema 2020-12 — the spec's
|
||||||
|
// own example of a legal result is a list of records like this one.
|
||||||
|
$outputSchema = [
|
||||||
|
'type' => 'array',
|
||||||
|
'items' => [
|
||||||
|
'type' => 'object',
|
||||||
|
'properties' => ['foo' => ['type' => 'string']],
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
$tool = $this->createValidTool('list_static_data', $outputSchema);
|
||||||
|
$toolReturnValue = [
|
||||||
['foo' => 'bar'],
|
['foo' => 'bar'],
|
||||||
['foo' => 'bar'],
|
['foo' => 'baz'],
|
||||||
['foo' => 'bar'],
|
];
|
||||||
['foo' => 'bar'],
|
|
||||||
], $structuredContent);
|
$this->registry->registerTool($tool, static fn () => $toolReturnValue);
|
||||||
|
|
||||||
|
$toolRef = $this->registry->getTool('list_static_data');
|
||||||
|
$this->assertSame($toolReturnValue, $toolRef->extractStructuredContent($toolReturnValue, ProtocolVersion::V2026_07_28));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testExtractStructuredContentDropsListOfScalarsBeforeSep2106(): void
|
||||||
|
{
|
||||||
|
$tool = $this->createValidTool('list_ids', null);
|
||||||
|
$toolReturnValue = ['101', '102', '103'];
|
||||||
|
|
||||||
|
$this->registry->registerTool($tool, static fn () => $toolReturnValue);
|
||||||
|
|
||||||
|
$toolRef = $this->registry->getTool('list_ids');
|
||||||
|
$this->assertNull($toolRef->extractStructuredContent($toolReturnValue, ProtocolVersion::V2025_11_25));
|
||||||
|
$this->assertSame($toolReturnValue, $toolRef->extractStructuredContent($toolReturnValue, ProtocolVersion::V2026_07_28));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testExtractStructuredContentEncodesObjectResults(): void
|
||||||
|
{
|
||||||
|
$tool = $this->createValidTool('describe_thing', null);
|
||||||
|
$toolReturnValue = new \stdClass();
|
||||||
|
$toolReturnValue->id = 1;
|
||||||
|
$toolReturnValue->label = 'thing';
|
||||||
|
|
||||||
|
$this->registry->registerTool($tool, static fn () => $toolReturnValue);
|
||||||
|
|
||||||
|
$toolRef = $this->registry->getTool('describe_thing');
|
||||||
|
$this->assertSame(['id' => 1, 'label' => 'thing'], $toolRef->extractStructuredContent($toolReturnValue));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testExtractStructuredContentAppliesTheListRuleToObjectResultsToo(): void
|
||||||
|
{
|
||||||
|
// `JsonSerializable` can hand back a list just as a raw array result can,
|
||||||
|
// and it is no more — and no less — valid for having come from an object.
|
||||||
|
$tool = $this->createValidTool('list_things', null);
|
||||||
|
$toolReturnValue = new class implements \JsonSerializable {
|
||||||
|
public function jsonSerialize(): array
|
||||||
|
{
|
||||||
|
return [['id' => 1], ['id' => 2]];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
$this->registry->registerTool($tool, static fn () => $toolReturnValue);
|
||||||
|
|
||||||
|
$toolRef = $this->registry->getTool('list_things');
|
||||||
|
$this->assertNull($toolRef->extractStructuredContent($toolReturnValue, ProtocolVersion::V2025_11_25));
|
||||||
|
$this->assertSame([['id' => 1], ['id' => 2]], $toolRef->extractStructuredContent($toolReturnValue, ProtocolVersion::V2026_07_28));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testExtractStructuredContentReturnsNullForObjectsSerializingToAScalar(): void
|
||||||
|
{
|
||||||
|
// SEP-2106 allows a scalar `structuredContent`, but `CallToolResult` types
|
||||||
|
// the field as `?array` and cannot carry one — so it is dropped in every
|
||||||
|
// revision until that type widens.
|
||||||
|
$tool = $this->createValidTool('count_things', null);
|
||||||
|
$toolReturnValue = new class implements \JsonSerializable {
|
||||||
|
public function jsonSerialize(): int
|
||||||
|
{
|
||||||
|
return 42;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
$this->registry->registerTool($tool, static fn () => $toolReturnValue);
|
||||||
|
|
||||||
|
$toolRef = $this->registry->getTool('count_things');
|
||||||
|
$this->assertNull($toolRef->extractStructuredContent($toolReturnValue, ProtocolVersion::V2025_11_25));
|
||||||
|
$this->assertNull($toolRef->extractStructuredContent($toolReturnValue, ProtocolVersion::V2026_07_28));
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testExtractStructuredContentReturnsNullForArrayOfContentItems(): void
|
public function testExtractStructuredContentReturnsNullForArrayOfContentItems(): void
|
||||||
{
|
{
|
||||||
|
// Unlike the list rule, this one is revision-independent: the items are
|
||||||
|
// already carried in the result's `content`.
|
||||||
$tool = $this->createValidTool('lookup_thing', null);
|
$tool = $this->createValidTool('lookup_thing', null);
|
||||||
$toolReturnValue = [
|
$toolReturnValue = [
|
||||||
new TextContent('Found it.'),
|
new TextContent('Found it.'),
|
||||||
|
|
@ -549,7 +642,8 @@ class RegistryTest extends TestCase
|
||||||
$this->registry->registerTool($tool, static fn () => $toolReturnValue);
|
$this->registry->registerTool($tool, static fn () => $toolReturnValue);
|
||||||
|
|
||||||
$toolRef = $this->registry->getTool('lookup_thing');
|
$toolRef = $this->registry->getTool('lookup_thing');
|
||||||
$this->assertNull($toolRef->extractStructuredContent($toolReturnValue));
|
$this->assertNull($toolRef->extractStructuredContent($toolReturnValue, ProtocolVersion::V2025_11_25));
|
||||||
|
$this->assertNull($toolRef->extractStructuredContent($toolReturnValue, ProtocolVersion::V2026_07_28));
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testConfiguredLoaderIsNotRunUntilFirstRead(): void
|
public function testConfiguredLoaderIsNotRunUntilFirstRead(): void
|
||||||
|
|
|
||||||
|
|
@ -227,5 +227,25 @@ final class ResourceLinkTest extends TestCase
|
||||||
['type' => 'resource_link', 'uri' => self::VALID_URI, 'name' => 'main.rs', '_meta' => 'not-an-array'],
|
['type' => 'resource_link', 'uri' => self::VALID_URI, 'name' => 'main.rs', '_meta' => 'not-an-array'],
|
||||||
'Invalid "_meta" in ResourceLink data.',
|
'Invalid "_meta" in ResourceLink data.',
|
||||||
];
|
];
|
||||||
|
yield 'invalid description' => [
|
||||||
|
['type' => 'resource_link', 'uri' => self::VALID_URI, 'name' => 'main.rs', 'description' => ['not-a-string']],
|
||||||
|
'Invalid "description" in ResourceLink data.',
|
||||||
|
];
|
||||||
|
yield 'invalid mimeType' => [
|
||||||
|
['type' => 'resource_link', 'uri' => self::VALID_URI, 'name' => 'main.rs', 'mimeType' => ['not-a-string']],
|
||||||
|
'Invalid "mimeType" in ResourceLink data.',
|
||||||
|
];
|
||||||
|
yield 'invalid size' => [
|
||||||
|
['type' => 'resource_link', 'uri' => self::VALID_URI, 'name' => 'main.rs', 'size' => 'not-an-int'],
|
||||||
|
'Invalid "size" in ResourceLink data; expected an integer.',
|
||||||
|
];
|
||||||
|
yield 'invalid annotations' => [
|
||||||
|
['type' => 'resource_link', 'uri' => self::VALID_URI, 'name' => 'main.rs', 'annotations' => 'not-an-array'],
|
||||||
|
'Invalid "annotations" in ResourceLink data; expected an array.',
|
||||||
|
];
|
||||||
|
yield 'invalid icons entry' => [
|
||||||
|
['type' => 'resource_link', 'uri' => self::VALID_URI, 'name' => 'main.rs', 'icons' => ['not-an-array']],
|
||||||
|
'Each entry in "icons" of ResourceLink data must be an array.',
|
||||||
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -131,4 +131,14 @@ class ProtocolVersionTest extends TestCase
|
||||||
{
|
{
|
||||||
$this->assertSame(ProtocolVersion::V2025_03_26, ProtocolVersion::DEFAULT_HEADER_VERSION);
|
$this->assertSame(ProtocolVersion::V2025_03_26, ProtocolVersion::DEFAULT_HEADER_VERSION);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[TestDox('SEP-2106 lifts the object-only rule for structuredContent')]
|
||||||
|
public function testRequiresObjectStructuredContent(): void
|
||||||
|
{
|
||||||
|
foreach (ProtocolVersion::handshakeVersions() as $version) {
|
||||||
|
$this->assertTrue($version->requiresObjectStructuredContent(), \sprintf('%s predates SEP-2106.', $version->value));
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->assertFalse(ProtocolVersion::V2026_07_28->requiresObjectStructuredContent());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -477,6 +477,143 @@ class CallToolHandlerTest extends TestCase
|
||||||
$this->assertArrayNotHasKey('structuredContent', $response->result->jsonSerialize());
|
$this->assertArrayNotHasKey('structuredContent', $response->result->jsonSerialize());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @dataProvider provideStructuredContentRevisions
|
||||||
|
*/
|
||||||
|
public function testStructuredContentFollowsTheNegotiatedRevision(?string $negotiated, ?array $expected): void
|
||||||
|
{
|
||||||
|
$listResult = [['id' => 1], ['id' => 2]];
|
||||||
|
$request = $this->createCallToolRequest('list_things', []);
|
||||||
|
$toolReference = $this->createToolReference('list_things', static fn () => $listResult);
|
||||||
|
|
||||||
|
$this->session
|
||||||
|
->method('get')
|
||||||
|
->with('protocol_version')
|
||||||
|
->willReturn($negotiated);
|
||||||
|
|
||||||
|
$this->registry
|
||||||
|
->method('getTool')
|
||||||
|
->willReturn($toolReference);
|
||||||
|
|
||||||
|
$this->referenceHandler
|
||||||
|
->method('handle')
|
||||||
|
->willReturn($listResult);
|
||||||
|
|
||||||
|
$toolReference
|
||||||
|
->method('formatResult')
|
||||||
|
->willReturn([new TextContent('[{"id":1},{"id":2}]')]);
|
||||||
|
|
||||||
|
$response = $this->handler->handle($request, $this->session);
|
||||||
|
|
||||||
|
$this->assertInstanceOf(Response::class, $response);
|
||||||
|
$this->assertSame($expected, $response->result->structuredContent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How a revision is resolved is {@see \Mcp\Server\RequestContext}'s business
|
||||||
|
* and covered there; this only pins that the handler applies it.
|
||||||
|
*
|
||||||
|
* @return iterable<string, array{?string, ?array<mixed>}>
|
||||||
|
*/
|
||||||
|
public static function provideStructuredContentRevisions(): iterable
|
||||||
|
{
|
||||||
|
// A list is only emittable from 2026-07-28 (SEP-2106) on. Without a
|
||||||
|
// negotiated revision the handler assumes the stricter handshake rule.
|
||||||
|
yield 'no negotiated revision' => [null, null];
|
||||||
|
yield '2025-11-25' => ['2025-11-25', null];
|
||||||
|
yield '2026-07-28' => ['2026-07-28', [['id' => 1], ['id' => 2]]];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @dataProvider provideSelfBuiltResults
|
||||||
|
*/
|
||||||
|
public function testSelfBuiltResultIsSentUnchangedAndOnlyWarnedAbout(
|
||||||
|
?string $negotiated,
|
||||||
|
?array $structuredContent,
|
||||||
|
int $expectedWarnings,
|
||||||
|
): void {
|
||||||
|
$request = $this->createCallToolRequest('build_result', []);
|
||||||
|
$toolReference = $this->createToolReference('build_result', static fn () => null);
|
||||||
|
$callToolResult = new CallToolResult([new TextContent('Built by hand')], false, $structuredContent);
|
||||||
|
|
||||||
|
$this->session
|
||||||
|
->method('get')
|
||||||
|
->with('protocol_version')
|
||||||
|
->willReturn($negotiated);
|
||||||
|
|
||||||
|
$this->registry
|
||||||
|
->method('getTool')
|
||||||
|
->willReturn($toolReference);
|
||||||
|
|
||||||
|
$this->referenceHandler
|
||||||
|
->method('handle')
|
||||||
|
->willReturn($callToolResult);
|
||||||
|
|
||||||
|
$toolReference
|
||||||
|
->expects($this->never())
|
||||||
|
->method('formatResult');
|
||||||
|
|
||||||
|
$this->logger
|
||||||
|
->expects($this->exactly($expectedWarnings))
|
||||||
|
->method('warning');
|
||||||
|
|
||||||
|
$response = $this->handler->handle($request, $this->session);
|
||||||
|
|
||||||
|
// Warned about, never rewritten: building the result is an explicit opt-out.
|
||||||
|
$this->assertInstanceOf(Response::class, $response);
|
||||||
|
$this->assertSame($callToolResult, $response->result);
|
||||||
|
$this->assertSame($structuredContent, $response->result->structuredContent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return iterable<string, array{?string, ?array<mixed>, int}>
|
||||||
|
*/
|
||||||
|
public static function provideSelfBuiltResults(): iterable
|
||||||
|
{
|
||||||
|
yield 'list before SEP-2106' => ['2025-11-25', [['id' => 1]], 1];
|
||||||
|
yield 'list without a negotiated revision' => [null, [['id' => 1]], 1];
|
||||||
|
yield 'list from SEP-2106 on' => ['2026-07-28', [['id' => 1]], 0];
|
||||||
|
yield 'object before SEP-2106' => ['2025-11-25', ['items' => [['id' => 1]]], 0];
|
||||||
|
yield 'none at all' => ['2025-11-25', null, 0];
|
||||||
|
// Dropped by `CallToolResult::jsonSerialize()` anyway, so nothing to warn about.
|
||||||
|
yield 'empty' => ['2025-11-25', [], 0];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testDeclaredOutputSchemaWithoutStructuredContentIsLogged(): void
|
||||||
|
{
|
||||||
|
$listResult = [['id' => 1]];
|
||||||
|
$request = $this->createCallToolRequest('list_things', []);
|
||||||
|
$toolReference = $this->createToolReference('list_things', static fn () => $listResult, [
|
||||||
|
'type' => 'object',
|
||||||
|
'properties' => ['items' => ['type' => 'array']],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->registry
|
||||||
|
->method('getTool')
|
||||||
|
->willReturn($toolReference);
|
||||||
|
|
||||||
|
$this->referenceHandler
|
||||||
|
->method('handle')
|
||||||
|
->willReturn($listResult);
|
||||||
|
|
||||||
|
$toolReference
|
||||||
|
->method('formatResult')
|
||||||
|
->willReturn([new TextContent('[{"id":1}]')]);
|
||||||
|
|
||||||
|
$this->logger
|
||||||
|
->expects($this->once())
|
||||||
|
->method('warning')
|
||||||
|
->with(
|
||||||
|
$this->stringContains('outputSchema'),
|
||||||
|
$this->callback(static fn (array $context): bool => 'list_things' === $context['name'] && 'array' === $context['result_type']),
|
||||||
|
);
|
||||||
|
|
||||||
|
$response = $this->handler->handle($request, $this->session);
|
||||||
|
|
||||||
|
$this->assertInstanceOf(Response::class, $response);
|
||||||
|
$this->assertNull($response->result->structuredContent);
|
||||||
|
}
|
||||||
|
|
||||||
public function testValidationError(): void
|
public function testValidationError(): void
|
||||||
{
|
{
|
||||||
$schema = [
|
$schema = [
|
||||||
|
|
|
||||||
89
tests/Unit/Server/RequestContextTest.php
Normal file
89
tests/Unit/Server/RequestContextTest.php
Normal file
|
|
@ -0,0 +1,89 @@
|
||||||
|
<?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\Tests\Unit\Server;
|
||||||
|
|
||||||
|
use Mcp\Schema\Enum\ProtocolVersion;
|
||||||
|
use Mcp\Schema\Request\CallToolRequest;
|
||||||
|
use Mcp\Server\RequestContext;
|
||||||
|
use Mcp\Server\Session\SessionInterface;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
class RequestContextTest extends TestCase
|
||||||
|
{
|
||||||
|
public function testProtocolVersionComesFromTheSessionInTheHandshakeEra(): void
|
||||||
|
{
|
||||||
|
$context = new RequestContext(
|
||||||
|
$this->createSession('2025-06-18'),
|
||||||
|
$this->createRequest(),
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->assertSame(ProtocolVersion::V2025_06_18, $context->getProtocolVersion());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testPerRequestMetaTakesPrecedenceOverTheSession(): void
|
||||||
|
{
|
||||||
|
// Modern revisions have no `initialize`, so the revision travels with every
|
||||||
|
// single request instead of being negotiated once.
|
||||||
|
$context = new RequestContext(
|
||||||
|
$this->createSession('2025-11-25'),
|
||||||
|
$this->createRequest(['io.modelcontextprotocol/protocolVersion' => '2026-07-28']),
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->assertSame(ProtocolVersion::V2026_07_28, $context->getProtocolVersion());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @dataProvider provideUnusableVersions
|
||||||
|
*/
|
||||||
|
public function testUnusableVersionFallsBackToTheNewestHandshakeRevision(mixed $stored): void
|
||||||
|
{
|
||||||
|
$context = new RequestContext(
|
||||||
|
$this->createSession($stored),
|
||||||
|
$this->createRequest(),
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->assertSame(ProtocolVersion::latestHandshake(), $context->getProtocolVersion());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return iterable<string, array{mixed}>
|
||||||
|
*/
|
||||||
|
public static function provideUnusableVersions(): iterable
|
||||||
|
{
|
||||||
|
yield 'never negotiated' => [null];
|
||||||
|
yield 'unknown revision' => ['1999-01-01'];
|
||||||
|
yield 'not a string' => [20260728];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function createSession(mixed $protocolVersion): SessionInterface
|
||||||
|
{
|
||||||
|
$session = $this->createMock(SessionInterface::class);
|
||||||
|
$session->method('get')->with('protocol_version')->willReturn($protocolVersion);
|
||||||
|
|
||||||
|
return $session;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed>|null $meta
|
||||||
|
*/
|
||||||
|
private function createRequest(?array $meta = null): CallToolRequest
|
||||||
|
{
|
||||||
|
$request = CallToolRequest::fromArray([
|
||||||
|
'jsonrpc' => '2.0',
|
||||||
|
'method' => CallToolRequest::getMethod(),
|
||||||
|
'id' => 'test-request',
|
||||||
|
'params' => ['name' => 'test_tool', 'arguments' => []],
|
||||||
|
]);
|
||||||
|
|
||||||
|
return null === $meta ? $request : $request->withMeta($meta);
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue