Skip to content

Normalize _meta and annotations on every emitted DTO, and emit mimeType as declared - #260

Draft
galatanovidiu wants to merge 22 commits into
trunkfrom
fix/preserve-resource-contents-meta
Draft

Normalize _meta and annotations on every emitted DTO, and emit mimeType as declared#260
galatanovidiu wants to merge 22 commits into
trunkfrom
fix/preserve-resource-contents-meta

Conversation

@galatanovidiu

@galatanovidiu galatanovidiu commented Jul 27, 2026

Copy link
Copy Markdown
Contributor

What?

Closes #245.

Carry _meta through to the DTOs the adapter emits, and normalize _meta and annotations at every site that builds one, so only values a client can accept reach the wire.

  • copy _meta from a handler's content item onto resource contents, embedded resources, content blocks and prompt messages
  • 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
  • route every _meta through McpValidator::normalize_meta(), which yields a value only when it serializes as a JSON object
  • route every annotations value through McpAnnotationMapper::map(), which keeps the fields the target type models and coerces them to the types it declares
  • recognize a blob-only item as resource contents in resources/read
  • emit mimeType as declared, checking presence and type only
  • log a _meta or annotations value the handlers drop, and deliver a prompt message the schema DTOs refuse as a text block rather than failing the response
  • log a tool result marked type: "image" that carries no results, which reaches the client as tool data
  • add 89 tests across the content block helper, validator, three handlers and four domain factories

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 and annotations as 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 through McpValidator::get_annotation_validation_errors() and are dropped when it reports errors.

Why coercion. The schema DTOs assert strict PHP types — Annotations::priority a float, Resource::size and ResourceLink::size an int, each ToolAnnotations hint 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 a WP_Error naming it.

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. annotations describes the content block in either form, since resource contents declare no annotations field.

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.

Content annotations vs. tool annotations. A tool result's annotations key carries content annotations — audience, priority, lastModified — not the ToolAnnotations vocabulary that describes a tool on its descriptor. docs/guides/creating-abilities.md states the split beside the tool annotations it is easily read as.

mimeType. MCP places no format constraint on mimeType on 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. image and audio content blocks still require a mimeType, which the schema marks required there.

Where the generic path stops. Only the two branches that read a type key — resource (inside the URI guard) and image — treat sibling annotations and _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 annotations off it would give one key two meanings. It attaches neither.

Logging. The six 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. 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.

Prompt messages. PromptsHandler::normalize_content_block() applies the same two rules to each block, casts a resource_link size to int, and checks embedded contents through is_valid_resource_contents() (a non-empty string uri, plus one of text or blob). 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. ToolsHandler and PromptsHandler share mapping and validation through HandlerHelperTrait::build_content_annotations().

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
  • annotations on a type: "resource" tool result now reaches the content block, and an image result carries the annotations and _meta written beside it
  • 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
  • annotations are filtered to the target vocabulary and coerced to declared types, so a hint written from stored data no longer costs a tool or resource its registration
  • a prompt message whose block the DTOs refuse is delivered as JSON in a text block; prompts/get answers with the messages it can render
  • 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
  • a mimeType carrying RFC 2045 parameters, such as the text/html;profile=mcp-app an MCP Apps UI template declares, reaches the resources/list descriptor instead of being dropped; any mimeType string now survives to the wire as written
  • a tool result marked type: "image" with no results key is logged as a warning naming the tool; it reaches the client as tool data exactly as before
  • 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:
    '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

  1. 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.
  2. 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:
    [
        'type'        => 'resource',
        'resource'    => [ 'uri' => 'ui://x', 'text' => '...', '_meta' => [ 'contents' => true ] ],
        'annotations' => [ 'audience' => [ 'user' ] ],
        '_meta'       => [ 'block' => true ],
    ]
  3. Return the flat shape (type, uri, mimeType, text, _meta, annotations) and confirm its _meta lands on the resource contents, not the block, while annotations still lands on the block.
  4. Return a type: "image" result with sibling annotations and _meta and confirm both reach the image content block. The image bytes go in results as 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 ],
    ]
  5. Return the same result with the encoded bytes under data and no results key, and confirm it arrives as a JSON text block and that a warning naming the tool is logged.
  6. Return a result with no type key that happens to contain annotations and _meta keys, and confirm they are left as tool data inside structuredContent and the JSON text block, not lifted onto the content block.
  7. Register an ability with meta.annotations set to [ 'readOnlyHint' => '1' ] and confirm the tool registers and emits readOnlyHint: true. Separately, return 'annotations' => [ 'priority' => '0.5' ] from a tool result and confirm the emitted priority is the float 0.5.

Resources — mimeType

  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.

Prompts

  1. Return a prompt message whose embedded resource carries no uri and confirm prompts/get answers with that message rendered as a text block, with a logged warning, and the remaining messages as written.

Gate

  1. Run composer test, composer lint and composer phpstan.

Changelog Entry

Fixed - Preserve _meta on resource contents, embedded resources and prompt messages, normalize _meta and annotations on every emitted DTO so only spec-valid values reach the client, and emit mimeType as declared.

`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.
Copilot AI review requested due to automatic review settings July 27, 2026 21:25
@github-actions

Copy link
Copy Markdown

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 props-bot label.

If you're merging code through a pull request on GitHub, copy and paste the following into the bottom of the merge commit message.

Co-authored-by: galatanovidiu <ovidiu-galatan@git.wordpress.org>
Co-authored-by: Utsav-Ladani <utsavladani@git.wordpress.org>

To understand the WordPress project's expectations around crediting contributors, please review the Contributor Attribution page in the Core Handbook.

@codecov

codecov Bot commented Jul 27, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 88.69%. Comparing base (6550fc5) to head (0f3efd7).

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     
Flag Coverage Δ
unit 88.69% <100.00%> (+0.66%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

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

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 _meta for tools/resources/prompts and for content blocks (including embedded resources).
  • Preserve _meta on resources/read contents, and correctly map _meta for embedded resources across the two DTO levels (block vs nested resource) while also parsing/dropping malformed annotations.
  • Add extensive unit test coverage for _meta normalization 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.

Comment thread includes/Domain/Utils/McpValidator.php Outdated
galatanovidiu and others added 15 commits July 28, 2026 08:30
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_*`.
@galatanovidiu galatanovidiu changed the title Carry _meta through resource contents, embedded resources and component descriptors Normalize _meta and annotations on every DTO the adapter emits Jul 28, 2026
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.
@galatanovidiu galatanovidiu changed the title Normalize _meta and annotations on every DTO the adapter emits Normalize _meta and annotations on every emitted DTO, and emit mimeType as declared Jul 28, 2026
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.
pull Bot pushed a commit to jonathanbossenger/mcp-adapter that referenced this pull request Aug 5, 2026
## 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#262WordPress#263WordPress#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.
galatanovidiu added a commit that referenced this pull request Aug 12, 2026
## 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>
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.

resources/read strips the _meta field from resource contents

2 participants