[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:
Christopher Hertel 2026-08-15 04:03:20 +02:00 • committed by GitHub
commit becce29c41
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 616 additions and 36 deletions

View file

@ -55,6 +55,39 @@ class PromptResultFormatterTest extends TestCase
$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([

View file

@ -24,6 +24,7 @@ use Mcp\Exception\ResourceNotFoundException;
use Mcp\Exception\ToolNotFoundException;
use Mcp\Schema\Content\ResourceLink;
use Mcp\Schema\Content\TextContent;
use Mcp\Schema\Enum\ProtocolVersion;
use Mcp\Schema\Prompt;
use Mcp\Schema\ResourceDefinition;
use Mcp\Schema\ResourceTemplate;
@ -496,50 +497,142 @@ class RegistryTest extends TestCase
$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 = [
'type' => 'array',
'items' => [
'type' => 'object',
'properties' => [
'foo' => [
'type' => 'string',
'description' => 'A static value',
],
],
'required' => ['foo'],
'type' => 'object',
'properties' => [
'foo' => ['type' => 'string'],
],
'required' => ['foo'],
];
$tool = $this->createValidTool('list_static_data', $outputSchema);
$toolReturnValue = [
['foo' => 'bar'],
['foo' => 'bar'],
['foo' => 'bar'],
['foo' => 'bar'],
];
$this->registry->registerTool($tool, static fn () => $toolReturnValue);
// Act
$toolRef = $this->registry->getTool('list_static_data');
$structuredContent = $toolRef->extractStructuredContent($toolReturnValue);
$this->assertNull($toolRef->extractStructuredContent($toolReturnValue, $version));
}
// Assert
$this->assertNotNull($structuredContent);
$this->assertCount(4, $structuredContent);
$this->assertEquals([
/**
* The revision is optional, and omitting it has to keep the strict rule: it is
* what every revision reachable through the `initialize` handshake requires.
*
* @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' => 'bar'],
], $structuredContent);
['foo' => 'baz'],
];
$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
{
// 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);
$toolReturnValue = [
new TextContent('Found it.'),
@ -549,7 +642,8 @@ class RegistryTest extends TestCase
$this->registry->registerTool($tool, static fn () => $toolReturnValue);
$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

View file

@ -227,5 +227,25 @@ final class ResourceLinkTest extends TestCase
['type' => 'resource_link', 'uri' => self::VALID_URI, 'name' => 'main.rs', '_meta' => 'not-an-array'],
'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.',
];
}
}

View file

@ -131,4 +131,14 @@ class ProtocolVersionTest extends TestCase
{
$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());
}
}

View file

@ -477,6 +477,143 @@ class CallToolHandlerTest extends TestCase
$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
{
$schema = [

View 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);
}
}