Skip to content

[Capability] Harden structuredContent extraction - #417

Merged
chr-hertel merged 7 commits into
modelcontextprotocol:mainfrom
chr-hertel:feat/resource-link-content-review
Aug 15, 2026
Merged

[Capability] Harden structuredContent extraction#417
chr-hertel merged 7 commits into
modelcontextprotocol:mainfrom
chr-hertel:feat/resource-link-content-review

Conversation

@chr-hertel

@chr-hertel chr-hertel commented Aug 14, 2026

Copy link
Copy Markdown
Member

Follow-up to #400.

  • structuredContent follows the negotiated protocol revision. Up to 2025-11-25 it must be a JSON object, so extractStructuredContent() drops a PHP list or an object serializing to a JSON array — strict clients reject the whole tool call over one. SEP-2106, part of 2026-07-28, widened it to any JSON value; from there both are emitted as-is. Objects serializing to a scalar and arrays holding Content are never emitted, in any revision: CallToolResult::$structuredContent is typed ?array, and content blocks are already carried in content.
  • RequestContext::getProtocolVersion() resolves the revision from the request's _meta (modern era) or the session (handshake era), falling back to the newest handshake revision. First consumer of the protocol_version InitializeHandler has been writing all along, and reachable from tool handlers via argument injection.
  • Warnings instead of silence when a tool declares an outputSchema but returns something unsendable, or hands back a self-built CallToolResult whose structuredContent the revision does not allow — that one is passed through unchanged, since building the result is an explicit opt-out.
  • ResourceLink::fromArray() validated optional fields inconsistently: description/mimeType reached the constructor as a TypeError, size was silently (int)-cast, annotations lacked the is_array() guard icons had. Now mirrors ResourceDefinition::fromArray().
  • Test that a typed resource_link block keeps its optional fields through PromptResultFormatter.
  • Document structured output, previously undocumented.

Drops testExtractStructuredContentReturnsArrayDirectlyForArrayOutputSchema, which asserted a list is emitted unconditionally; replaced by revision-specific cases.

BC: tools returning a list no longer advertise structuredContent to clients below 2026-07-28; the value stays in content.

Left for the 2026-07-28 work: Tool::fromArray() still rejects every non-object outputSchema, so Client::listTools() cannot parse a 2026-07-28 tool listing; CallToolResult::$structuredContent cannot carry a scalar; and the truthiness check in its jsonSerialize() drops a legal 0/false/"" once that type widens.

@cancan101

Copy link
Copy Markdown
Contributor

Did you want me to do a merge /rebase to resolve conflicts?

@chr-hertel

Copy link
Copy Markdown
Member Author

@cancan101 no, all good - just a follow up. your commits will be dropped after merging #400 in a sec

@chr-hertel
chr-hertel force-pushed the feat/resource-link-content-review branch from 64b3a3d to fa158fc Compare August 14, 2026 21:25
@chr-hertel chr-hertel changed the title [Schema] Add resource_link content block [Capability] Harden structuredContent extraction, tighten ResourceLink validation Aug 14, 2026
@chr-hertel chr-hertel changed the title [Capability] Harden structuredContent extraction, tighten ResourceLink validation [Capability] Harden structuredContent extraction Aug 14, 2026
@chr-hertel chr-hertel modified the milestones: 0.8.0, 0.9.0 Aug 14, 2026
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.
`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.
`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.
@chr-hertel
chr-hertel force-pushed the feat/resource-link-content-review branch from 7513d9f to 254c1cf Compare August 15, 2026 00:50
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.
Keeps the `_meta`-then-session lookup in one place instead of per handler,
and exposes the negotiated revision to tool handlers.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Hardens structured tool output by applying protocol-aware extraction and improves ResourceLink validation and documentation.

Changes:

  • Gates structured content shapes by protocol revision.
  • Adds protocol resolution through RequestContext.
  • Strengthens ResourceLink validation and test coverage.

Reviewed changes

Copilot reviewed 12 out of 12 changed files in this pull request and generated 2 comments.

Show a summary per file
File Description
src/Capability/Registry/ToolReference.php Adds revision-aware structured-content extraction.
src/Server/RequestContext.php Resolves the request protocol revision.
src/Server/Handler/Request/CallToolHandler.php Applies revision-aware extraction and warnings.
src/Schema/Content/ResourceLink.php Validates optional resource-link fields.
tests/Unit/Capability/RegistryTest.php Tests structured-content extraction rules.
tests/Unit/Server/RequestContextTest.php Tests protocol-version resolution.
tests/Unit/Server/Handler/Request/CallToolHandlerTest.php Tests handler integration and warnings.
tests/Unit/Schema/Content/ResourceLinkTest.php Tests invalid optional fields.
tests/Unit/Capability/Formatter/PromptResultFormatterTest.php Tests preservation of resource-link fields.
docs/server-client-communication.md Documents protocol access.
docs/mcp-elements.md Documents structured output behavior.
CHANGELOG.md Records the compatibility change.
Suppressed comments (2)

src/Capability/Registry/ToolReference.php:80

  • The protocol revision only widens which root shapes are legal; it does not override a declared outputSchema. With a 2026 request, a tool whose schema is ['type' => 'object'] can return a list and this method emits it, guaranteeing a schema mismatch that strict clients may reject. The extracted value needs output-schema validation (or at minimum root-type enforcement) before it is returned.
        $objectOnly = !($protocolVersion ?? ProtocolVersion::latestHandshake())->isAtLeast(ProtocolVersion::V2026_07_28);

src/Capability/Registry/ToolReference.php:87

  • The new 2026 path accepts an empty list, but it still never reaches the wire: CallToolResult::jsonSerialize() uses a truthiness check at line 147, so structuredContent: [] is omitted. Use a null check in that serializer and cover an empty-list result.
            if ($objectOnly && array_is_list($toolExecutionResult)) {
                return null;

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread src/Server/Handler/Request/CallToolHandler.php Outdated
Comment thread src/Capability/Registry/ToolReference.php
…ntent

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.
@chr-hertel
chr-hertel merged commit becce29 into modelcontextprotocol:main Aug 15, 2026
23 checks passed
@chr-hertel
chr-hertel deleted the feat/resource-link-content-review branch August 15, 2026 02:03
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants