Skip to content

fix: emit a mimeType as declared - #262

Merged
galatanovidiu merged 2 commits into
trunkfrom
split/1-mimetype-as-declared
Aug 5, 2026
Merged

fix: emit a mimeType as declared#262
galatanovidiu merged 2 commits into
trunkfrom
split/1-mimetype-as-declared

Conversation

@galatanovidiu

@galatanovidiu galatanovidiu commented Jul 28, 2026

Copy link
Copy Markdown
Contributor

What?

See #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 #260, which carries the same change as one branch.
Review order is #262#263#264. To exercise all three together, test #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.

Copilot AI review requested due to automatic review settings July 28, 2026 18:25
@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>

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 87.95%. Comparing base (6550fc5) to head (aaafcdb).
⚠️ Report is 2 commits behind head on trunk.

Additional details and impacted files
@@             Coverage Diff              @@
##              trunk     #262      +/-   ##
============================================
- Coverage     88.02%   87.95%   -0.07%     
+ Complexity     1270     1258      -12     
============================================
  Files            54       54              
  Lines          4117     4085      -32     
============================================
- Hits           3624     3593      -31     
+ Misses          493      492       -1     
Flag Coverage Δ
unit 87.95% <100.00%> (-0.07%) ⬇️

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 removes MIME type format/allow-list validation and updates the adapter to emit mimeType exactly as provided (subject to existing non-empty guards in some places), aligning behavior with MCP’s “opaque string” treatment of mimeType.

Changes:

  • Removed MIME type validators and their call sites so mimeType is no longer constrained by an RFC2045-style pattern or icon allow-list.
  • Adjusted resource/prompt/icon validation to check only presence/type where applicable (with image/audio still requiring a mimeType field).
  • Updated PHPUnit coverage to reflect the new “accept any string” behavior.

Reviewed changes

Copilot reviewed 9 out of 9 changed files in this pull request and generated 3 comments.

Show a summary per file
File Description
includes/Domain/Utils/McpValidator.php Drops icon MIME allow-list enforcement (type-only check).
includes/Domain/Resources/RegisterAbilityAsMcpResource.php Emits ability _meta.mcp.mimeType when non-empty after trimming.
includes/Domain/Resources/McpResourceValidator.php Removes MIME format validation; keeps type checking when provided.
includes/Domain/Resources/McpResource.php Emits mimeType when non-empty (now without format validation).
includes/Domain/Prompts/McpPromptValidator.php Requires mimeType for image/audio blocks without validating prefixes; loosens resource_link mimeType checks.
tests/phpunit/Unit/Resources/RegisterAbilityAsMcpResourceTest.php Updates expectation to keep and emit unusual mimeType values.
tests/phpunit/Unit/Domain/Utils/McpValidatorTest.php Removes MIME validator tests; updates icon validation expectations.
tests/phpunit/Unit/Domain/Resources/McpResourceValidatorTest.php Updates resource validation tests to accept arbitrary mimeType strings.
tests/phpunit/Unit/Domain/Prompts/McpPromptValidatorTest.php Updates prompt validation tests to accept arbitrary image/audio/resource_link mimeType strings while still requiring presence for image/audio.
Comments suppressed due to low confidence (1)

includes/Domain/Prompts/McpPromptValidator.php:373

  • Using empty($content['mimeType']) treats the string '0' as empty and will incorrectly fail validation even though it's a non-empty string. Use an isset/is_string/trim check instead to avoid empty() edge cases and align with the intended "non-empty string" requirement.
				if ( empty( $content['mimeType'] ) || ! is_string( $content['mimeType'] ) ) {

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread includes/Domain/Resources/McpResource.php
Comment thread includes/Domain/Prompts/McpPromptValidator.php
Comment thread includes/Domain/Resources/RegisterAbilityAsMcpResource.php Outdated
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, along with their call sites in the resource and prompt
validators, `McpResource` and `RegisterAbilityAsMcpResource`. A `mimeType`
is now kept when it is a non-empty string and emitted as written.

`image` and `audio` content blocks still require a `mimeType`, which the
schema marks required there.

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 rather than being dropped.

Removes four public static methods from `McpValidator`.

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

Code changes loog good.

Some minor tweaks are required before merge:

  • outdated docs
    - `validate_mime_type()` -- MIME type format validation
  • the existing fixture needs some refresh, as it is valid per requirements, but unusual in
    // Resource with invalid mimeType - should silently skip mimeType
    wp_register_ability(
    'test/resource-invalid-mimetype',
    array(
    'label' => 'Resource Invalid MimeType',
    'description' => 'A resource with invalid mimeType for testing validation',
    'category' => 'test',
    'execute_callback' => static function () {
    return 'content';
    },
    'permission_callback' => static function () {
    return true;
    },
    'meta' => array(
    'mcp' => array(
    'public' => true,
    'type' => 'resource',
    'uri' => 'WordPress://local/resource-invalid-mimetype',
    'mimeType' => 'not//valid', // Invalid mime type.
    ),
    ),
    )
    );

@galatanovidiu
galatanovidiu merged commit f245561 into trunk Aug 5, 2026
26 checks passed
@galatanovidiu
galatanovidiu deleted the split/1-mimetype-as-declared branch August 5, 2026 12:21
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.

3 participants