Skip to content

fix: carry _meta through to every emitted DTO - #263

Merged
galatanovidiu merged 5 commits into
trunkfrom
split/2-meta-end-to-end
Aug 12, 2026
Merged

fix: carry _meta through to every emitted DTO#263
galatanovidiu merged 5 commits into
trunkfrom
split/2-meta-end-to-end

Conversation

@galatanovidiu

@galatanovidiu galatanovidiu commented Jul 28, 2026

Copy link
Copy Markdown
Contributor

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:
    '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 ] ],
        '_meta'    => [ 'block' => true ],
    ]
  3. Return the flat shape (type, uri, mimeType, text, _meta) and confirm its _meta lands on the resource contents, not the block.
  4. 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

  1. 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

  1. 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.

@github-actions

github-actions Bot commented Jul 28, 2026

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: gziolo <gziolo@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 28, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 88.17%. Comparing base (65ea934) to head (e6d2a16).

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

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 review requested due to automatic review settings July 28, 2026 18:34
@galatanovidiu
galatanovidiu force-pushed the split/2-meta-end-to-end branch from 74527b8 to fe3a4db Compare July 28, 2026 18:34

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 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 _meta through it across domain factories and ContentBlockHelper.
  • Preserves _meta on resource contents, embedded resources, tool image blocks, and prompt message content blocks; logs when handler-provided _meta is dropped.
  • Updates resources/read content detection to treat blob-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.

Comment thread tests/phpunit/Unit/Handlers/ToolsHandlerCallTest.php
Base automatically changed from split/1-mimetype-as-declared to trunk August 5, 2026 12:21
@galatanovidiu
galatanovidiu force-pushed the split/2-meta-end-to-end branch from fe3a4db to 666dfdc Compare August 5, 2026 12:21
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.

@gziolo gziolo left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

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.

galatanovidiu and others added 5 commits August 12, 2026 08:43
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.
@galatanovidiu
galatanovidiu force-pushed the split/2-meta-end-to-end branch from ca34aaa to e6d2a16 Compare August 12, 2026 06:46
@galatanovidiu

Copy link
Copy Markdown
Contributor Author

I simplified this PR substantially in e6d2a16 while keeping the intended scope: usable _meta support across the Adapter, not only the specific resource-read omission reported in #245.

Why the smaller implementation is sufficient

php-mcp-schema 0.1.3 already accepts _meta on the relevant DTOs and emits it from their array representations. The Adapter therefore does not need a second serialization system. Its responsibilities are narrower:

  1. Preserve _meta wherever Adapter code reconstructs a protocol DTO.
  2. Keep metadata at the correct level of the DTO tree.
  3. Avoid emitting a PHP list as _meta, because MCP declares the field as a JSON object.

The implementation now uses one shallow rule through McpValidator::normalize_meta():

  • a non-empty associative array is passed through unchanged;
  • an absent, empty, non-array, or list-shaped value is treated as omitted.

This is intentionally shallow. MCP leaves the values under direct _meta keys application-defined, so the Adapter should not recursively rewrite them, invoke JsonSerializable objects early, or invent a nested normalization contract. Key names and nested values remain unchanged. Invalid optional metadata is omitted without withholding the payload it accompanies.

Behavior retained

  • Tool, resource, and prompt definitions preserve object-shaped _meta.
  • Prompt content blocks preserve block-level _meta, including nested resource-content metadata.
  • resources/read preserves _meta on both text and blob contents. This closes the concrete resources/read strips the _meta field from resource contents #245 reconstruction gap.
  • Tool image shorthand preserves content-block _meta.
  • Tool embedded-resource shorthand supports both levels correctly:
    • in the nested form, outer _meta belongs to the content block and resource._meta belongs to the resource contents;
    • in the flat resource-contents form, _meta belongs to the nested resource contents.
  • Blob-only resource results are recognized without adding broad whole-result validation.
  • ContentBlockHelper exposes separate optional arguments for wrapper metadata and nested resource metadata.

Generic tool results remain generic tool data. Without an explicit content-block type, a key named _meta may be part of the tool's domain result, so the Adapter does not reinterpret it as protocol metadata.

What was removed

  • Recursive normalization and metadata-tree copying.
  • Stateful JsonSerializable freezing and replay tests.
  • JSON preflight encoding.
  • Transport-level metadata processing and transport-specific tests.
  • Metadata-drop logging and the shared handler logging abstraction.
  • Broad resource-result classification and speculative validation unrelated to metadata propagation.
  • Repeated per-class tests for the same shared normalization rule.

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

  • npm run test:php: 1,034 tests, 3,946 assertions, with one existing multisite-only skip.
  • npm run lint:php: passed.
  • npm run lint:php:stan: passed with no errors.
  • git diff --check: passed.

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.

@galatanovidiu
galatanovidiu merged commit a996566 into trunk Aug 12, 2026
26 checks passed
@galatanovidiu
galatanovidiu deleted the split/2-meta-end-to-end branch August 12, 2026 06:54
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

3 participants