Normalize _meta and annotations on every emitted DTO, and emit mimeType as declared - #260
Normalize _meta and annotations on every emitted DTO, and emit mimeType as declared#260galatanovidiu wants to merge 22 commits into
_meta and annotations on every emitted DTO, and emit mimeType as declared#260Conversation
`resources/read` and the `tools/call` embedded-resource path build their DTOs from the item a handler returns. Both now copy `_meta` from that item onto the DTO, and the tools path also copies `annotations` onto the content block. `ContentBlockHelper::embedded_text_resource()` and `embedded_blob_resource()` take a trailing `resource_meta` argument. The DTO tree has two levels that each carry `_meta`, the content block and the resource contents nested inside it, and the existing `_meta` argument sets the block's. A tool may write the embedded resource nested, with the resource fields under a `resource` key, or flat, with them inlined. The nested form maps onto both levels, so its outer keys belong to the block and its inner `_meta` to the contents. The flat form is the block itself and has a single level for `_meta` to mean, so it belongs to the block; reading it into the contents as well would put the same metadata on both levels of one response. `McpValidator::normalize_meta()` returns a `_meta` value only when it serializes as a JSON object, which is what MCP declares the field to be. PHP represents a JSON object and a JSON array with one type, so an empty or sequential array reaches the wire as `[]`. Such values are treated as absent, as are non-arrays and malformed `annotations`, so that metadata never costs a client the payload it accompanies. `array_is_list()` requires PHP 8.1 and this package supports 7.4, hence the key comparison. Fixes #245.
Every `_meta` the adapter writes into a protocol DTO passes through `McpValidator::normalize_meta()`, which yields a value only when it serializes as a JSON object. PHP represents a JSON object and a JSON array with one type, so a plain array check admits a list, and a list reaches the wire as a JSON array. The seven descriptor sites this covers build the Tool, Resource and Prompt DTOs from an ability's `mcp._meta`, from a `fromArray()` config, and from the prompt builder's own `_meta`. `ContentBlockHelper` normalizes each `_meta` argument it takes, both on the content block and on the resource contents nested inside an embedded resource. Its method signatures are unchanged. MCP declares `_meta` an object, and a client validates it as part of the response that carries it, so a non-object there costs the whole payload rather than only the metadata it accompanies.
|
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 #260 +/- ##
============================================
+ Coverage 88.02% 88.69% +0.66%
- Complexity 1270 1286 +16
============================================
Files 54 54
Lines 4117 4236 +119
============================================
+ Hits 3624 3757 +133
+ Misses 493 479 -14
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:
|
There was a problem hiding this comment.
Pull request overview
This PR fixes and hardens _meta handling across the adapter’s protocol DTO emission paths, ensuring _meta is preserved where MCP allows it (notably resource contents and embedded resources) while preventing invalid (non-object) _meta shapes from reaching the wire and potentially invalidating whole responses.
Changes:
- Add
McpValidator::normalize_meta()and apply it consistently when emitting_metafor tools/resources/prompts and for content blocks (including embedded resources). - Preserve
_metaonresources/readcontents, and correctly map_metafor embedded resources across the two DTO levels (block vs nested resource) while also parsing/dropping malformedannotations. - Add extensive unit test coverage for
_metanormalization and embedded resource shape handling, plus documentation for structured resource returns.
Reviewed changes
Copilot reviewed 20 out of 20 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 ensuring list-shaped tool _meta is omitted and sparse numeric keys are retained. |
| tests/phpunit/Unit/Resources/McpResourceTest.php | Adds coverage ensuring list-shaped resource _meta is omitted for both ability- and array-based construction. |
| tests/phpunit/Unit/Prompts/McpPromptTest.php | Adds coverage ensuring list-shaped prompt _meta is omitted for both ability- and array-based construction. |
| tests/phpunit/Unit/Prompts/McpPromptBuilderTest.php | Adds builder-based coverage ensuring list-shaped _meta is omitted from prompt DTOs. |
| tests/phpunit/Unit/Handlers/ToolsHandlerCallTest.php | Adds end-to-end tests for embedded resource nested/flat shapes, _meta level mapping, and malformed annotations behavior. |
| tests/phpunit/Unit/Handlers/ResourcesHandlerReadTest.php | Adds coverage ensuring resources/read carries through valid _meta and drops invalid/list meta without failing content delivery. |
| tests/phpunit/Unit/Domain/Utils/McpValidatorTest.php | Adds targeted tests for _meta normalization rules (associative OK, list/empty/non-array rejected, sparse numeric OK). |
| tests/phpunit/Unit/Domain/Utils/ContentBlockHelperTest.php | Adds coverage for _meta normalization on content blocks and distinct block vs resource _meta for embedded resources. |
| includes/Handlers/Tools/ToolsHandler.php | Implements embedded-resource _meta level mapping and adds tolerant annotations parsing/logging. |
| includes/Handlers/Resources/ResourcesHandler.php | Ensures resource content DTOs carry normalized _meta through to the client. |
| includes/Domain/Utils/McpValidator.php | Introduces normalize_meta() to ensure only JSON-object-shaped meta is emitted. |
| includes/Domain/Utils/ContentBlockHelper.php | Normalizes _meta for content blocks and adds a separate $resource_meta for embedded resource contents. |
| includes/Domain/Tools/RegisterAbilityAsMcpTool.php | Normalizes tool _meta sourced from ability metadata. |
| includes/Domain/Tools/McpTool.php | Normalizes tool _meta sourced from fromArray() configuration. |
| includes/Domain/Resources/RegisterAbilityAsMcpResource.php | Normalizes resource _meta sourced from ability metadata. |
| includes/Domain/Resources/McpResource.php | Normalizes resource _meta sourced from fromArray() configuration. |
| includes/Domain/Prompts/RegisterAbilityAsMcpPrompt.php | Normalizes prompt _meta sourced from ability metadata. |
| includes/Domain/Prompts/McpPromptBuilder.php | Normalizes prompt builder _meta before emitting the protocol DTO. |
| includes/Domain/Prompts/McpPrompt.php | Normalizes prompt _meta sourced from fromArray() configuration. |
| docs/guides/creating-abilities.md | Documents structured resource content returns, embedded resource shapes, and _meta constraints. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Tool results carry content annotations (audience, priority, lastModified), not the ToolAnnotations vocabulary used on tool descriptors. Annotations are validated as part of the content block that carries them, so a value the schema rejects costs the whole block rather than only itself. build_annotations() runs McpValidator::get_annotation_validation_errors() before constructing the DTO and drops the annotations when it reports errors. It also omits the field when the resulting DTO holds no values, since an annotations object with nothing in it serializes to a JSON array where MCP declares an object. Both cases are logged, so the rest of the result still reaches the client. The call sits inside the URI guard: without a URI the result is not an embedded resource and falls through to the generic JSON path, where no annotations are attached.
The schema DTOs assert strict PHP types: `Annotations::priority` requires a float, `Resource::size` an int, and each `ToolAnnotations` hint a bool. Values reaching the adapter's factories carry whatever type the caller holds, and WordPress returns stored scalars as strings. `McpTool::fromArray()`, `McpResource::fromArray()` and `ToolsHandler::build_annotations()` route `annotations` through `McpAnnotationMapper::map()`, which keeps only the fields the target type models and coerces each value to that field's declared type. `McpResource` casts `size` at its guard and validates the mapped annotations against the ranges MCP defines, dropping them when a value falls outside. Coercion covers values whose intent is unambiguous: a number written as a string, a boolean written as "1", "true", "0" or "false". Any other value for those fields is dropped, and a field the DTO cannot accept at all returns a WP_Error naming it. Mapping filters the vocabulary that yields an empty DTO, and mapping together with validation leaves no value for `Annotations::fromArray()` to reject, so `build_annotations()` returns the DTO directly and relies on the tool-call boundary for exception handling.
Prompt messages carry the same content blocks tool results do and reach the wire through the same DTOs, so they take the same normalization. `normalize_content_block()` runs `_meta` through `McpValidator::normalize_meta()`, routes `annotations` through `McpAnnotationMapper::map()` and `McpValidator::get_annotation_validation_errors()`, and casts a `resource_link` `size` to the int the schema declares. It covers the message tiers and the text shorthand, which builds its own block from the caller's `annotations`. `EmbeddedResource::fromArray()` takes its resource contents as given, so no DTO inspects the `_meta` nested inside an embedded resource. The handler normalizes that level too. `get_prompt()` wraps normalization in a `\Throwable` catch that returns an internal error, and the schema DTOs throw on a value they cannot accept, so a `_meta` that is not an array, a priority written as "0.5" or a size written as "1024" is resolved or dropped here rather than costing the message. Annotation mapping and validation live in `HandlerHelperTrait::build_content_annotations()`, shared with the tool-result path. The log message and context are arguments, so each handler keeps its own wording.
A tool may write an embedded resource nested, with the resource fields under a `resource` key, or flat, with them inlined. Strip `type` from the flat form and what remains, `uri`, `mimeType`, one of `text` or `blob`, and `_meta`, is a `ResourceContents` literal, and `_meta` is declared on that type alongside its siblings. The flat form's `_meta` therefore describes the resource contents, which is what those same keys mean to `ResourcesHandler::create_content_dto()`. The nested form addresses both levels: its outer `_meta` belongs to the content block and its inner one to the contents. `annotations` describes the content block in either form, because resource contents declare no `annotations` field. A named `$is_nested` flag carries the shape, and both `_meta` assignments read from it.
Guard the PromptMessage construction in validate_and_create_message() so a content block whose type is valid but whose payload the schema DTOs reject becomes a text block carrying its JSON, logged as a warning. Construction is the point that sees the DTO's own verdict, so the guard covers every field the DTOs assert on without restating their rules, and the other messages in the prompt are unaffected. This is the substitution the handler already applies to an unrecognized content type and to an invalid role. Validate embedded resource contents in normalize_content_block() through is_valid_resource_contents(): a non-empty string uri, plus text or blob. EmbeddedResource takes its contents as given, so this is the only level that inspects them, and contents outside that shape are rejected by a conforming client together with the message that carries them. Extract degrade_content_to_text() so every substitution site emits the same shape, and guard the missing-type fallback with is_scalar before casting text so a non-scalar value routes to the JSON representation instead of a string cast.
A block with no type falls back to its own `text` when that value is scalar, and to the JSON representation otherwise. Pin the scalar side so both branches of the fallback are exercised.
A tool carries annotations at two levels. The ability's `meta.annotations` describes the tool itself and takes the ToolAnnotations hints. A content block the tool returns is a different object, and the schema models `audience`, `priority` and `lastModified` on a content block, so that is the vocabulary a result takes. The annotation reference gains a "Tool Result Annotations" section carrying that rule with an example, noting that a tool hint written on a result is dropped and that values outside what MCP allows drop the annotations as a group and log. The usage summary names both levels for Tools, matching the Prompts entry.
A `type` key marks a tool result as a description of a content block rather than as tool data, so its sibling `annotations` and `_meta` belong to that block. The image branch reads both and passes them to `ContentBlockHelper::image()`, which already accepts them. Annotations go through `build_content_annotations()`, so the image branch applies the mapping, range validation and drop-and-log the resource branch applies, under the same log message. `_meta` goes through `McpValidator::normalize_meta()`, which yields a value only when it serializes as a JSON object and accepts any type, so a malformed one costs the client its metadata and not the image. The generic fallback keeps reading neither. It has no `type` marker, and the result it holds is JSON-encoded into the text block and returned verbatim as `structuredContent`, so an `annotations` key there is already part of the payload and reading it as a block annotation would leave one key with two meanings. A comment at the call site records that.
MCP declares `_meta` an object, and `build()` emits the builder's only when it is a non-empty associative array, so a list or an empty array is omitted rather than sent as a JSON array. The property and setter docblocks say so and point at `build()`, where the rule is applied. Key names are still kept as written, which is the part of the earlier wording that holds.
`convert_contents_to_dtos()` decides whether an ability returned a list of content items by looking at the first item for a field such an item is built from. `blob` is one of those fields: `create_content_dto()` branches on it before anything else, and the URI falls back to the resource's own, so a blob and a MIME type are all a caller has to write for binary contents. Without it a blob-only first item sends the whole list down the fallback path, where it is JSON-encoded into one text item. Only the first item is inspected, so that costs every sibling in the list as well.
`HandlerHelperTrait::normalize_content_meta()` normalizes a `_meta` value and logs a warning when one was written but could not be emitted. MCP declares `_meta` an object, and a conforming client strips metadata it does not recognize, so a value dropped for shape is reported nowhere else and the author has nothing to work from. The helper takes the raw value rather than a pre-normalized one, which is what separates the two cases `McpValidator::normalize_meta()` answers null for: an absent key arrives as null and stays quiet, while anything else that normalizes away is a value someone wrote. No guard is needed at the call sites. The six handler call sites use it, each naming the object the metadata was written on, so a tool result's content block and the resource contents nested inside it report separately. Dropping is logged rather than raised, matching the annotations path in the same trait: `_meta` travels alongside a payload, and withholding the payload over its metadata is the worse outcome.
`_meta` is a request to the client rather than a control the adapter applies. The adapter checks only that the value serializes as a JSON object and forwards it as written, interpreting none of the keys, so a CSP declared there is enforced by whatever renders the resource.
Every `_meta` argument on ContentBlockHelper names the content block it sets, matching the two embedded-resource helpers that already distinguished the block from the resource contents nested inside it. Those two also record the version that added `$resource_meta`. ResourcesHandler::create_content_dto() gains the shape of the item it reads. Every key is optional and loosely typed because the method casts `blob` and `text`, keeps `mimeType` only when it already is a string, and falls back to the resource's own URI, so it requires no type from its caller.
Data providers in this suite are named `data_*`.
_meta through resource contents, embedded resources and component descriptors_meta and annotations on every DTO the adapter emits
MCP places no format constraint on `mimeType` on any object that carries one, so presence and type are the only checks the adapter applies. Remove `validate_mime_type()`, `validate_image_mime_type()`, `validate_audio_mime_type()`, `validate_icon_mime_type()` and the icon MIME allow-list, together with their call sites in the resource registrar, the resource and prompt validators, and icon validation. `image` and `audio` content blocks still require a `mimeType`, which the schema marks required there.
Tools read annotations from `meta.annotations`, resources from `meta.mcp.annotations`, and prompts carry none on the descriptor: the MCP Prompt object has no annotations field, so only message content blocks take them. Tool annotations are written with the Abilities API names `readonly`, `destructive` and `idempotent`, which the adapter emits as `readOnlyHint`, `destructiveHint` and `idempotentHint`. WordPress core defines these names on every ability and reads them back in its REST layer, so an ability that uses them stays consistent across Abilities API consumers. `openWorldHint` and `title` have no Abilities API equivalent and keep their MCP names. When both spellings are present, the Abilities API name takes precedence. Resource examples carry `uri`, `mimeType` and `annotations` under `meta.mcp`, and the prompt example carries `arguments` there.
An MCP App is predeclared: the UI resource is registered so it appears in `resources/list` under its `ui://` URI, and the tool descriptor binds it with `mcp._meta.ui.resourceUri`. The host fetches the template over `resources/read` and renders it in a sandboxed frame, so the tool result carries data only. Embedding the resource in the tool result instead yields plain text.
A tool result marked `type: "image"` carries its raw bytes under `results`, which the handler base64-encodes into the `data` field MCP expects. Without that key there is nothing to encode, so the result falls through to the generic path and reaches the client as a JSON text block. Log a warning naming the tool at that point, since the `type` marker states an intent the payload does not carry out. Document the image return shape alongside the other content-block forms.
_meta and annotations on every DTO the adapter emits_meta and annotations on every emitted DTO, and emit mimeType as declared
Add a two-step recipe for a tool that renders a UI: register the HTML template as a resource under a `ui://` URI carrying the `text/html;profile=mcp-app` MIME type, then bind a tool to it through `meta.mcp._meta.ui.resourceUri`, which reaches the tool descriptor unaltered. The host fetches and renders the template itself and the tool returns data alone, so the two are registered separately. Tabulate the `_meta.ui` keys a template declares — `csp`, `permissions`, `domain` and `prefersBorder` — and the `visibility` values deciding which of the model and the app see a tool. Add a resource returning binary contents, where `blob` takes base64 the ability encodes while a tool's image result takes raw bytes in `results`. Add a prompt returning the four non-text content block types with the fields each requires: `uri` plus `text` or `blob` for an embedded resource, `uri` and `name` for a resource link, `data` and `mimeType` for an image. Link the MCP Apps section from the documentation index.
## 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.
## What? Closes #245. Carry `_meta` through 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. - copy `_meta` from a handler's content item onto resource contents, embedded resources, content blocks and prompt messages - route every `_meta` through `McpValidator::normalize_meta()`, which yields a value only when it serializes as a JSON object - 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 - recognize a blob-only item as resource contents in `resources/read` - log a `_meta` the handlers drop, naming the object it sat on Second 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? `_meta` is the spec's channel for metadata that travels with a resource but is not its body. The adapter copied `uri`, `text`/`blob` and `mimeType` out of a handler's content item and left `_meta` behind, so MCP App UI resources (`ui://` with `text/html;profile=mcp-app`) reached the client with their HTML but none of the `_meta.ui` config 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 `_meta` as 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 `_meta` travels 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 `resource` key, or flat. The nested form addresses both `_meta` levels — outer for the block, inner for the contents. Strip `type` from the flat form and what remains is a `ResourceContents` literal, so its `_meta` describes the contents, which is what the same literal already means to `ResourcesHandler::create_content_dto()`. A caller who needs block-level `_meta` writes 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 for `uri`, `text` or `blob`. Binary contents carry no text and take the resource's own URI when they name none, so `blob` alone identifies one. **Where the generic path stops.** Only the two branches that read a `type` key — `resource` (inside the URI guard) and `image` — treat a sibling `_meta` as the content block's. The generic path returns the result verbatim as `structuredContent` and JSON-encodes it into a text block, so each key is already tool data; reading `_meta` off it would give one key two meanings. **Logging.** The handler sites route `_meta` through `HandlerHelperTrait::normalize_content_meta()`, which logs a warning naming the object the metadata sat on. It takes the raw value, because `normalize_meta()` answers null both for an absent `_meta` and 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. `ContentBlockHelper` is 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 - a `_meta` that cannot serialize as a JSON object is omitted rather than emitted as a JSON array; this reaches `ContentBlockHelper`, public API as of 0.5.0, though signatures are unchanged and only input that could never have serialized correctly is affected - a `resources/read` handler whose first item carries a `blob` and no `uri` or `text` returns one `BlobResourceContents` per item, rather than a single text block holding the list JSON-encoded - dropped `_meta` is logged with the object it sat on and the tool, prompt or URI it came from; the emitted payload is unchanged by the logging - the new `resource_meta` argument is trailing and optional, so existing calls are unaffected ### 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 ### Resources 1. Register a resource whose handler returns a content item with `_meta`: ```php 'handler' => fn () => [[ 'uri' => 'ui://example/app', 'mimeType' => 'text/html;profile=mcp-app', 'text' => '<!doctype html>...', '_meta' => [ 'ui' => [ 'prefersBorder' => true ] ], ]], ``` Call `resources/read` and confirm `result.contents[0]._meta` is present. 2. Change that `_meta` to 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. 3. Return a handler payload whose first item carries only `blob` and `mimeType`, with no `uri` or `text`, and confirm `result.contents` holds one `BlobResourceContents` per item rather than a single text block with the list JSON-encoded. ### Tools 4. Register an ability with `meta.mcp._meta` set to an object and confirm it appears on that tool in `tools/list`. Set it to a list and confirm the key is omitted rather than emitted as a JSON array. 5. Return the **nested** embedded-resource shape from a tool and confirm each `_meta` lands on its own level — outer on the content block, inner on the resource contents: ```php [ 'type' => 'resource', 'resource' => [ 'uri' => 'ui://x', 'text' => '...', '_meta' => [ 'contents' => true ] ], '_meta' => [ 'block' => true ], ] ``` 6. Return the **flat** shape (`type`, `uri`, `mimeType`, `text`, `_meta`) and confirm its `_meta` lands on the resource contents, not the block. 7. Return a `type: "image"` result with a sibling `_meta` and confirm it reaches the image content block. The image bytes go in `results` as raw binary, which the handler base64-encodes. ### Prompts 8. Return a prompt message content block carrying a `_meta` object and confirm it survives to `prompts/get`. Change it to a list and confirm the key is omitted and a warning naming the prompt is logged. ### Gate 9. Run `composer test`, `composer lint` and `composer phpstan`. ## Changelog Entry > Fixed - Preserve `_meta` on resource contents, embedded resources, content blocks and prompt messages, and omit a `_meta` that would not serialize as a JSON object. --------- Co-authored-by: Grzegorz Ziolkowski <grzegorz@gziolo.pl>
What?
Closes #245.
Carry
_metathrough to the DTOs the adapter emits, and normalize_metaandannotationsat every site that builds one, so only values a client can accept reach the wire._metafrom a handler's content item onto resource contents, embedded resources, content blocks and prompt messagesresource_metaargument toContentBlockHelper::embedded_text_resource()andembedded_blob_resource(), which sets the nested resource contents'_metawhile the existing argument sets the content block's_metathroughMcpValidator::normalize_meta(), which yields a value only when it serializes as a JSON objectannotationsvalue throughMcpAnnotationMapper::map(), which keeps the fields the target type models and coerces them to the types it declaresresources/readmimeTypeas declared, checking presence and type only_metaorannotationsvalue the handlers drop, and deliver a prompt message the schema DTOs refuse as a text block rather than failing the responsetype: "image"that carries noresults, which reaches the client as tool dataWhy?
_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
_metaandannotationsas part of the response that carries them, so a malformed value costs the whole payload rather than only itself.How?
Shape rules.
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.McpAnnotationMapper::map()keeps only the fields the target type models, then coerces each to that field's declared type. Mapped annotations run throughMcpValidator::get_annotation_validation_errors()and are dropped when it reports errors.Why coercion. The schema DTOs assert strict PHP types —
Annotations::prioritya float,Resource::sizeandResourceLink::sizean int, eachToolAnnotationshint a bool — and WordPress returns stored scalars as strings, so a priority read from post meta arrives as"0.5". Coercion covers unambiguous intent (a number written as a string, a boolean as"1","true","0","false"); anything else is dropped, and a field the DTO cannot accept returns aWP_Errornaming it.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.annotationsdescribes the content block in either form, since resource contents declare noannotationsfield.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.Content annotations vs. tool annotations. A tool result's
annotationskey carries content annotations —audience,priority,lastModified— not theToolAnnotationsvocabulary that describes a tool on its descriptor.docs/guides/creating-abilities.mdstates the split beside the tool annotations it is easily read as.mimeType. MCP places no format constraint onmimeTypeon any object that carries one, so presence and type are the only checks that apply.validate_mime_type(),validate_image_mime_type(),validate_audio_mime_type(),validate_icon_mime_type()and the icon MIME allow-list are removed with their call sites, and the value is emitted as written.imageandaudiocontent blocks still require amimeType, which the schema marks required there.Where the generic path stops. Only the two branches that read a
typekey —resource(inside the URI guard) andimage— treat siblingannotationsand_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; readingannotationsoff it would give one key two meanings. It attaches neither.Logging. The six 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.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.Prompt messages.
PromptsHandler::normalize_content_block()applies the same two rules to each block, casts aresource_linksizeto int, and checks embedded contents throughis_valid_resource_contents()(a non-empty stringuri, plus one oftextorblob). A block the DTOs refuse becomes a text block carrying that block's JSON with a logged warning, leaving the other messages untouched — the substitution the handler already applies to an unrecognized content type and an invalid role.ToolsHandlerandPromptsHandlershare mapping and validation throughHandlerHelperTrait::build_content_annotations().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 affectedannotationson atype: "resource"tool result now reaches the content block, and animageresult carries theannotationsand_metawritten beside itresources/readhandler whose first item carries abloband nouriortextreturns oneBlobResourceContentsper item, rather than a single text block holding the list JSON-encodedprompts/getanswers with the messages it can render_metais logged with the object it sat on and the tool, prompt or URI it came from; the emitted payload is unchanged by the loggingmimeTypecarrying RFC 2045 parameters, such as thetext/html;profile=mcp-appan MCP Apps UI template declares, reaches theresources/listdescriptor instead of being dropped; anymimeTypestring now survives to the wire as writtentype: "image"with noresultskey is logged as a warning naming the tool; it reaches the client as tool data exactly as beforeresource_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 ] ], 'annotations' => [ 'audience' => [ 'user' ] ], '_meta' => [ 'block' => true ], ]type,uri,mimeType,text,_meta,annotations) and confirm its_metalands on the resource contents, not the block, whileannotationsstill lands on the block.type: "image"result with siblingannotationsand_metaand confirm both reach the image content block. The image bytes go inresultsas raw binary, which the handler base64-encodes:[ 'type' => 'image', 'results' => file_get_contents( $png_path ), // raw bytes, not base64 'mimeType' => 'image/png', 'annotations' => [ 'audience' => [ 'user' ] ], '_meta' => [ 'block' => true ], ]dataand noresultskey, and confirm it arrives as a JSON text block and that a warning naming the tool is logged.typekey that happens to containannotationsand_metakeys, and confirm they are left as tool data insidestructuredContentand the JSON text block, not lifted onto the content block.meta.annotationsset to[ 'readOnlyHint' => '1' ]and confirm the tool registers and emitsreadOnlyHint: true. Separately, return'annotations' => [ 'priority' => '0.5' ]from a tool result and confirm the emittedpriorityis the float0.5.Resources —
mimeTypemeta.mcp.mimeTypeset totext/html;profile=mcp-appand confirmresources/listcarries that exact string on the descriptor, unaltered.Prompts
uriand confirmprompts/getanswers with that message rendered as a text block, with a logged warning, and the remaining messages as written.Gate
composer test,composer lintandcomposer phpstan.Changelog Entry