fix: carry _meta through to every emitted DTO - #263
Conversation
|
The following accounts have interacted with this PR and/or linked issues. I will continue to update these lists as activity occurs. You can also manually ask me to refresh this list by adding the If you're merging code through a pull request on GitHub, copy and paste the following into the bottom of the merge commit message. To understand the WordPress project's expectations around crediting contributors, please review the Contributor Attribution page in the Core Handbook. |
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## trunk #263 +/- ##
============================================
+ Coverage 88.03% 88.17% +0.14%
- Complexity 1257 1259 +2
============================================
Files 54 54
Lines 4079 4120 +41
============================================
+ Hits 3591 3633 +42
+ Misses 488 487 -1
Flags with carried forward coverage won't be shown. Click here to find out more. ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
74527b8 to
fe3a4db
Compare
There was a problem hiding this comment.
Pull request overview
This PR ensures _meta is preserved and normalized across all MCP protocol DTOs the adapter emits (tools/resources/prompts and content blocks), so only JSON-object-shaped metadata reaches the client and malformed _meta is dropped (with warnings in handler-driven paths).
Changes:
- Introduces
McpValidator::normalize_meta()and routes DTO_metathrough it across domain factories andContentBlockHelper. - Preserves
_metaon resource contents, embedded resources, tool image blocks, and prompt message content blocks; logs when handler-provided_metais dropped. - Updates
resources/readcontent detection to treatblob-only items as resource contents, and adds/extends unit tests covering these behaviors.
Reviewed changes
Copilot reviewed 22 out of 22 changed files in this pull request and generated 1 comment.
Show a summary per file
| File | Description |
|---|---|
| tests/phpunit/Unit/Tools/McpToolTest.php | Adds coverage for list-shaped _meta being omitted for tools (ability + array config). |
| tests/phpunit/Unit/Resources/McpResourceTest.php | Adds coverage for list-shaped _meta being omitted for resources (ability + array config). |
| tests/phpunit/Unit/Prompts/McpPromptTest.php | Adds coverage for list-shaped _meta being omitted for prompts (ability + array config). |
| tests/phpunit/Unit/Prompts/McpPromptBuilderTest.php | Adds builder-level coverage ensuring list-shaped _meta is omitted. |
| tests/phpunit/Unit/Handlers/ToolsHandlerCallTest.php | Adds handler-level tests for embedded resource _meta placement + logging, and image _meta handling. |
| tests/phpunit/Unit/Handlers/ResourcesHandlerReadTest.php | Adds handler-level tests for _meta preservation/logging and blob-only contents detection. |
| tests/phpunit/Unit/Handlers/PromptsHandlerTest.php | Adds prompt message content-block normalization tests for _meta (including nested embedded resources) + logging. |
| tests/phpunit/Unit/Domain/Utils/McpValidatorTest.php | Adds direct unit tests for normalize_meta() acceptance/rejection cases. |
| tests/phpunit/Unit/Domain/Utils/ContentBlockHelperTest.php | Adds tests for _meta normalization and distinct block-vs-resource meta in embedded resources. |
| includes/Handlers/Tools/ToolsHandler.php | Preserves/normalizes _meta for embedded resource results (nested vs flat) and image results; logs drops. |
| includes/Handlers/Resources/ResourcesHandler.php | Treats blob as a resource-contents discriminator; preserves/normalizes _meta on emitted contents with logging. |
| includes/Handlers/Prompts/PromptsHandler.php | Normalizes prompt message content-block _meta (and nested resource contents _meta) before DTO creation with logging. |
| includes/Handlers/HandlerHelperTrait.php | Adds normalize_content_meta() helper that normalizes via normalize_meta() and logs dropped non-conformant values. |
| includes/Domain/Utils/McpValidator.php | Adds normalize_meta() implementing the “must serialize as JSON object” rule. |
| includes/Domain/Utils/ContentBlockHelper.php | Routes all _meta parameters through normalize_meta(); adds optional $resource_meta for embedded resources. |
| includes/Domain/Tools/RegisterAbilityAsMcpTool.php | Normalizes ability-provided tool _meta before emitting into protocol DTO. |
| includes/Domain/Tools/McpTool.php | Normalizes array-config tool meta before emitting _meta. |
| includes/Domain/Resources/RegisterAbilityAsMcpResource.php | Normalizes ability-provided resource _meta before emitting into protocol DTO. |
| includes/Domain/Resources/McpResource.php | Normalizes array-config resource meta before emitting _meta. |
| includes/Domain/Prompts/RegisterAbilityAsMcpPrompt.php | Normalizes ability-provided prompt _meta before emitting into protocol DTO. |
| includes/Domain/Prompts/McpPromptBuilder.php | Normalizes builder meta on build and clarifies behavior in docs/comments. |
| includes/Domain/Prompts/McpPrompt.php | Normalizes array-config prompt meta before emitting _meta. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
fe3a4db to
666dfdc
Compare
## What? See WordPress#245. Emit `mimeType` as declared. MCP places no format constraint on `mimeType` on any object that carries one, so presence and type are the only checks that apply. - remove `validate_mime_type()`, `validate_image_mime_type()`, `validate_audio_mime_type()`, `validate_icon_mime_type()` and the icon MIME allow-list, with their call sites - keep a `mimeType` when it is a non-empty string and emit the value as written - `image` and `audio` content blocks still require a `mimeType`, which the schema marks required there First of three stacked PRs splitting WordPress#260, which carries the same change as one branch. Review order is WordPress#262 → WordPress#263 → WordPress#264. To exercise all three together, test WordPress#260. ## Why? The adapter validated `mimeType` against an RFC 2045 pattern that rejects parameters, and icons against a fixed allow-list. Neither constraint comes from MCP, which types `mimeType` as a plain string everywhere it appears. The pattern drops any media type carrying a parameter. `text/html;profile=mcp-app` is the media type an MCP Apps UI template declares, so a UI resource never reached the `resources/list` descriptor with the type that identifies it. ## How? The four validators are removed rather than relaxed: with the format constraint gone, each reduces to `is_string()`, which the call sites already do. Icon `mimeType` keeps its type check and loses the allow-list. The spec states which types a *client* must support; it does not restrict what a server may declare, and an icon a given client cannot render is that client's decision to make. `McpResource` and `RegisterAbilityAsMcpResource` keep the `'' !== $mime_type` guard, so an empty or whitespace-only value is still omitted rather than emitted as an empty string. ### Behaviour changes - a `mimeType` carrying RFC 2045 parameters, such as `text/html;profile=mcp-app`, reaches the `resources/list` descriptor instead of being dropped - any `mimeType` string now survives to the wire as written, on every object that carries one - an icon declaring a MIME type outside the previous allow-list is kept rather than skipped - prompt message `image` and `audio` blocks no longer require the type to start with `image/` or `audio/`; a `mimeType` is still required - four `public static` methods are removed from `McpValidator` ### Use of AI Tools AI assistance: Yes Tool(s): Claude Code, Codex CLI Model(s): Claude Opus 5, Claude Fable 5, GPT-5.6 Sol Used for: Implementation and review. Every change was verified manually by me. ## Testing Instructions 1. Register a resource ability with `meta.mcp.mimeType` set to `text/html;profile=mcp-app` and confirm `resources/list` carries that exact string on the descriptor, unaltered. 2. Register a component with an icon whose `mimeType` is `image/avif` and confirm the icon is kept rather than skipped. 3. Return a prompt message `image` block with `mimeType` set to `application/octet-stream` and confirm the message renders rather than failing validation. Remove `mimeType` entirely and confirm it still fails. 4. Run `composer test`, `composer lint` and `composer phpstan`. ## Changelog Entry > Fixed - Emit `mimeType` as declared, so a media type carrying parameters such as `text/html;profile=mcp-app` reaches the client unaltered.
gziolo
left a comment
There was a problem hiding this comment.
Approving this PR. I used Claude Code and Codex to help review the changes and add initially failing unit tests that reproduce the edge cases identified during review. I then added fixes to make those tests pass, primarily to illustrate the reported findings and preserve them as regression coverage.
If any of these additions don’t align with the intended direction of the PR, please feel free to adjust or revert them.
MCP declares `_meta` an object. PHP represents a JSON object and a JSON array with one type, so an array check admits a list, and a list reaches the wire as a JSON array. A client validates `_meta` as part of the response that carries it, so a malformed value costs the whole payload rather than only itself. Add `McpValidator::normalize_meta()`, which yields a value only when it serializes as a JSON object: a non-array, an empty array or a list yields null. Route every `_meta` through it — the tool, resource and prompt descriptors, the content block helper, and the six handler sites. Copy `_meta` from a handler's content item onto resource contents, embedded resources, content blocks and prompt messages. `resources/read` now carries the `_meta` a handler attaches to its contents, which MCP Apps UI resources rely on for the CSP config and border hints they place under `_meta.ui` alongside the HTML body. Add a trailing, optional `resource_meta` argument to `ContentBlockHelper::embedded_text_resource()` and `embedded_blob_resource()`, which sets the nested resource contents' `_meta` while the existing argument sets the content block's. Existing calls are unaffected. A tool may write an embedded resource nested, under a `resource` key, or flat. The nested form addresses both levels. Strip `type` from the flat form and what remains is a `ResourceContents` literal, so its `_meta` describes the contents. Recognize a blob-only item as resource contents in `resources/read`: binary contents carry no text and take the resource's own URI when they name none, so `blob` alone identifies one. The handler sites route `_meta` through `HandlerHelperTrait::normalize_content_meta()`, which logs a warning naming the object the metadata sat on. A conforming client strips metadata it does not recognize, so this log is the only place the mistake surfaces. Closes #245.
Reject object-shaped _meta values that still cannot be JSON encoded, including serializers that throw. Only treat resources/read output as content DTOs when it is a sequential list and every member carries a resource content field. Delay tool-result metadata normalization until an embedded resource will actually be emitted, avoiding false drop warnings on generic fallbacks. Add regression coverage for each case.
Keep one shallow normalization rule at DTO construction boundaries: pass through non-empty associative arrays unchanged and omit absent, empty, non-array, or list-shaped values. This guarantees that `_meta` has its MCP JSON object shape without interpreting or rewriting arbitrary nested values. Preserve metadata only where the Adapter reconstructs protocol DTOs: tool, resource, and prompt definitions; prompt content blocks; resources/read text and blob contents; and tool image or embedded-resource shorthands. Keep outer content-block metadata separate from nested resource-contents metadata. Remove recursive freezing, JSON serialization preflights, drop logging, transport-level normalization, and whole-result hardening. php-mcp-schema 0.1.3 already accepts and emits `_meta`; those layers duplicated the schema package, changed caller data, and expanded the failure surface without being required for usable metadata propagation. Retain focused coverage for associative pass-through, list omission, text and blob resources, images, and outer versus nested metadata placement.
ca34aaa to
e6d2a16
Compare
|
I simplified this PR substantially in e6d2a16 while keeping the intended scope: usable Why the smaller implementation is sufficient
The implementation now uses one shallow rule through
This is intentionally shallow. MCP leaves the values under direct Behavior retained
Generic tool results remain generic tool data. Without an explicit content-block What was removed
Those layers duplicated behavior already owned by the schema DTOs or final JSON encoder, could change caller data, and made optional metadata capable of affecting unrelated payload handling. Validation
The resulting PR is 17 files with 643 additions and 55 deletions, reduced from the previous 24-file, 1,856-addition version while retaining the distinct end-to-end metadata mappings. |
What?
Closes #245.
Carry
_metathrough to the DTOs the adapter emits, and normalize it at every site that builds one, so only a value a client can accept reaches the wire._metafrom a handler's content item onto resource contents, embedded resources, content blocks and prompt messages_metathroughMcpValidator::normalize_meta(), which yields a value only when it serializes as a JSON objectresource_metaargument toContentBlockHelper::embedded_text_resource()andembedded_blob_resource(), which sets the nested resource contents'_metawhile the existing argument sets the content block'sresources/read_metathe handlers drop, naming the object it sat onSecond of three stacked PRs splitting #260, which carries the same change as one branch.
Based on #262, so review that one first; the diff shown here is against it. Followed by #264.
To exercise all three together, test #260.
Why?
_metais the spec's channel for metadata that travels with a resource but is not its body. The adapter copieduri,text/blobandmimeTypeout of a handler's content item and left_metabehind, so MCP App UI resources (ui://withtext/html;profile=mcp-app) reached the client with their HTML but none of the_meta.uiconfig the server attached.Fixing that alone would still emit metadata a client rejects. PHP represents a JSON object and a JSON array with one type, so an array check admits a list, and a list reaches the wire as a JSON array where MCP declares an object. A client validates
_metaas part of the response that carries it, so a malformed value costs the whole payload rather than only itself.How?
Shape rule.
McpValidator::normalize_meta()returns a value only when it serializes as a JSON object; a non-array, an empty array or a list yields null. It returns null rather than raising, because_metatravels alongside a payload and a malformed one is not a reason to withhold the payload itself.Embedded resources. A tool may write the resource nested, under a
resourcekey, or flat. The nested form addresses both_metalevels — outer for the block, inner for the contents. Striptypefrom the flat form and what remains is aResourceContentsliteral, so its_metadescribes the contents, which is what the same literal already means toResourcesHandler::create_content_dto(). A caller who needs block-level_metawrites the nested form, which exists to express that distinction.Resource contents detection.
convert_contents_to_dtos()distinguishes a single payload from a list of content items by looking in the first item foruri,textorblob. Binary contents carry no text and take the resource's own URI when they name none, soblobalone identifies one.Where the generic path stops. Only the two branches that read a
typekey —resource(inside the URI guard) andimage— treat a sibling_metaas the content block's. The generic path returns the result verbatim asstructuredContentand JSON-encodes it into a text block, so each key is already tool data; reading_metaoff it would give one key two meanings.Logging. The handler sites route
_metathroughHandlerHelperTrait::normalize_content_meta(), which logs a warning naming the object the metadata sat on. It takes the raw value, becausenormalize_meta()answers null both for an absent_metaand for an unemittable one. A conforming client strips metadata it does not recognize and reports nothing, so this log is the only place the mistake surfaces.ContentBlockHelperis static and the domain factories build from config, so neither holds an error handler, and adding one would change signatures public since 0.5.0 — those sites normalize silently.Documentation. The user-facing guide for the shapes introduced here — structured resource contents, binary resources, and the MCP Apps route — lands in #264, because the same sections describe annotation and degradation behaviour that only exists after that PR.
Behaviour changes
_metathat cannot serialize as a JSON object is omitted rather than emitted as a JSON array; this reachesContentBlockHelper, public API as of 0.5.0, though signatures are unchanged and only input that could never have serialized correctly is affectedresources/readhandler whose first item carries abloband nouriortextreturns oneBlobResourceContentsper item, rather than a single text block holding the list JSON-encoded_metais logged with the object it sat on and the tool, prompt or URI it came from; the emitted payload is unchanged by the loggingresource_metaargument is trailing and optional, so existing calls are unaffectedUse of AI Tools
AI assistance: Yes
Tool(s): Claude Code, Codex CLI
Model(s): Claude Opus 5, Claude Fable 5, GPT-5.6 Sol
Used for: Implementation and review. Every change was verified manually by me.
Testing Instructions
Resources
_meta:resources/readand confirmresult.contents[0]._metais present._metato a list ([ 'a', 'b' ]) and confirm the key is absent from the response rather than emitted as a JSON array, and that a warning naming the resource is logged.blobandmimeType, with nouriortext, and confirmresult.contentsholds oneBlobResourceContentsper item rather than a single text block with the list JSON-encoded.Tools
meta.mcp._metaset to an object and confirm it appears on that tool intools/list. Set it to a list and confirm the key is omitted rather than emitted as a JSON array._metalands on its own level — outer on the content block, inner on the resource contents:[ 'type' => 'resource', 'resource' => [ 'uri' => 'ui://x', 'text' => '...', '_meta' => [ 'contents' => true ] ], '_meta' => [ 'block' => true ], ]type,uri,mimeType,text,_meta) and confirm its_metalands on the resource contents, not the block.type: "image"result with a sibling_metaand confirm it reaches the image content block. The image bytes go inresultsas raw binary, which the handler base64-encodes.Prompts
_metaobject and confirm it survives toprompts/get. Change it to a list and confirm the key is omitted and a warning naming the prompt is logged.Gate
composer test,composer lintandcomposer phpstan.Changelog Entry