diff --git a/docs/README.md b/docs/README.md index 853b0e84..bd99ef25 100644 --- a/docs/README.md +++ b/docs/README.md @@ -12,6 +12,7 @@ Documentation for the WordPress MCP Adapter - transform WordPress abilities into - **[Default Server](guides/default-server.md)** - Understanding the built-in MCP server and core abilities - **[Creating Abilities](guides/creating-abilities.md)** - Build tools, resources, and prompts with annotations +- **[Tools with a UI (MCP Apps)](guides/creating-abilities.md#building-a-tool-with-a-ui-mcp-apps)** - Render an interactive interface from a tool - **[Transport Permissions](guides/transport-permissions.md)** - Custom authentication and access control - **[Custom Transports](guides/custom-transports.md)** - Specialized communication protocols - **[Error Handling](guides/error-handling.md)** - Custom error logging and monitoring diff --git a/docs/getting-started/basic-examples.md b/docs/getting-started/basic-examples.md index a2bc623f..9f1db5bf 100644 --- a/docs/getting-started/basic-examples.md +++ b/docs/getting-started/basic-examples.md @@ -102,9 +102,8 @@ add_action( 'wp_abilities_api_init', function() { 'meta' => [ 'public' => true, // Expose to clients, including MCP 'annotations' => [ - 'priority' => 2.0, - 'readOnlyHint' => false, - 'destructiveHint' => false + 'readonly' => false, // Abilities API name; emitted as readOnlyHint + 'destructive' => false // Abilities API name; emitted as destructiveHint ] ] ]); @@ -124,7 +123,7 @@ echo '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"my-plugin- ## Example 2: Resource - Site Configuration -Resources provide access to data. They require a `uri` in the ability meta: +Resources provide access to data. They require a `uri` under `meta.mcp`: ```php [ 'public' => true, // Expose to clients, including MCP - 'uri' => 'wordpress://site/config', // Required for resources - 'annotations' => [ - 'readOnlyHint' => true, - 'idempotentHint' => true, - 'audience' => ['user', 'assistant'], - 'priority' => 0.8 - ], 'mcp' => [ - 'type' => 'resource' // Mark as resource for auto-discovery + 'type' => 'resource', // Mark as resource for auto-discovery + 'uri' => 'wordpress://site/config', // Required for resources + 'annotations' => [ + 'audience' => ['user', 'assistant'], + 'priority' => 0.8 + ] ] ] ]); @@ -213,24 +210,20 @@ add_action( 'wp_abilities_api_init', function() { }, 'meta' => [ 'public' => true, // Expose to clients, including MCP - 'arguments' => [ - [ - 'name' => 'code', - 'description' => 'Code to review', - 'required' => true - ], - [ - 'name' => 'focus', - 'description' => 'Areas to focus on during review', - 'required' => false - ] - ], - 'annotations' => [ - 'readOnlyHint' => true, - 'idempotentHint' => true - ], 'mcp' => [ - 'type' => 'prompt' // Mark as prompt for auto-discovery + 'type' => 'prompt', // Mark as prompt for auto-discovery + 'arguments' => [ + [ + 'name' => 'code', + 'description' => 'Code to review', + 'required' => true + ], + [ + 'name' => 'focus', + 'description' => 'Areas to focus on during review', + 'required' => false + ] + ] ] ] ]); @@ -259,11 +252,16 @@ The MCP Adapter automatically creates a default server that exposes all register ### Component Types - **Tools**: Execute actions (like `tools/call`) -- **Resources**: Provide data access (like `resources/read`) - require `meta.uri` +- **Resources**: Provide data access (like `resources/read`) - require `meta.mcp.uri` - **Prompts**: Generate messages (like `prompts/get`) - return `messages` array ### Annotations -All MCP components may include metadata in `meta.annotations`, which hint at how clients should treat them. +Annotations hint at how clients should treat a component, and each type reads them from a different place: + +- **Tools**: `meta.annotations` — write the Abilities API names `readonly`, `destructive`, `idempotent`, which the adapter maps to `readOnlyHint`, `destructiveHint`, `idempotentHint`. `openWorldHint` and `title` have no Abilities API equivalent, so write those under their MCP names. +- **Resources**: `meta.mcp.annotations` — `audience`, `priority`, `lastModified` +- **Prompts**: no descriptor annotations; annotate the message content blocks instead + For full details on annotations, their semantics, and usage guidelines, see the Annotations section of the MCP schema spec: https://modelcontextprotocol.io/specification/2025-06-18/schema#annotations ### Testing diff --git a/docs/guides/creating-abilities.md b/docs/guides/creating-abilities.md index 0e53b79b..fc681882 100644 --- a/docs/guides/creating-abilities.md +++ b/docs/guides/creating-abilities.md @@ -9,7 +9,7 @@ WordPress abilities can be registered as different MCP components: - **Resources**: Provide access to data or content - **Prompts**: Generate structured messages for language models -**Full annotation support**: All component types support MCP annotations through the ability's `meta.annotations` field to provide behavior hints to MCP clients. +**Annotations** provide behavior hints to MCP clients. Where you write them depends on the component type: Tools read `meta.annotations`, Resources read `meta.mcp.annotations`, and Prompts take no descriptor annotations at all. See [MCP Annotations](#mcp-annotations). ## MCP Exposure @@ -21,7 +21,7 @@ WordPress abilities are NOT accessible via the default MCP server by default. Se 'mcp' => [ 'type' => 'tool' // Optional: 'tool' (default), 'resource', or 'prompt' ], - 'annotations' => [...] // Optional MCP annotations + 'annotations' => [...] // Optional MCP annotations (Tools; Resources use mcp.annotations) ] ``` @@ -88,17 +88,21 @@ wp_register_ability('my-plugin/my-ability', [ 'execute_callback' => 'my_callback', 'permission_callback' => 'my_permission_check', 'meta' => [ - 'public' => true, // Expose to clients, including MCP - 'annotations' => [...], // MCP annotations - 'uri' => '...', // For resources - 'arguments' => [...], // For prompts + 'public' => true, // Expose to clients, including MCP + 'annotations' => [...], // MCP annotations (Tools only) 'mcp' => [ - 'type' => 'tool', // 'tool', 'resource', or 'prompt' + 'type' => 'tool', // 'tool', 'resource', or 'prompt' + 'uri' => '...', // For resources + 'annotations' => [...], // For resources + 'arguments' => [...], // For prompts ] ] ]); ``` +Note that Tools and Resources read annotations from different places, and Prompts have no +descriptor annotations at all. The [MCP Annotations](#mcp-annotations) section covers this. + ## Tool naming When abilities are registered on a custom server as MCP tools, the adapter must transform the ability name into an MCP-compliant tool name. The MCP specification (2025-11-25) restricts tool names to the characters `A-Za-z0-9_.-` with a maximum length of 128. @@ -338,7 +342,21 @@ For simple single-value outputs, you can use flattened schemas. These are automa ## MCP Annotations -Annotations provide behavior hints to MCP clients about how to handle your abilities. **Annotations are type-specific** - Tools use different annotations than Resources and Prompts. +Annotations provide behavior hints to MCP clients about how to handle your abilities. **Annotations are type-specific** — Tools use a different vocabulary than Resources, and each type reads them from a different place. + +| Component | Where to write them | What you write | +|---|---|---| +| **Tool** descriptor | `meta.annotations` | `readonly`, `destructive`, `idempotent` (Abilities API names), plus `openWorldHint` and `title` | +| **Resource** descriptor | `meta.mcp.annotations` | `audience`, `priority`, `lastModified` | +| **Prompt** descriptor | *not supported* | — | +| **Content block** (tool result or prompt message) | `annotations` on the block itself | `audience`, `priority`, `lastModified` | + +Tool annotations are the one case where the name you write differs from the name that goes on the wire: the adapter maps `readonly` → `readOnlyHint`, `destructive` → `destructiveHint`, `idempotent` → `idempotentHint`. `openWorldHint` and `title` have no Abilities API equivalent, so they are written and emitted under the same name. + +Two things to watch: + +- Resources also accept a top-level `meta.annotations`, but that location is **deprecated as of 0.5.0** and logs a deprecation notice. Write `meta.mcp.annotations` instead. The same applies to `meta.uri`, `meta.mimeType` and `meta.size` — prefer `meta.mcp.*`. +- The MCP `Prompt` object has **no** `annotations` field, so a Prompt ability's `meta.annotations` is ignored entirely — it is neither emitted nor logged. Annotate the message content blocks instead. ### Annotation Format: WordPress Abilities API vs MCP @@ -388,72 +406,110 @@ The MCP Adapter automatically converts WordPress Abilities API annotation names - **Future-proof**: Additional WordPress formats may be added - **Interoperability**: Works with other WordPress Abilities API consumers -#### For Resources & Prompts: MCP Format Only +#### For Resources: MCP Format Only, Under `mcp` -Resources and Prompts use MCP format directly - there are no WordPress equivalents: +Resources use MCP format directly — there are no WordPress equivalents — and read from `meta.mcp.annotations`: ```php 'meta' => [ - 'annotations' => [ - 'audience' => ['user', 'assistant'], // MCP format (no WordPress equivalent) - 'lastModified' => '2024-01-15T10:30:00Z', // MCP format (no WordPress equivalent) - 'priority' => 0.8 // MCP format (no WordPress equivalent) + 'mcp' => [ + 'type' => 'resource', + 'annotations' => [ + 'audience' => ['user', 'assistant'], // MCP format (no WordPress equivalent) + 'lastModified' => '2024-01-15T10:30:00Z', // MCP format (no WordPress equivalent) + 'priority' => 0.8 // MCP format (no WordPress equivalent) + ] ] ] ``` +A top-level `meta.annotations` on a Resource still works, but is deprecated as of 0.5.0 and logs a notice. + +#### For Prompts: No Descriptor Annotations + +The MCP `Prompt` object has no `annotations` field, so there is nothing to write on a Prompt ability's meta. Annotate the message content blocks your callback returns — see [Message Content Annotations](#message-content-annotations-mcp-specification). + ### Tool Annotations (ToolAnnotations) -Tools support these MCP specification annotations: +Write them in `meta.annotations`, using the Abilities API names where one exists: ```php 'meta' => [ 'annotations' => [ - 'readOnlyHint' => true, // Tool doesn't modify data - 'destructiveHint' => false, // Tool doesn't delete/destroy data - 'idempotentHint' => true, // Same input → same output + 'readonly' => true, // Tool doesn't modify data + 'destructive' => false, // Tool doesn't delete/destroy data + 'idempotent' => true, // Same input → same output 'openWorldHint' => false, // Works with predefined data only 'title' => 'Custom Title' // Display title (optional) ] ] ``` -**Supported Tool Annotation Fields:** -- `readOnlyHint` (bool): Tool doesn't modify data -- `destructiveHint` (bool): Tool may delete or destroy data -- `idempotentHint` (bool): Same input always produces same output -- `openWorldHint` (bool): Tool can work with arbitrary/unknown data -- `title` (string): Custom display title for the tool +**Field Names — Written vs Emitted:** + +| What you write | What MCP receives | Meaning | +|---|---|---| +| `readonly` (bool) | `readOnlyHint` | Tool doesn't modify data | +| `destructive` (bool) | `destructiveHint` | Tool may delete or destroy data | +| `idempotent` (bool) | `idempotentHint` | Same input always produces same output | +| `openWorldHint` (bool) | `openWorldHint` | Tool can work with arbitrary/unknown data | +| `title` (string) | `title` | Custom display title for the tool | + +`readonly`, `destructive` and `idempotent` are the WordPress Abilities API's own annotation names. WordPress core defines them on every ability, validates that `meta.annotations` is an array, and reads them back in its REST layer — so writing them keeps your ability consistent for every Abilities API consumer, not just MCP. + +Writing the MCP names (`readOnlyHint`, `destructiveHint`, `idempotentHint`) directly also works. If both are present, the Abilities API name wins. + +### Tool Result Annotations + +The annotations above describe the tool itself and belong on its descriptor, in the ability's `meta.annotations`. A content block a tool *returns* is a different object, and it takes the content vocabulary — the same `audience`, `priority` and `lastModified` that Resources and Prompts use: + +```php +'execute_callback' => function() { + return [ + 'type' => 'resource', + 'annotations' => [ + 'audience' => ['user'], // who the block is for + 'priority' => 0.8, // 0.0 (lowest) to 1.0 (highest) + ], + 'resource' => [ + 'uri' => 'wordpress://report/latest', + 'text' => 'Report body', + ], + ]; +}, +``` -**WordPress → MCP Field Conversion**: For backward compatibility, Tools support WordPress-format field names that are automatically converted: -- `readonly` → `readOnlyHint` -- `destructive` → `destructiveHint` -- `idempotent` → `idempotentHint` +A tool hint written on a result is dropped: `readOnlyHint` and its siblings describe a tool, and a content block is not one. Values outside what MCP allows — a `priority` beyond 0.0–1.0, an `audience` role other than `user` or `assistant`, a `lastModified` that is not a valid timestamp — cause the annotations to be dropped as a group and logged, and the result is still returned. -### Resource & Prompt Annotations (Annotations) +### Resource Annotations (Annotations) -Resources and Prompts share the same annotation schema per MCP specification: +Resources use the MCP content annotation schema, written under `meta.mcp.annotations`: ```php 'meta' => [ - 'annotations' => [ - 'audience' => ['user', 'assistant'], // Intended audience - 'lastModified' => '2024-01-15T10:30:00Z', // ISO 8601 timestamp - 'priority' => 0.8 // 0.0 (lowest) to 1.0 (highest) + 'mcp' => [ + 'type' => 'resource', + 'annotations' => [ + 'audience' => ['user', 'assistant'], // Intended audience + 'lastModified' => '2024-01-15T10:30:00Z', // ISO 8601 timestamp + 'priority' => 0.8 // 0.0 (lowest) to 1.0 (highest) + ] ] ] ``` -**Supported Resource & Prompt Annotation Fields:** +**Supported Resource Annotation Fields:** - `audience` (array): Intended roles - `["user"]`, `["assistant"]`, or both - `lastModified` (string): ISO 8601 timestamp of last modification - `priority` (float): Relative importance (0.0 = lowest, 1.0 = highest) +Content blocks — whether returned from a tool or carried in a prompt message — use these same three fields, written on the block itself. + ### Annotation Usage by Component Type -- **Tools**: Use annotations to describe tool behavior and execution characteristics -- **Resources**: Use annotations for content metadata and access patterns -- **Prompts**: Support two types of annotations (template-level and message content-level) +- **Tools**: Two places. `meta.annotations` describes the tool's behavior and execution characteristics on its descriptor; a returned content block takes content annotations on the block. +- **Resources**: One place, `meta.mcp.annotations`, for content metadata and access patterns. +- **Prompts**: One place, the message content blocks. The descriptor has no annotations field. ### Complete Annotation Example @@ -490,19 +546,19 @@ wp_register_ability('my-plugin/user-data', [ 'permission_callback' => function() { return current_user_can('read'); }, 'meta' => [ 'public' => true, - 'uri' => 'wordpress://users/profile', - 'annotations' => [ - 'audience' => ['assistant'], // For AI use only - 'priority' => 0.9, // High importance - 'lastModified' => date('c') // ISO 8601 timestamp - ], 'mcp' => [ - 'type' => 'resource' + 'type' => 'resource', + 'uri' => 'wordpress://users/profile', + 'annotations' => [ + 'audience' => ['assistant'], // For AI use only + 'priority' => 0.9, // High importance + 'lastModified' => date('c') // ISO 8601 timestamp + ] ] ] ]); -// Prompt with Prompt-specific annotations +// Prompt — no descriptor annotations; annotate the message content instead wp_register_ability('my-plugin/review-prompt', [ 'label' => 'Code Review Prompt', 'description' => 'Generate structured code review prompts', @@ -518,11 +574,6 @@ wp_register_ability('my-plugin/review-prompt', [ 'permission_callback' => function() { return current_user_can('edit_posts'); }, 'meta' => [ 'public' => true, - 'annotations' => [ - 'audience' => ['user', 'assistant'], // For both user and AI - 'priority' => 0.8, // High priority - 'lastModified' => date('c') // Current timestamp - ], 'mcp' => [ 'type' => 'prompt' ] @@ -637,7 +688,7 @@ wp_register_ability('my-plugin/count-posts', [ ## Creating Resources -Resources provide access to data or content. They require a `uri` in the meta field and should set `type: 'resource'` in the MCP configuration: +Resources provide access to data or content. They require a `uri` and should set `type: 'resource'`, both under `meta.mcp`: ```php wp_register_ability('my-plugin/site-config', [ @@ -658,19 +709,246 @@ wp_register_ability('my-plugin/site-config', [ }, 'meta' => [ 'public' => true, // Expose to clients, including MCP - 'uri' => 'wordpress://site/config', - 'annotations' => [ - 'audience' => ['user', 'assistant'], // For both users and AI - 'priority' => 0.8, // High priority resource - 'lastModified' => '2024-01-15T10:30:00Z' // Last update timestamp - ], 'mcp' => [ - 'type' => 'resource' // Mark as resource for auto-discovery + 'type' => 'resource', // Mark as resource for auto-discovery + 'uri' => 'wordpress://site/config', + 'annotations' => [ + 'audience' => ['user', 'assistant'], // For both users and AI + 'priority' => 0.8, // High priority resource + 'lastModified' => '2024-01-15T10:30:00Z' // Last update timestamp + ] ] ] ]); ``` +### Returning Structured Resource Contents + +The example above returns a plain array, which the adapter JSON-encodes into a single text content item. To control the response yourself, return a list of content items instead. Each item may set `uri`, `mimeType`, one of `text` or `blob`, and `_meta`: + +```php +'execute_callback' => function() { + return [ + [ + 'uri' => 'ui://my-plugin/app', + 'mimeType' => 'text/html;profile=mcp-app', + 'text' => '...', + '_meta' => [ + 'ui' => ['prefersBorder' => true], + ], + ], + ]; +}, +``` + +`_meta` travels with the resource but is not part of its body. MCP Apps UI resources use it for CSP config and rendering hints. + +What you write there is a request to the client, not a control the adapter applies. The adapter checks only that `_meta` can serialize as a JSON object and forwards it as written; it does not interpret the keys, and a client is free to ignore any of them. A CSP declared here is enforced by whatever renders the resource, so treat it as a hint to that renderer rather than as a boundary around your own content. + +MCP declares `_meta` as a JSON object, so it must be a non-empty PHP associative array — a sequential array (including an empty one) would serialize as a JSON array. This holds wherever the adapter emits `_meta`: resource contents, content blocks, and the `_meta` a tool, resource or prompt declares under `mcp._meta`. A value that would not serialize as an object is dropped, and whatever it travelled with is still returned. Key names may carry an optional reverse-DNS prefix (`com.example/hint`); prefixes whose second label is `modelcontextprotocol` or `mcp` are reserved by the specification. + +### Serving an Image or Binary File as a Resource + +Binary contents go in `blob` instead of `text`. **The adapter does not encode them for you** — write base64 yourself: + +```php +wp_register_ability('my-plugin/site-logo', [ + 'label' => 'Site Logo', + 'description' => 'The site logo as a PNG image', + 'category' => 'site', + 'execute_callback' => function() { + $path = get_attached_file(get_theme_mod('custom_logo')); + + return [ + [ + 'blob' => base64_encode(file_get_contents($path)), + 'mimeType' => 'image/png', + ], + ]; + }, + 'permission_callback' => function() { + return current_user_can('read'); + }, + 'meta' => [ + 'public' => true, + 'mcp' => [ + 'type' => 'resource', + 'uri' => 'wordpress://site/logo', + // Declared here, `resources/list` tells a client this is a PNG + // before it ever reads the resource. + 'mimeType' => 'image/png', + ], + ], +]); +``` + +An item that omits `uri` inherits the resource's own, so `blob` and `mimeType` are all a binary item needs. Return several items to serve several files from one resource. + +> **Watch the direction of encoding.** A resource's `blob` must arrive already base64-encoded, but a tool's image result takes **raw** bytes in `results` and the adapter encodes them — see [Returning an Image](#returning-an-image). Base64-encoding both, or neither, is the usual mistake. + +Only the **first** item is inspected to decide whether you returned content items or a single payload: it must carry `uri`, `text` or `blob`. If it does not, the whole list is JSON-encoded into one text block instead, siblings included. + +Embedding a resource in a tool result is a general content-block capability, not the route to an MCP App. An MCP App is predeclared: register the UI resource so it appears in `resources/list` under its `ui://` URI, then bind it from the tool descriptor with `mcp._meta.ui.resourceUri`. The host fetches the template itself over `resources/read` and renders it in a sandboxed frame, and the tool result carries only data. A tool result that embeds the UI resource instead is rendered as plain text. + +Tools can return the same contents embedded in a `resource` content block. Two levels each carry their own `_meta`: the content block, and the resource contents nested inside it. The flat form is a content item with a `type` tag added, so its `_meta` describes the resource exactly as it does above: + +```php +return [ + 'type' => 'resource', + 'uri' => 'ui://my-plugin/app', + 'text' => '...', + '_meta' => ['ui' => ['prefersBorder' => true]], // resource contents +]; +``` + +Write the nested form to address both levels. `annotations` describes the block in either form, because resource contents have no `annotations` field: + +```php +return [ + 'type' => 'resource', + 'annotations' => ['audience' => ['user']], + '_meta' => ['block' => 'level'], // content block + 'resource' => [ + 'uri' => 'ui://my-plugin/app', + 'text' => '...', + '_meta' => ['ui' => ['prefersBorder' => true]], // resource contents + ], +]; +``` + +### Returning an Image + +A tool returns an image by marking the result `type` as `image` and putting the **raw bytes** in `results`. The adapter base64-encodes them into the `data` field MCP expects, so do not encode them yourself: + +```php +'execute_callback' => function() { + return [ + 'type' => 'image', + 'results' => file_get_contents( $path ), // raw bytes, not base64 + 'mimeType' => 'image/png', + 'annotations' => ['audience' => ['user']], + '_meta' => ['block' => 'level'], + ]; +}, +``` + +`mimeType` defaults to `image/png` when omitted. `annotations` and `_meta` describe the content block, exactly as in the `resource` form above. + +`results` is the only key the image branch reads. A result marked `type: 'image'` that carries the encoded bytes under `data` instead is returned as ordinary tool data — a JSON text block — and the adapter logs a warning naming the tool. + +## Building a Tool with a UI (MCP Apps) + +[MCP Apps](https://modelcontextprotocol.io/seps/1865-mcp-apps-interactive-user-interfaces-for-mcp) lets a tool render an interactive HTML interface in the client instead of returning text the model reads aloud. It takes **two abilities**: + +1. a **resource** holding the HTML template, published under a `ui://` URI; +2. a **tool** that points at that URI from its descriptor and returns only data. + +The host fetches the template itself over `resources/read`, renders it in a sandboxed iframe, and feeds it the tool's result. Keeping the two apart is deliberate: the host can prefetch and security-review the template before the tool ever runs. + +### Step 1: Register the UI resource + +```php +wp_register_ability('my-plugin/sales-dashboard-ui', [ + 'label' => 'Sales Dashboard UI', + 'description' => 'HTML template that renders the sales dashboard', + 'category' => 'site', + 'execute_callback' => function() { + return [ + [ + 'uri' => 'ui://my-plugin/sales-dashboard', + 'mimeType' => 'text/html;profile=mcp-app', + 'text' => file_get_contents( + plugin_dir_path(__FILE__) . 'ui/sales-dashboard.html' + ), + '_meta' => [ + 'ui' => [ + 'prefersBorder' => true, + 'csp' => [ + 'connectDomains' => ['https://api.example.com'], + 'resourceDomains' => ['https://cdn.example.com'], + ], + ], + ], + ], + ]; + }, + 'permission_callback' => function() { + return current_user_can('view_woocommerce_reports'); + }, + 'meta' => [ + 'public' => true, + 'mcp' => [ + 'type' => 'resource', + 'uri' => 'ui://my-plugin/sales-dashboard', + 'mimeType' => 'text/html;profile=mcp-app', + ], + ], +]); +``` + +The `mimeType` carries an RFC 2045 parameter (`;profile=mcp-app`) and is emitted exactly as written, on both the `resources/list` descriptor and the contents. + +`_meta.ui` is how the template states its rendering needs. The specification defines four keys: + +| Key | Type | Purpose | +|---|---|---| +| `csp` | object | Domains the frame may reach: `connectDomains`, `resourceDomains`, `frameDomains`, `baseUriDomains` | +| `permissions` | object | Browser capabilities to request — camera, microphone, geolocation, clipboardWrite | +| `domain` | string | A dedicated sandbox origin for the frame | +| `prefersBorder` | bool | Whether the host should draw a visual boundary | + +These are requests to the host, not controls the adapter enforces. It checks only that `_meta` serializes as a JSON object and forwards it verbatim — a client is free to ignore any key, and the CSP is applied by whatever renders the frame. + +### Step 2: Point a tool at it + +```php +wp_register_ability('my-plugin/get-sales-summary', [ + 'label' => 'Get Sales Summary', + 'description' => 'Sales totals for a date range, rendered as a dashboard', + 'category' => 'site', + 'input_schema' => [ + 'type' => 'object', + 'properties' => [ + 'days' => ['type' => 'integer', 'default' => 30], + ], + ], + 'execute_callback' => function($input) { + // Return plain data. The template renders it; do not embed HTML here. + return [ + 'total_revenue' => my_plugin_revenue_since($input['days'] ?? 30), + 'order_count' => my_plugin_order_count_since($input['days'] ?? 30), + ]; + }, + 'permission_callback' => function() { + return current_user_can('view_woocommerce_reports'); + }, + 'meta' => [ + 'public' => true, + 'annotations' => [ + 'readonly' => true, + ], + 'mcp' => [ + '_meta' => [ + 'ui' => [ + 'resourceUri' => 'ui://my-plugin/sales-dashboard', + 'visibility' => ['model', 'app'], + ], + ], + ], + ], +]); +``` + +`meta.mcp._meta` is passed through to the tool's descriptor in `tools/list` untouched, which is what makes the binding possible. + +`visibility` governs both who sees the tool and who may call it — `model` the language model, `app` the rendered UI. It defaults to `['model', 'app']`, so the example above states the default explicitly for clarity. The case worth reaching for is `['app']`: a host must leave such a tool out of the model's tool list entirely, which lets the dashboard call back for a refresh or a drill-down without those operations cluttering what the model sees. + +The same `meta.mcp._meta` passthrough exists on all three component types — tool, resource and prompt — for any metadata you need to reach a client. As everywhere else, a value that would not serialize as a JSON object is dropped. + +> **Do not embed the UI in the tool result.** Returning the `ui://` resource as a `resource` content block is a rejected alternative in the specification, not a shortcut — hosts render it as plain text. The tool result carries data; the template is fetched separately. + +MCP Apps is an optional extension a client negotiates. A client that does not support it still gets your tool and its data normally, so the tool must stand on its own without the UI. + ## Creating Prompts Prompts generate structured messages for language models. They use `input_schema` to define parameters, which are automatically converted to MCP prompt arguments format. Prompts should set `type: 'prompt'` in the MCP configuration. @@ -739,10 +1017,6 @@ wp_register_ability('my-plugin/code-review', [ }, 'meta' => [ 'public' => true, // Expose to clients, including MCP - 'annotations' => [ - 'audience' => ['user'], // For user-facing prompts - 'priority' => 0.7 // Standard priority - ], 'mcp' => [ 'type' => 'prompt' // Mark as prompt for auto-discovery ] @@ -805,11 +1079,6 @@ wp_register_ability('my-plugin/analysis-prompt', [ }, 'meta' => [ 'public' => true, // Expose to clients, including MCP - 'annotations' => [ - 'audience' => ['assistant'], // For AI analysis only - 'priority' => 0.9, // High priority analysis - 'lastModified' => date('c') // Current timestamp - ], 'mcp' => [ 'type' => 'prompt' // Mark as prompt for auto-discovery ] @@ -817,21 +1086,88 @@ wp_register_ability('my-plugin/analysis-prompt', [ ]); ``` -### Prompt Annotations Summary +### Messages Carrying Resources and Images -**Template-Level Annotations** (in `meta.annotations`): -- Apply to the prompt template itself -- Describe the prompt's behavior characteristics -- Support Prompt-specific annotations: `audience`, `priority`, `lastModified` +A message's `content` is not limited to text. Five block types are accepted — `text`, `image`, `audio`, `resource` and `resource_link` — so a prompt can hand the model a file, a screenshot or a pointer alongside its instructions: -**Message Content Annotations** (in message `content.annotations`): +```php +'execute_callback' => function($input) { + $post = get_post($input['post_id']); + + return [ + 'messages' => [ + [ + 'role' => 'user', + 'content' => [ + 'type' => 'text', + 'text' => 'Review the post below against our style guide.', + ], + ], + + // An embedded resource: the content travels with the message. + [ + 'role' => 'user', + 'content' => [ + 'type' => 'resource', + 'resource' => [ + 'uri' => 'wordpress://post/' . $post->ID, + 'mimeType' => 'text/markdown', + 'text' => $post->post_content, + ], + 'annotations' => ['audience' => ['assistant']], + ], + ], + + // A resource link: a pointer the client fetches if it wants to. + [ + 'role' => 'user', + 'content' => [ + 'type' => 'resource_link', + 'uri' => 'wordpress://style-guide', + 'name' => 'Editorial style guide', + 'mimeType' => 'text/markdown', + 'size' => 4096, + ], + ], + + // An image: base64 in `data`. + [ + 'role' => 'user', + 'content' => [ + 'type' => 'image', + 'data' => base64_encode(file_get_contents($screenshot)), + 'mimeType' => 'image/png', + ], + ], + ], + ]; +}, +``` + +Three details decide whether these arrive intact: + +- **An embedded `resource` needs a non-empty `uri`, plus `text` or `blob`.** These are the same resource contents a resource ability returns, and they take `_meta` the same way. A block missing them is delivered as a text block holding the block's JSON, with a warning logged — the surrounding messages are unaffected, so one bad block never costs the whole prompt. +- **`image` and `audio` blocks take base64 in `data`**, not raw bytes, and `mimeType` is required on both. This differs from a tool's image result, which takes raw bytes in `results`. +- **`resource_link` carries a pointer, not content** — no `text` or `blob`. Both `uri` and `name` are required. Its `size` is the byte count of the target; a value that is not a positive number is dropped rather than emitted, so a count read from stored data as `"4096"` still works. + +Any block the schema refuses — a `resource_link` with no `name`, an `image` with no `mimeType` — is delivered as a text block carrying its JSON rather than failing the response, and the reason is logged. + +`annotations` and `_meta` sit on the block itself in every case, exactly as on a tool result. + +> `resource_link` and `audio` are prompt-message block types. A tool result only recognises `resource` and `image`; anything else it returns becomes ordinary tool data in a JSON text block. + +### Prompt Annotations Summary + +**Message Content Annotations** (in message `content.annotations`) are the only annotations a prompt has: - Apply to individual messages within the prompt - Provide metadata for specific message content - Support: `audience`, `priority`, `lastModified` +There is no template-level equivalent. The MCP `Prompt` object carries no `annotations` field, so writing `meta.annotations` on a prompt ability has no effect — the value is not emitted, and no warning is logged. + ### Key Points for Prompts -1. **Use `input_schema`** instead of `meta.arguments` - it provides validation and is automatically converted to MCP format +1. **Use `input_schema`** instead of `meta.mcp.arguments` - it provides validation and is automatically converted to MCP format 2. **Callbacks receive validated input** - the Abilities API validates against your schema 3. **Return MCP message format** - prompts must return `{ messages: [...] }` structure 4. **Set `type: 'prompt'`** in `meta.mcp` for proper auto-discovery diff --git a/includes/Domain/Prompts/McpPrompt.php b/includes/Domain/Prompts/McpPrompt.php index 76f3e1bf..42d4a522 100644 --- a/includes/Domain/Prompts/McpPrompt.php +++ b/includes/Domain/Prompts/McpPrompt.php @@ -161,8 +161,9 @@ public static function fromArray( array $config ) { $prompt_data['title'] = $config['title']; } - if ( isset( $config['meta'] ) && is_array( $config['meta'] ) && ! empty( $config['meta'] ) ) { - $prompt_data['_meta'] = $config['meta']; + $prompt_meta = McpValidator::normalize_meta( $config['meta'] ?? null ); + if ( null !== $prompt_meta ) { + $prompt_data['_meta'] = $prompt_meta; } if ( null !== $valid_icons ) { diff --git a/includes/Domain/Prompts/McpPromptBuilder.php b/includes/Domain/Prompts/McpPromptBuilder.php index b6d996f0..a4724549 100644 --- a/includes/Domain/Prompts/McpPromptBuilder.php +++ b/includes/Domain/Prompts/McpPromptBuilder.php @@ -85,10 +85,12 @@ abstract class McpPromptBuilder implements McpPromptBuilderInterface { protected ?array $icons = null; /** - * Additional metadata passed through to MCP clients. + * Additional metadata for MCP clients. * * Use this to attach purpose-specific metadata that MCP clients can consume. - * Keys are passed through unchanged. + * Key names are kept as written. MCP declares `_meta` an object, so {@see self::build()} + * emits this only when it is a non-empty associative array; a list or an empty array + * would serialize as a JSON array and is omitted instead. * * @since 0.5.0 * @@ -163,8 +165,9 @@ static function ( array $arg ): PromptArgument { 'arguments' => $argument_dtos, ); - if ( ! empty( $this->meta ) ) { - $prompt_data['_meta'] = $this->meta; + $prompt_meta = McpValidator::normalize_meta( $this->meta ); + if ( null !== $prompt_meta ) { + $prompt_data['_meta'] = $prompt_meta; } // Only include icons if valid ones exist. @@ -258,7 +261,8 @@ public function get_meta(): array { /** * Set additional metadata. * - * This metadata is passed through to MCP clients unchanged. + * Key names are kept as written. MCP declares `_meta` an object, so {@see self::build()} + * emits this only when it is a non-empty associative array. * * @param array $meta Additional metadata key-value pairs. * diff --git a/includes/Domain/Prompts/McpPromptValidator.php b/includes/Domain/Prompts/McpPromptValidator.php index cfc0ffe5..876519e0 100644 --- a/includes/Domain/Prompts/McpPromptValidator.php +++ b/includes/Domain/Prompts/McpPromptValidator.php @@ -344,18 +344,13 @@ private static function get_content_validation_errors( array $content, int $mess ); } + // mimeType is required. Its value is not checked. if ( empty( $content['mimeType'] ) || ! is_string( $content['mimeType'] ) ) { $errors[] = sprintf( /* translators: %d: message index */ __( 'Message %d image content must have a mimeType field', 'mcp-adapter' ), $message_index ); - } elseif ( ! McpValidator::validate_image_mime_type( $content['mimeType'] ) ) { - $errors[] = sprintf( - /* translators: %d: message index */ - __( 'Message %d image content must have a valid image MIME type', 'mcp-adapter' ), - $message_index - ); } break; @@ -374,18 +369,13 @@ private static function get_content_validation_errors( array $content, int $mess ); } + // mimeType is required. Its value is not checked. if ( empty( $content['mimeType'] ) || ! is_string( $content['mimeType'] ) ) { $errors[] = sprintf( /* translators: %d: message index */ __( 'Message %d audio content must have a mimeType field', 'mcp-adapter' ), $message_index ); - } elseif ( ! McpValidator::validate_audio_mime_type( $content['mimeType'] ) ) { - $errors[] = sprintf( - /* translators: %d: message index */ - __( 'Message %d audio content must have a valid audio MIME type', 'mcp-adapter' ), - $message_index - ); } break; @@ -413,20 +403,12 @@ private static function get_content_validation_errors( array $content, int $mess ); } - if ( isset( $content['mimeType'] ) ) { - if ( ! is_string( $content['mimeType'] ) ) { - $errors[] = sprintf( - /* translators: %d: message index */ - __( 'Message %d resource_link content mimeType must be a string if provided', 'mcp-adapter' ), - $message_index - ); - } elseif ( ! McpValidator::validate_mime_type( $content['mimeType'] ) ) { - $errors[] = sprintf( - /* translators: %d: message index */ - __( 'Message %d resource_link content mimeType must be a valid MIME type format', 'mcp-adapter' ), - $message_index - ); - } + if ( isset( $content['mimeType'] ) && ! is_string( $content['mimeType'] ) ) { + $errors[] = sprintf( + /* translators: %d: message index */ + __( 'Message %d resource_link content mimeType must be a string if provided', 'mcp-adapter' ), + $message_index + ); } if ( isset( $content['size'] ) && ! is_int( $content['size'] ) ) { diff --git a/includes/Domain/Prompts/RegisterAbilityAsMcpPrompt.php b/includes/Domain/Prompts/RegisterAbilityAsMcpPrompt.php index 4e200406..60a7b935 100644 --- a/includes/Domain/Prompts/RegisterAbilityAsMcpPrompt.php +++ b/includes/Domain/Prompts/RegisterAbilityAsMcpPrompt.php @@ -44,7 +44,6 @@ * ), * 'meta' => array( * 'mcp' => array('public' => true, 'type' => 'prompt'), - * 'annotations' => array(...) * ) * ) * ); @@ -182,11 +181,8 @@ private function build_prompt_data() { } // Preserve user-provided _meta from ability.meta.mcp._meta. - $prompt_meta = array(); - if ( ! empty( $mcp_meta['_meta'] ) && is_array( $mcp_meta['_meta'] ) ) { - $prompt_meta = $mcp_meta['_meta']; - } - if ( ! empty( $prompt_meta ) ) { + $prompt_meta = McpValidator::normalize_meta( $mcp_meta['_meta'] ?? null ); + if ( null !== $prompt_meta ) { $data['_meta'] = $prompt_meta; } diff --git a/includes/Domain/Resources/McpResource.php b/includes/Domain/Resources/McpResource.php index a94a137b..4b90c936 100644 --- a/includes/Domain/Resources/McpResource.php +++ b/includes/Domain/Resources/McpResource.php @@ -11,6 +11,7 @@ namespace WP\MCP\Domain\Resources; use WP\MCP\Domain\Contracts\McpComponentInterface; +use WP\MCP\Domain\Utils\McpAnnotationMapper; use WP\MCP\Domain\Utils\McpValidator; use WP\MCP\Infrastructure\ErrorHandling\Contracts\McpErrorHandlerInterface; use WP\MCP\Infrastructure\Observability\FailureReason; @@ -149,17 +150,19 @@ public static function fromArray( array $config ) { $resource_data['description'] = $config['description']; } - // Include mimeType only when valid. + // Include mimeType when non-empty. The value itself is not checked. if ( isset( $config['mimeType'] ) ) { $mime_type = trim( $config['mimeType'] ); - if ( '' !== $mime_type && McpValidator::validate_mime_type( $mime_type ) ) { + if ( '' !== $mime_type ) { $resource_data['mimeType'] = $mime_type; } } - // Include size only when > 0. - if ( isset( $config['size'] ) && $config['size'] > 0 ) { - $resource_data['size'] = $config['size']; + // Include size only when > 0. A byte count reaches us as whatever the caller had - + // "1024" from stored data, 1024.0 from arithmetic - while the schema asserts a + // strict int, so cast rather than let a usable value fail the whole resource. + if ( isset( $config['size'] ) && is_numeric( $config['size'] ) && (int) $config['size'] > 0 ) { + $resource_data['size'] = (int) $config['size']; } // Validate and include icons if set. @@ -170,15 +173,35 @@ public static function fromArray( array $config ) { } } - if ( isset( $config['meta'] ) && is_array( $config['meta'] ) && ! empty( $config['meta'] ) ) { - $resource_data['_meta'] = $config['meta']; + $resource_meta = McpValidator::normalize_meta( $config['meta'] ?? null ); + if ( null !== $resource_meta ) { + $resource_data['_meta'] = $resource_meta; } - // Create the Resource DTO - wrap in try-catch since Annotations::fromArray() and ResourceDto::fromArray() can throw. + // Annotations go through the mapper before the DTO sees them: it keeps only the + // fields the shared Annotations type models and coerces each to the type that type + // asserts. Without it, vocabulary the type does not model leaves an all-null DTO + // that serializes to `[]` where MCP declares an object, and a loosely typed value - + // the string "0.5" WordPress hands back from post meta - fails the DTO's strict + // float assertion and takes the whole resource down with it. A rendering hint must + // not cost the resource its registration. + $annotations = isset( $config['annotations'] ) && is_array( $config['annotations'] ) + ? McpAnnotationMapper::map( $config['annotations'], 'resource' ) + : array(); + + // Mapping fixes each value's type but says nothing about its range. A well-typed but + // out-of-spec value - priority outside 0.0-1.0, an audience role MCP does not define - + // is rejected by a conforming client along with the whole resource, so drop the + // annotations rather than publish something unusable. Matches what + // RegisterAbilityAsMcpResource already does on the ability-backed path. + if ( ! empty( $annotations ) && ! empty( McpValidator::get_annotation_validation_errors( $annotations ) ) ) { + $annotations = array(); + } + + // Create the Resource DTO - wrap in try-catch since ResourceDto::fromArray() can throw. try { - // Process annotations inside try-catch since Annotations::fromArray() can throw. - if ( isset( $config['annotations'] ) && is_array( $config['annotations'] ) && ! empty( $config['annotations'] ) ) { - $resource_data['annotations'] = Annotations::fromArray( $config['annotations'] ); + if ( ! empty( $annotations ) ) { + $resource_data['annotations'] = Annotations::fromArray( $annotations ); } $resource = ResourceDto::fromArray( $resource_data ); diff --git a/includes/Domain/Resources/McpResourceValidator.php b/includes/Domain/Resources/McpResourceValidator.php index 0e3f93dd..e7190d27 100644 --- a/includes/Domain/Resources/McpResourceValidator.php +++ b/includes/Domain/Resources/McpResourceValidator.php @@ -62,12 +62,6 @@ public static function validate_resource_dto( ResourceDto $resource_dto ) { $errors[] = __( 'Resource URI must be a valid URI string', 'mcp-adapter' ); } - // Validate the MIME type if present. - $mime_type = $resource_dto->getMimeType(); - if ( $mime_type && ! McpValidator::validate_mime_type( $mime_type ) ) { - $errors[] = __( 'Resource MIME type is invalid', 'mcp-adapter' ); - } - // Validate icons if present. $icons = $resource_dto->getIcons(); if ( ! empty( $icons ) ) { @@ -161,13 +155,9 @@ public static function get_validation_errors( array $resource_data ): array { $errors[] = __( 'Resource blob content must be valid base64-encoded data', 'mcp-adapter' ); } - // Validate mimeType if present (optional). - if ( isset( $resource_data['mimeType'] ) ) { - if ( ! is_string( $resource_data['mimeType'] ) ) { - $errors[] = __( 'Resource mimeType must be a string if provided', 'mcp-adapter' ); - } elseif ( ! McpValidator::validate_mime_type( $resource_data['mimeType'] ) ) { - $errors[] = __( 'Resource mimeType must be a valid MIME type format', 'mcp-adapter' ); - } + // mimeType is optional. Only its type is checked. + if ( isset( $resource_data['mimeType'] ) && ! is_string( $resource_data['mimeType'] ) ) { + $errors[] = __( 'Resource mimeType must be a string if provided', 'mcp-adapter' ); } return $errors; diff --git a/includes/Domain/Resources/RegisterAbilityAsMcpResource.php b/includes/Domain/Resources/RegisterAbilityAsMcpResource.php index cf504dbf..3eb029f2 100644 --- a/includes/Domain/Resources/RegisterAbilityAsMcpResource.php +++ b/includes/Domain/Resources/RegisterAbilityAsMcpResource.php @@ -156,11 +156,12 @@ private function build_resource_data() { $resource_data['description'] = $description; } - // Optional: mimeType from ability meta (with validation). + // Optional: mimeType from ability meta. Emitted as written - MCP treats it as an + // opaque string, so only a non-empty value is required. $mime_type = $this->get_mcp_meta( 'mimeType', 'string' ); if ( null !== $mime_type ) { $mime_type = trim( $mime_type ); - if ( McpValidator::validate_mime_type( $mime_type ) ) { + if ( '' !== $mime_type ) { $resource_data['mimeType'] = $mime_type; } } @@ -207,11 +208,8 @@ private function build_resource_data() { // Build Resource `_meta`: // - Preserve user-provided `_meta` from ability.meta.mcp._meta. // - Adapter metadata is NEVER included in protocol DTO meta; it is returned separately in adapter_meta. - $resource_meta = array(); - if ( ! empty( $mcp_meta['_meta'] ) && is_array( $mcp_meta['_meta'] ) ) { - $resource_meta = $mcp_meta['_meta']; - } - if ( ! empty( $resource_meta ) ) { + $resource_meta = McpValidator::normalize_meta( $mcp_meta['_meta'] ?? null ); + if ( null !== $resource_meta ) { $resource_data['_meta'] = $resource_meta; } diff --git a/includes/Domain/Tools/McpTool.php b/includes/Domain/Tools/McpTool.php index c7d93b0c..1d626ff9 100644 --- a/includes/Domain/Tools/McpTool.php +++ b/includes/Domain/Tools/McpTool.php @@ -12,6 +12,7 @@ use WP\MCP\Domain\Contracts\McpComponentInterface; use WP\MCP\Domain\Utils\AbilityArgumentNormalizer; +use WP\MCP\Domain\Utils\McpAnnotationMapper; use WP\MCP\Domain\Utils\McpValidator; use WP\MCP\Infrastructure\Observability\FailureReason; use WP\McpSchema\Server\Tools\DTO\Tool as ToolDto; @@ -162,16 +163,27 @@ public static function fromArray( array $config ) { } } - // Preserve user-provided _meta as-is. - if ( isset( $config['meta'] ) && is_array( $config['meta'] ) && ! empty( $config['meta'] ) ) { - $tool_data['_meta'] = $config['meta']; + // Preserve user-provided _meta. + $tool_meta = McpValidator::normalize_meta( $config['meta'] ?? null ); + if ( null !== $tool_meta ) { + $tool_data['_meta'] = $tool_meta; } - // Create the Tool DTO - wrap in try-catch since ToolAnnotations::fromArray() and ToolDto::fromArray() can throw. + // Annotations go through the mapper before the DTO sees them: it keeps only the + // fields ToolAnnotations models and coerces each to the type that type asserts. + // Without it, the shared content vocabulary leaves an all-null DTO that serializes + // to `[]` where MCP declares an object, and a hint stored as "1" - which is how + // WordPress hands booleans back - fails the DTO's strict bool assertion and takes + // the whole tool down with it. A rendering hint must not cost the tool its + // registration. + $annotations = isset( $config['annotations'] ) && is_array( $config['annotations'] ) + ? McpAnnotationMapper::map( $config['annotations'], 'tool' ) + : array(); + + // Create the Tool DTO - wrap in try-catch since ToolDto::fromArray() can throw. try { - // Process annotations inside try-catch since ToolAnnotations::fromArray() can throw. - if ( isset( $config['annotations'] ) && is_array( $config['annotations'] ) && ! empty( $config['annotations'] ) ) { - $tool_data['annotations'] = ToolAnnotations::fromArray( $config['annotations'] ); + if ( ! empty( $annotations ) ) { + $tool_data['annotations'] = ToolAnnotations::fromArray( $annotations ); } $tool = ToolDto::fromArray( $tool_data ); diff --git a/includes/Domain/Tools/RegisterAbilityAsMcpTool.php b/includes/Domain/Tools/RegisterAbilityAsMcpTool.php index a900421d..124eac8f 100644 --- a/includes/Domain/Tools/RegisterAbilityAsMcpTool.php +++ b/includes/Domain/Tools/RegisterAbilityAsMcpTool.php @@ -183,11 +183,8 @@ private function build_tool_data() { // Build Tool `_meta`: // - Preserve user-provided `_meta` from ability.meta.mcp._meta. // - Adapter metadata is NEVER included in protocol DTO meta; it is returned separately in adapter_meta. - $tool_meta = array(); - if ( ! empty( $mcp_meta['_meta'] ) && is_array( $mcp_meta['_meta'] ) ) { - $tool_meta = $mcp_meta['_meta']; - } - if ( ! empty( $tool_meta ) ) { + $tool_meta = McpValidator::normalize_meta( $mcp_meta['_meta'] ?? null ); + if ( null !== $tool_meta ) { $tool_data['_meta'] = $tool_meta; } diff --git a/includes/Domain/Utils/ContentBlockHelper.php b/includes/Domain/Utils/ContentBlockHelper.php index d598e8f2..3b48bf32 100644 --- a/includes/Domain/Utils/ContentBlockHelper.php +++ b/includes/Domain/Utils/ContentBlockHelper.php @@ -29,6 +29,12 @@ * ContentBlockInterface. These DTOs are used in tool call results, prompt messages, * and resource contents throughout the MCP protocol. * + * Every `_meta` argument passes through {@see McpValidator::normalize_meta()}, so a + * value that would not serialize as a JSON object arrives at the client as an absent + * field rather than as a JSON array. MCP declares `_meta` an object, and clients + * validate it as part of the enclosing response, so a non-object there risks the + * whole payload rather than just the metadata. + * * @since 0.5.0 */ final class ContentBlockHelper { @@ -39,7 +45,7 @@ final class ContentBlockHelper { * @param string $data Base64-encoded image data. * @param string $mime_type The MIME type of the image (e.g., 'image/png'). * @param \WP\McpSchema\Common\Protocol\DTO\Annotations|null $annotations Optional annotations for the client. - * @param array|null $_meta Optional metadata. + * @param array|null $_meta Optional metadata for the content block. * * @return \WP\McpSchema\Common\Content\DTO\ImageContent The created ImageContent DTO. */ @@ -50,7 +56,7 @@ public static function image( string $data, string $mime_type, ?Annotations $ann 'data' => $data, 'mimeType' => $mime_type, 'annotations' => $annotations, - '_meta' => $_meta, + '_meta' => McpValidator::normalize_meta( $_meta ), ) ); } @@ -61,7 +67,7 @@ public static function image( string $data, string $mime_type, ?Annotations $ann * @param string $data Base64-encoded audio data. * @param string $mime_type The MIME type of the audio (e.g., 'audio/mp3'). * @param \WP\McpSchema\Common\Protocol\DTO\Annotations|null $annotations Optional annotations for the client. - * @param array|null $_meta Optional metadata. + * @param array|null $_meta Optional metadata for the content block. * * @return \WP\McpSchema\Common\Content\DTO\AudioContent The created AudioContent DTO. */ @@ -72,7 +78,7 @@ public static function audio( string $data, string $mime_type, ?Annotations $ann 'data' => $data, 'mimeType' => $mime_type, 'annotations' => $annotations, - '_meta' => $_meta, + '_meta' => McpValidator::normalize_meta( $_meta ), ) ); } @@ -82,11 +88,19 @@ public static function audio( string $data, string $mime_type, ?Annotations $ann * * Use this for embedding text-based resources (files, documents, etc.) in content. * + * The DTO tree has two levels that each carry their own `_meta`: the content + * block wrapper and the resource contents nested inside it. `$_meta` sets the + * wrapper's; `$resource_meta` sets the contents'. They are distinct fields in + * the spec and are not interchangeable. + * + * @since n.e.x.t Added the optional $resource_meta parameter. + * * @param string $uri The URI of the resource. * @param string $text The text content of the resource. * @param string|null $mime_type Optional MIME type of the resource. * @param \WP\McpSchema\Common\Protocol\DTO\Annotations|null $annotations Optional annotations for the client. - * @param array|null $_meta Optional metadata. + * @param array|null $_meta Optional metadata for the content block. + * @param array|null $resource_meta Optional metadata for the nested resource contents. * * @return \WP\McpSchema\Common\Protocol\DTO\EmbeddedResource The created EmbeddedResource DTO. */ @@ -95,13 +109,15 @@ public static function embedded_text_resource( string $text, ?string $mime_type = null, ?Annotations $annotations = null, - ?array $_meta = null + ?array $_meta = null, + ?array $resource_meta = null ): EmbeddedResource { $resource = TextResourceContents::fromArray( array( 'uri' => $uri, 'text' => $text, 'mimeType' => $mime_type, + '_meta' => McpValidator::normalize_meta( $resource_meta ), ) ); @@ -110,7 +126,7 @@ public static function embedded_text_resource( 'type' => EmbeddedResource::TYPE, 'resource' => $resource, 'annotations' => $annotations, - '_meta' => $_meta, + '_meta' => McpValidator::normalize_meta( $_meta ), ) ); } @@ -120,11 +136,19 @@ public static function embedded_text_resource( * * Use this for embedding binary resources (images, PDFs, etc.) in content. * + * The DTO tree has two levels that each carry their own `_meta`: the content + * block wrapper and the resource contents nested inside it. `$_meta` sets the + * wrapper's; `$resource_meta` sets the contents'. They are distinct fields in + * the spec and are not interchangeable. + * + * @since n.e.x.t Added the optional $resource_meta parameter. + * * @param string $uri The URI of the resource. * @param string $blob Base64-encoded binary data. * @param string|null $mime_type Optional MIME type of the resource. * @param \WP\McpSchema\Common\Protocol\DTO\Annotations|null $annotations Optional annotations for the client. - * @param array|null $_meta Optional metadata. + * @param array|null $_meta Optional metadata for the content block. + * @param array|null $resource_meta Optional metadata for the nested resource contents. * * @return \WP\McpSchema\Common\Protocol\DTO\EmbeddedResource The created EmbeddedResource DTO. */ @@ -133,13 +157,15 @@ public static function embedded_blob_resource( string $blob, ?string $mime_type = null, ?Annotations $annotations = null, - ?array $_meta = null + ?array $_meta = null, + ?array $resource_meta = null ): EmbeddedResource { $resource = BlobResourceContents::fromArray( array( 'uri' => $uri, 'blob' => $blob, 'mimeType' => $mime_type, + '_meta' => McpValidator::normalize_meta( $resource_meta ), ) ); @@ -148,7 +174,7 @@ public static function embedded_blob_resource( 'type' => EmbeddedResource::TYPE, 'resource' => $resource, 'annotations' => $annotations, - '_meta' => $_meta, + '_meta' => McpValidator::normalize_meta( $_meta ), ) ); } @@ -161,7 +187,7 @@ public static function embedded_blob_resource( * * @param string $message The error message. * @param \WP\McpSchema\Common\Protocol\DTO\Annotations|null $annotations Optional annotations for the client. - * @param array|null $_meta Optional metadata. + * @param array|null $_meta Optional metadata for the content block. * * @return \WP\McpSchema\Common\Content\DTO\TextContent The created TextContent DTO. */ @@ -174,7 +200,7 @@ public static function error_text( string $message, ?Annotations $annotations = * * @param string $text The text content. * @param \WP\McpSchema\Common\Protocol\DTO\Annotations|null $annotations Optional annotations for the client. - * @param array|null $_meta Optional metadata. + * @param array|null $_meta Optional metadata for the content block. * * @return \WP\McpSchema\Common\Content\DTO\TextContent The created TextContent DTO. */ @@ -184,7 +210,7 @@ public static function text( string $text, ?Annotations $annotations = null, ?ar 'type' => TextContent::TYPE, 'text' => $text, 'annotations' => $annotations, - '_meta' => $_meta, + '_meta' => McpValidator::normalize_meta( $_meta ), ) ); } @@ -198,7 +224,7 @@ public static function text( string $text, ?Annotations $annotations = null, ?ar * @param mixed $data The data to JSON-encode. * @param int $flags JSON encoding flags (default: 0). * @param \WP\McpSchema\Common\Protocol\DTO\Annotations|null $annotations Optional annotations for the client. - * @param array|null $_meta Optional metadata. + * @param array|null $_meta Optional metadata for the content block. * * @return \WP\McpSchema\Common\Content\DTO\TextContent The created TextContent DTO. */ diff --git a/includes/Domain/Utils/McpValidator.php b/includes/Domain/Utils/McpValidator.php index 0192a809..509686f5 100644 --- a/includes/Domain/Utils/McpValidator.php +++ b/includes/Domain/Utils/McpValidator.php @@ -32,24 +32,6 @@ class McpValidator { */ private const URI_SCHEME_PATTERN = '[a-zA-Z][a-zA-Z0-9+.-]*'; - /** - * Allowed MIME types for MCP icons per specification. - * - * MUST support: image/png, image/jpeg, image/jpg - * SHOULD support: image/svg+xml, image/webp - * - * @since 0.5.0 - * - * @var array - */ - private static array $allowed_icon_mime_types = array( - 'image/png', - 'image/jpeg', - 'image/jpg', - 'image/svg+xml', - 'image/webp', - ); - /** * Validate an MCP component name. * @@ -80,32 +62,6 @@ public static function validate_name( string $name, int $max_length = 128 ): boo return (bool) preg_match( '/^[a-zA-Z0-9_.-]+$/', $name ); } - /** - * Validate image MIME type. - * - * Checks if the MIME type is a valid image type according to MCP specification. - * - * @param string $mime_type The MIME type to validate. - * - * @return bool True if valid image MIME type, false otherwise. - */ - public static function validate_image_mime_type( string $mime_type ): bool { - return str_starts_with( strtolower( $mime_type ), 'image/' ); - } - - /** - * Validate audio MIME type. - * - * Checks if the MIME type is a valid audio type according to MCP specification. - * - * @param string $mime_type The MIME type to validate. - * - * @return bool True if valid audio MIME type, false otherwise. - */ - public static function validate_audio_mime_type( string $mime_type ): bool { - return str_starts_with( strtolower( $mime_type ), 'audio/' ); - } - /** * Validate base64 content. * @@ -212,17 +168,9 @@ public static function get_icon_validation_errors( array $icon ): array { $errors[] = __( 'Icon src must be a valid URL (http/https) or data: URI', 'mcp-adapter' ); } - // mimeType is optional but must be valid if present. - if ( isset( $icon['mimeType'] ) ) { - if ( ! is_string( $icon['mimeType'] ) ) { - $errors[] = __( 'Icon mimeType must be a string', 'mcp-adapter' ); - } elseif ( ! self::validate_icon_mime_type( $icon['mimeType'] ) ) { - $errors[] = sprintf( - /* translators: %s: comma-separated list of allowed MIME types */ - __( 'Icon mimeType must be one of: %s', 'mcp-adapter' ), - implode( ', ', self::$allowed_icon_mime_types ) - ); - } + // mimeType is optional. Only its type is checked. + if ( isset( $icon['mimeType'] ) && ! is_string( $icon['mimeType'] ) ) { + $errors[] = __( 'Icon mimeType must be a string', 'mcp-adapter' ); } // sizes is optional but must be valid if present. @@ -294,22 +242,6 @@ public static function validate_icon_src( string $src ): bool { return false; } - /** - * Validate an icon MIME type. - * - * Per MCP spec, clients MUST support image/png, image/jpeg (and image/jpg). - * Clients SHOULD support image/svg+xml, image/webp. - * - * @param string $mime_type The MIME type to validate. - * - * @return bool True if valid icon MIME type, false otherwise. - * @since 0.5.0 - * - */ - public static function validate_icon_mime_type( string $mime_type ): bool { - return in_array( strtolower( trim( $mime_type ) ), self::$allowed_icon_mime_types, true ); - } - /** * Validate an icon size string. * @@ -501,6 +433,36 @@ public static function validate_priority( $priority ): bool { return $priority_float >= 0.0 && $priority_float <= 1.0; } + /** + * Normalize a `_meta` value for inclusion in a protocol DTO. + * + * MCP declares `_meta` as `{ [key: string]: unknown }` — a JSON object. PHP has one + * array type for both JSON shapes, so a sequential array (including an empty one) + * would serialize to a JSON array and put non-conformant output on the wire. Those + * are treated as absent, as is any non-array value. + * + * Returns null rather than raising: `_meta` is metadata travelling alongside a + * payload, and a malformed one is not a reason to withhold the payload itself. + * + * @since n.e.x.t + * + * @param mixed $meta The raw `_meta` value. + * + * @return array|null A non-empty, non-list array suitable for JSON-object encoding, or null if absent/invalid. + */ + public static function normalize_meta( $meta ): ?array { + if ( ! is_array( $meta ) || array() === $meta ) { + return null; + } + + // A list serializes to a JSON array. array_is_list() needs PHP 8.1; the floor is 7.4. + if ( array_keys( $meta ) === range( 0, count( $meta ) - 1 ) ) { + return null; + } + + return $meta; + } + /** * Validate a resource URI format. * @@ -548,20 +510,4 @@ public static function fold_uri_scheme( string $uri ): string { $uri ) ?? $uri; } - - /** - * Validate general MIME type format. - * - * Validates that a MIME type follows the standard format: type/subtype - * where both type and subtype contain valid characters. - * - * @param string $mime_type The MIME type to validate. - * - * @return bool True if valid MIME type format, false otherwise. - */ - public static function validate_mime_type( string $mime_type ): bool { - // RFC 2045 compliant: allows +, ., and other valid MIME type characters. - // Examples: image/svg+xml, application/vnd.api+json, text/plain. - return (bool) preg_match( '/^[a-zA-Z0-9][a-zA-Z0-9!#$&^_.+-]*\/[a-zA-Z0-9][a-zA-Z0-9!#$&^_.+-]*$/', $mime_type ); - } } diff --git a/includes/Handlers/HandlerHelperTrait.php b/includes/Handlers/HandlerHelperTrait.php index 74dc041d..4aadf263 100644 --- a/includes/Handlers/HandlerHelperTrait.php +++ b/includes/Handlers/HandlerHelperTrait.php @@ -9,7 +9,10 @@ namespace WP\MCP\Handlers; +use WP\MCP\Domain\Utils\McpAnnotationMapper; +use WP\MCP\Domain\Utils\McpValidator; use WP\MCP\Infrastructure\ErrorHandling\Contracts\McpErrorHandlerInterface; +use WP\McpSchema\Common\Protocol\DTO\Annotations; /** * Provides common helper methods for MCP handlers. @@ -61,4 +64,110 @@ protected function validate_filtered_list( $filtered, array $original, string $f return $original; } + + /** + * Build an Annotations DTO for a content block from raw handler output. + * + * Content blocks carry the shared annotations vocabulary (`audience`, `priority`, + * `lastModified`), not the tool-hint vocabulary that belongs on a tool descriptor. + * Anything a conforming client would reject is dropped here, because annotations are + * validated as part of the block that carries them, so a bad annotation costs the + * whole block rather than only itself. Two shapes have to be kept off the wire: a + * value outside what the schema allows, and an annotations object with nothing left + * in it, which PHP serializes as a JSON array where MCP declares an object. + * + * Dropping is logged rather than raised. Annotations are a rendering hint, and + * failing the whole response over one would be a worse outcome for a payload that is + * otherwise valid. + * + * @since n.e.x.t + * + * @param mixed $annotations The raw annotations value. + * @param \WP\MCP\Infrastructure\ErrorHandling\Contracts\McpErrorHandlerInterface $error_handler The error handler for logging. + * @param string $log_message Message to log when annotations are dropped. + * @param array $log_context Context to log alongside the validation errors. + * + * @return \WP\McpSchema\Common\Protocol\DTO\Annotations|null The DTO, or null when there is nothing conformant to emit. + */ + protected function build_content_annotations( + $annotations, + McpErrorHandlerInterface $error_handler, + string $log_message, + array $log_context = array() + ): ?Annotations { + if ( $annotations instanceof Annotations ) { + return $annotations; + } + + if ( ! is_array( $annotations ) ) { + return null; + } + + // The mapper is the single seam where raw annotation input becomes DTO-ready: it + // keeps only the fields the shared Annotations type models, and coerces each to the + // type that type asserts. Both matter here. Vocabulary it drops - most likely the + // tool hints, which belong on the descriptor - would otherwise leave an all-null DTO + // that serializes to `[]`. And a loosely typed but valid value, such as the string + // "0.5" WordPress hands back from post meta, would otherwise be rejected by the + // DTO's strict float assertion. + $mapped = McpAnnotationMapper::map( $annotations, 'resource' ); + if ( empty( $mapped ) ) { + return null; + } + + // Handler output is untrusted, and a value the schema rejects costs the whole + // content block rather than just the annotation. Validate before building the DTO. + $errors = McpValidator::get_annotation_validation_errors( $mapped ); + if ( ! empty( $errors ) ) { + $error_handler->log( + $log_message, + array_merge( $log_context, array( 'errors' => $errors ) ), + 'warning' + ); + + return null; + } + + // Mapping and validation between them leave nothing for fromArray() to reject. + return Annotations::fromArray( $mapped ); + } + + /** + * Normalize a `_meta` value from handler output, logging when one is dropped. + * + * {@see McpValidator::normalize_meta()} answers null both for a `_meta` that was never + * written and for one that could not serialize as a JSON object. The two deserve + * different treatment: the first is the ordinary case, while the second means an author + * wrote metadata that will not reach the client. Passing the raw value separates them + * without a guard at each call site, because an absent key arrives here as null. + * + * Dropping is logged rather than raised, matching {@see self::build_content_annotations()}: + * `_meta` travels alongside a payload, and withholding the payload over its metadata + * would be the worse outcome. A client gives no signal either, since a conforming one + * strips metadata it does not recognize, so this log is the only place the mistake + * surfaces. + * + * @since n.e.x.t + * + * @param mixed $meta The raw `_meta` value. + * @param \WP\MCP\Infrastructure\ErrorHandling\Contracts\McpErrorHandlerInterface $error_handler The error handler for logging. + * @param string $log_message Message to log when `_meta` is dropped. + * @param array $log_context Context to log alongside the message. + * + * @return array|null The normalized `_meta`, or null when there is nothing conformant to emit. + */ + protected function normalize_content_meta( + $meta, + McpErrorHandlerInterface $error_handler, + string $log_message, + array $log_context = array() + ): ?array { + $normalized = McpValidator::normalize_meta( $meta ); + + if ( null === $normalized && null !== $meta ) { + $error_handler->log( $log_message, $log_context, 'warning' ); + } + + return $normalized; + } } diff --git a/includes/Handlers/Prompts/PromptsHandler.php b/includes/Handlers/Prompts/PromptsHandler.php index 091aacfe..7c57680a 100644 --- a/includes/Handlers/Prompts/PromptsHandler.php +++ b/includes/Handlers/Prompts/PromptsHandler.php @@ -241,7 +241,7 @@ private function normalize_result_to_dto( // Tier 2: Simple 'text' shorthand. if ( isset( $result['text'] ) && is_string( $result['text'] ) ) { - return $this->normalize_tier2_text( $result, $prompt ); + return $this->normalize_tier2_text( $result, $prompt, $prompt_name ); } // Tier 3: Single message with 'role' key. @@ -321,12 +321,13 @@ private function normalize_tier1_messages( * * @since 0.5.0 * - * @param array $result Raw result with 'text' key. - * @param \WP\McpSchema\Server\Prompts\DTO\Prompt $prompt The prompt DTO. + * @param array $result Raw result with 'text' key. + * @param \WP\McpSchema\Server\Prompts\DTO\Prompt $prompt The prompt DTO. + * @param string $prompt_name Prompt name for logging. * * @return \WP\McpSchema\Server\Prompts\DTO\GetPromptResult */ - private function normalize_tier2_text( array $result, PromptDto $prompt ): GetPromptResult { + private function normalize_tier2_text( array $result, PromptDto $prompt, string $prompt_name ): GetPromptResult { $content = array( 'type' => 'text', 'text' => (string) $result['text'], @@ -337,6 +338,8 @@ private function normalize_tier2_text( array $result, PromptDto $prompt ): GetPr $content['annotations'] = $result['annotations']; } + $content = $this->normalize_content_block( $content, $prompt_name ); + $message_dto = PromptMessage::fromArray( array( 'role' => self::$default_role, @@ -512,15 +515,179 @@ private function validate_and_create_message( array $message, string $prompt_nam } $content = $this->validate_content_type( $content, $prompt_name ); + $content = $this->normalize_content_block( $content, $prompt_name ); - return PromptMessage::fromArray( - array( - 'role' => $role, - 'content' => $content, - ) + // normalize_content_block() covers the fields the DTOs are known to refuse, but it + // is not a whitelist - a valid type carrying any other malformed payload still + // throws here. Without this guard that throw reaches get_prompt()'s catch and the + // whole prompt is lost, including the messages that were fine. Degrading matches + // what this handler already does for an unknown type and an invalid role. + try { + return PromptMessage::fromArray( + array( + 'role' => $role, + 'content' => $content, + ) + ); + } catch ( \Throwable $e ) { + $this->mcp->get_error_handler()->log( + 'Prompt message content rejected by the schema, degrading to text', + array( + 'prompt_name' => $prompt_name, + 'exception' => $e->getMessage(), + ), + 'warning' + ); + + return PromptMessage::fromArray( + array( + 'role' => $role, + 'content' => $this->degrade_content_to_text( $content ), + ) + ); + } + } + + /** + * Represent a content block that cannot be rendered as a text block carrying its JSON. + * + * The same substitution validate_content_type() makes for an unrecognized type, so a + * block the schema refuses costs its own fidelity rather than the whole prompt. + * + * @since n.e.x.t + * + * @param array $content The content block that could not be rendered. + * + * @return array{type: string, text: string} A text content block. + */ + private function degrade_content_to_text( array $content ): array { + $json = wp_json_encode( $content, JSON_PRETTY_PRINT ); + + return array( + 'type' => 'text', + 'text' => false === $json ? '{}' : $json, ); } + /** + * Whether a value is usable as the contents of an embedded resource. + * + * Mirrors what a conforming client accepts: a non-empty uri, plus either text or + * blob. EmbeddedResource takes its contents as given, so nothing else checks this. + * + * @since n.e.x.t + * + * @param mixed $contents Embedded resource contents as the prompt returned them. + * + * @return bool + */ + private function is_valid_resource_contents( $contents ): bool { + if ( ! is_array( $contents ) ) { + return false; + } + + if ( ! isset( $contents['uri'] ) || ! is_string( $contents['uri'] ) || '' === $contents['uri'] ) { + return false; + } + + return ( isset( $contents['text'] ) && is_string( $contents['text'] ) ) + || ( isset( $contents['blob'] ) && is_string( $contents['blob'] ) ); + } + + /** + * Bring a caller-supplied content block into the shape the schema DTOs accept. + * + * Prompt messages carry the same content blocks tool results do, and reach the wire + * through the same DTOs, so they carry the same two hazards: a `_meta` that would + * serialize as a JSON array where MCP declares an object, and an annotations object a + * conforming client rejects along with the block that carries it. + * + * Here the cost is higher than on the tool path. A value the DTO refuses throws, and + * the catch in get_prompt() turns that into an error response - so a stored byte count + * that arrived as the string "1024", or a `_meta` that is not an array at all, loses + * the whole prompt rather than the field. Everything below is dropped or coerced so + * that the message survives. + * + * @since n.e.x.t + * + * @param array $content Content block as the prompt returned it. + * @param string $prompt_name Prompt name for logging. + * + * @return array Content block safe to hand to PromptMessage::fromArray(). + */ + private function normalize_content_block( array $content, string $prompt_name ): array { + $block_meta = $this->normalize_content_meta( + $content['_meta'] ?? null, + $this->mcp->get_error_handler(), + 'Invalid _meta on prompt message content block, dropping it', + array( 'prompt_name' => $prompt_name ) + ); + if ( null === $block_meta ) { + unset( $content['_meta'] ); + } else { + $content['_meta'] = $block_meta; + } + + if ( isset( $content['annotations'] ) ) { + $annotations = $this->build_content_annotations( + $content['annotations'], + $this->mcp->get_error_handler(), + 'Invalid annotations in prompt message, dropping them', + array( 'prompt_name' => $prompt_name ) + ); + + if ( null === $annotations ) { + unset( $content['annotations'] ); + } else { + $content['annotations'] = $annotations; + } + } + + // EmbeddedResource takes its resource contents as given, so a nested block never + // reaches a DTO that could reject them. This is the only level that inspects them, + // and the failure it catches is the quiet one: contents that are not valid + // ResourceContents do not throw, they reach the wire, and a conforming client + // rejects the message with nothing to tell the author why. + if ( 'resource' === ( $content['type'] ?? '' ) ) { + $resource = $content['resource'] ?? null; + + if ( ! $this->is_valid_resource_contents( $resource ) ) { + $this->mcp->get_error_handler()->log( + 'Invalid embedded resource contents in prompt message, degrading to text', + array( 'prompt_name' => $prompt_name ), + 'warning' + ); + + return $this->degrade_content_to_text( $content ); + } + + $resource_meta = $this->normalize_content_meta( + $resource['_meta'] ?? null, + $this->mcp->get_error_handler(), + 'Invalid _meta on prompt message resource contents, dropping it', + array( 'prompt_name' => $prompt_name ) + ); + if ( null === $resource_meta ) { + unset( $resource['_meta'] ); + } else { + $resource['_meta'] = $resource_meta; + } + $content['resource'] = $resource; + } + + // A resource_link byte count reaches us as whatever the caller had - "1024" from + // stored data, 1024.0 from arithmetic - while the schema asserts a strict int. + if ( 'resource_link' === ( $content['type'] ?? '' ) && isset( $content['size'] ) ) { + if ( is_numeric( $content['size'] ) && (int) $content['size'] > 0 ) { + $content['size'] = (int) $content['size']; + } else { + unset( $content['size'] ); + } + } + + return $content; + } + /** * Validate content type against ContentBlockFactory registry. * @@ -544,12 +711,14 @@ private function validate_content_type( array $content, string $prompt_name ): a 'warning' ); - $text = isset( $content['text'] ) ? (string) $content['text'] : wp_json_encode( $content, JSON_PRETTY_PRINT ); + if ( isset( $content['text'] ) && is_scalar( $content['text'] ) ) { + return array( + 'type' => 'text', + 'text' => (string) $content['text'], + ); + } - return array( - 'type' => 'text', - 'text' => false === $text ? '{}' : $text, - ); + return $this->degrade_content_to_text( $content ); } // Check if type is valid. @@ -565,15 +734,7 @@ private function validate_content_type( array $content, string $prompt_name ): a ); // Convert the entire content to a text representation. - $json_content = wp_json_encode( $content, JSON_PRETTY_PRINT ); - if ( false === $json_content ) { - $json_content = '{}'; - } - - return array( - 'type' => 'text', - 'text' => $json_content, - ); + return $this->degrade_content_to_text( $content ); } // Type is valid, return content as-is (preserves annotations). diff --git a/includes/Handlers/Resources/ResourcesHandler.php b/includes/Handlers/Resources/ResourcesHandler.php index c49e5916..07591221 100644 --- a/includes/Handlers/Resources/ResourcesHandler.php +++ b/includes/Handlers/Resources/ResourcesHandler.php @@ -235,9 +235,13 @@ public function read_resource( array $params, $request_id = 0 ) { private function convert_contents_to_dtos( $contents, string $uri ): array { // If contents is already an array of properly structured items, convert each. if ( is_array( $contents ) && ! empty( $contents ) ) { - // Check if this is an array of content items (has 'uri' or 'text' keys in first item). + // Check if this is an array of content items, by looking for a field the first + // item would be built from. `blob` counts alongside `uri` and `text`: binary + // contents carry no text, and the URI falls back to the resource's own, so a + // blob is all a caller has to write. Only the first item is inspected, so a + // field missing here costs every sibling too. $first_item = reset( $contents ); - if ( is_array( $first_item ) && ( isset( $first_item['uri'] ) || isset( $first_item['text'] ) ) ) { + if ( is_array( $first_item ) && ( isset( $first_item['uri'] ) || isset( $first_item['text'] ) || isset( $first_item['blob'] ) ) ) { return array_map( function ( $item ) use ( $uri ) { return $this->create_content_dto( $item, $uri ); @@ -270,14 +274,33 @@ function ( $item ) use ( $uri ) { /** * Create a content DTO from an array item. * - * @param array $item The content item array. - * @param string $default_uri The default URI to use if not specified. + * `_meta` is carried through from the item so metadata a handler attaches to its + * resource contents reaches the client. MCP Apps UI resources rely on this: they + * put CSP config and border hints under `_meta.ui` alongside the HTML body. + * + * A `_meta` that would not serialize as a JSON object is dropped rather than + * forwarded, so a malformed one costs the client its metadata and not the resource. + * The drop is logged, since a conforming client strips metadata it does not + * recognize and would report nothing. See {@see HandlerHelperTrait::normalize_content_meta()}. + * + * Every key is optional and read defensively, because a handler returns whatever + * WordPress handed it: `blob` and `text` are cast to string, `mimeType` is kept + * only when it already is one, and an absent `uri` falls back to $default_uri. + * + * @param array{uri?: mixed, mimeType?: mixed, text?: mixed, blob?: mixed, _meta?: mixed} $item The content item array. + * @param string $default_uri The URI to use when the item names none. * * @return \WP\McpSchema\Common\Protocol\DTO\TextResourceContents|\WP\McpSchema\Common\Protocol\DTO\BlobResourceContents */ private function create_content_dto( array $item, string $default_uri ) { $item_uri = $item['uri'] ?? $default_uri; $mime_type = $item['mimeType'] ?? null; + $meta = $this->normalize_content_meta( + $item['_meta'] ?? null, + $this->mcp->get_error_handler(), + 'Invalid _meta on resource contents, dropping it', + array( 'uri' => $item_uri ) + ); // If there's blob data, create BlobResourceContents. if ( isset( $item['blob'] ) ) { @@ -286,6 +309,7 @@ private function create_content_dto( array $item, string $default_uri ) { 'uri' => $item_uri, 'blob' => (string) $item['blob'], 'mimeType' => is_string( $mime_type ) ? $mime_type : null, + '_meta' => $meta, ) ); } @@ -298,6 +322,7 @@ private function create_content_dto( array $item, string $default_uri ) { 'uri' => $item_uri, 'text' => (string) $text, 'mimeType' => is_string( $mime_type ) ? $mime_type : null, + '_meta' => $meta, ) ); } diff --git a/includes/Handlers/Tools/ToolsHandler.php b/includes/Handlers/Tools/ToolsHandler.php index a337c0be..c8795cca 100644 --- a/includes/Handlers/Tools/ToolsHandler.php +++ b/includes/Handlers/Tools/ToolsHandler.php @@ -234,11 +234,23 @@ public function call_tool( array $params, $request_id = 0 ) { // Handle embedded resource results (MCP ContentBlock type: "resource"). // This allows tools to return text/blob resources using the MCP schema's EmbeddedResource content block. + // + // Two shapes are accepted, and they place `_meta` differently: + // + // - Nested `{ type, resource: { uri, text, _meta }, annotations, _meta }` maps + // one-to-one onto the DTO tree, so the outer keys belong to the content block + // and the inner `_meta` to the resource contents. + // - Flat `{ type, uri, mimeType, text, _meta }` is a resource-contents literal + // carrying a `type` tag: every key beside `type` and `annotations` is a + // `ResourceContents` field, and `_meta` is declared there alongside them. Its + // `_meta` therefore describes the resource, which is what the same literal + // already means to `ResourcesHandler::create_content_dto()`. `annotations` + // stays on the block because resource contents have no such field. A caller + // who needs block-level `_meta` writes the nested form, which exists to + // express that distinction. if ( isset( $result['type'] ) && 'resource' === $result['type'] ) { - $resource_item = $result; - if ( isset( $result['resource'] ) && is_array( $result['resource'] ) ) { - $resource_item = $result['resource']; - } + $is_nested = isset( $result['resource'] ) && is_array( $result['resource'] ); + $resource_item = $is_nested ? $result['resource'] : $result; $uri = $resource_item['uri'] ?? null; $mime_type = $resource_item['mimeType'] ?? null; @@ -247,8 +259,33 @@ public function call_tool( array $params, $request_id = 0 ) { $uri = trim( $uri ); } + $block_meta = $is_nested + ? $this->normalize_content_meta( + $result['_meta'] ?? null, + $this->mcp->get_error_handler(), + 'Invalid _meta on tool result content block, dropping it', + array( 'tool_name' => $tool_name ) + ) + : null; + $resource_meta = $this->normalize_content_meta( + $resource_item['_meta'] ?? null, + $this->mcp->get_error_handler(), + 'Invalid _meta on tool result resource contents, dropping it', + array( 'tool_name' => $tool_name ) + ); + // Only return an EmbeddedResource if we have a valid URI and some content. if ( is_string( $uri ) && '' !== $uri ) { + // Built inside the guard: without a URI this result falls through to the + // generic JSON path, where annotations were never going to be attached, + // so warning that they were dropped would point at the wrong problem. + $annotations = $this->build_content_annotations( + $result['annotations'] ?? null, + $this->mcp->get_error_handler(), + 'Invalid annotations in tool result, dropping them', + array( 'tool_name' => $tool_name ) + ); + if ( isset( $resource_item['text'] ) && is_string( $resource_item['text'] ) ) { return CallToolResult::fromArray( array( @@ -256,7 +293,10 @@ public function call_tool( array $params, $request_id = 0 ) { ContentBlockHelper::embedded_text_resource( $uri, $resource_item['text'], - is_string( $mime_type ) ? $mime_type : null + is_string( $mime_type ) ? $mime_type : null, + $annotations, + $block_meta, + $resource_meta ), ), 'isError' => false, @@ -271,7 +311,10 @@ public function call_tool( array $params, $request_id = 0 ) { ContentBlockHelper::embedded_blob_resource( $uri, $resource_item['blob'], - is_string( $mime_type ) ? $mime_type : null + is_string( $mime_type ) ? $mime_type : null, + $annotations, + $block_meta, + $resource_meta ), ), 'isError' => false, @@ -282,19 +325,60 @@ public function call_tool( array $params, $request_id = 0 ) { } // Handle image results. - if ( isset( $result['type'] ) && 'image' === $result['type'] && isset( $result['results'] ) ) { + // + // `type` marks this result as a description of a content block rather than tool + // data, so its sibling `annotations` and `_meta` are the block's, which is the + // reading the `resource` branch above already applies to the same two keys. + $is_image_result = isset( $result['type'] ) && 'image' === $result['type']; + + // The image bytes are read from `results`. Without that key there is nothing to + // encode, so the result falls through to the generic path and reaches the client + // as a text block; say so rather than letting the `type` marker go unanswered. + if ( $is_image_result && ! isset( $result['results'] ) ) { + $this->mcp->get_error_handler()->log( + 'Tool result marked type "image" has no "results" key, returning it as tool data', + array( 'tool_name' => $tool_name ), + 'warning' + ); + } + + if ( $is_image_result && isset( $result['results'] ) ) { $image_data = base64_encode( $result['results'] ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode $mime_type = $result['mimeType'] ?? self::DEFAULT_IMAGE_MIME_TYPE; + $annotations = $this->build_content_annotations( + $result['annotations'] ?? null, + $this->mcp->get_error_handler(), + 'Invalid annotations in tool result, dropping them', + array( 'tool_name' => $tool_name ) + ); + return CallToolResult::fromArray( array( - 'content' => array( ContentBlockHelper::image( $image_data, $mime_type ) ), + 'content' => array( + ContentBlockHelper::image( + $image_data, + $mime_type, + $annotations, + $this->normalize_content_meta( + $result['_meta'] ?? null, + $this->mcp->get_error_handler(), + 'Invalid _meta on tool result content block, dropping it', + array( 'tool_name' => $tool_name ) + ) + ), + ), 'structuredContent' => null, 'isError' => false, ) ); } + // The generic fallback carries no `type` marker, so every key it holds is tool + // data: the result is JSON-encoded into the text block and returned verbatim as + // `structuredContent`. Reading `annotations` or `_meta` off it would give one key + // two meanings, with nothing to tell a rendering hint from a domain field. + // Standard result - JSON-encode for text content, include as structuredContent. $json_text = wp_json_encode( $result ); if ( false === $json_text ) { diff --git a/tests/phpunit/Fixtures/DummyAbility.php b/tests/phpunit/Fixtures/DummyAbility.php index ee978fa7..b65cba13 100644 --- a/tests/phpunit/Fixtures/DummyAbility.php +++ b/tests/phpunit/Fixtures/DummyAbility.php @@ -150,6 +150,32 @@ public static function register_abilities(): void { ) ); + // Image ability: marks itself as an image but omits the `results` key. + wp_register_ability( + 'test/image-without-results', + array( + 'label' => 'Image Tool Without Results', + 'description' => 'Returns an image-marked payload with no results key', + 'category' => 'test', + 'input_schema' => array( 'type' => 'object' ), + 'execute_callback' => static function ( array $input ) { + return array( + 'type' => 'image', + 'data' => 'iVBORw0KGgo=', + 'mimeType' => 'image/png', + ); + }, + 'permission_callback' => static function ( array $input ) { + return true; + }, + 'meta' => array( + 'mcp' => array( + 'public' => true, // Expose via MCP for testing + ), + ), + ) + ); + // Tool ability: returns an EmbeddedResource-style payload (text). wp_register_ability( 'test/embedded-text-resource', diff --git a/tests/phpunit/Unit/Domain/Prompts/McpPromptValidatorTest.php b/tests/phpunit/Unit/Domain/Prompts/McpPromptValidatorTest.php index dfaac75c..5f7ebe9a 100644 --- a/tests/phpunit/Unit/Domain/Prompts/McpPromptValidatorTest.php +++ b/tests/phpunit/Unit/Domain/Prompts/McpPromptValidatorTest.php @@ -474,21 +474,35 @@ public function test_validate_prompt_messages_with_valid_image_content(): void { $this->assertEmpty( $errors ); } - public function test_validate_prompt_messages_with_invalid_image_mime_type(): void { + public function test_validate_prompt_messages_accepts_any_image_mime_type(): void { $messages = array( array( 'role' => 'user', 'content' => array( 'type' => 'image', 'data' => base64_encode( 'image-data' ), - 'mimeType' => 'text/plain', // Invalid for image + 'mimeType' => 'text/plain', + ), + ), + ); + + $this->assertSame( array(), McpPromptValidator::validate_prompt_messages( $messages ) ); + } + + public function test_validate_prompt_messages_with_missing_image_mime_type(): void { + $messages = array( + array( + 'role' => 'user', + 'content' => array( + 'type' => 'image', + 'data' => base64_encode( 'image-data' ), ), ), ); $errors = McpPromptValidator::validate_prompt_messages( $messages ); $this->assertNotEmpty( $errors ); - $this->assertStringContainsString( 'image content must have a valid image MIME type', $errors[0] ); + $this->assertStringContainsString( 'image content must have a mimeType field', $errors[0] ); } public function test_validate_prompt_messages_with_valid_audio_content(): void { @@ -507,21 +521,35 @@ public function test_validate_prompt_messages_with_valid_audio_content(): void { $this->assertEmpty( $errors ); } - public function test_validate_prompt_messages_with_invalid_audio_mime_type(): void { + public function test_validate_prompt_messages_accepts_any_audio_mime_type(): void { $messages = array( array( 'role' => 'user', 'content' => array( 'type' => 'audio', 'data' => base64_encode( 'audio-data' ), - 'mimeType' => 'text/plain', // Invalid for audio + 'mimeType' => 'text/plain', + ), + ), + ); + + $this->assertSame( array(), McpPromptValidator::validate_prompt_messages( $messages ) ); + } + + public function test_validate_prompt_messages_with_missing_audio_mime_type(): void { + $messages = array( + array( + 'role' => 'user', + 'content' => array( + 'type' => 'audio', + 'data' => base64_encode( 'audio-data' ), ), ), ); $errors = McpPromptValidator::validate_prompt_messages( $messages ); $this->assertNotEmpty( $errors ); - $this->assertStringContainsString( 'audio content must have a valid audio MIME type', $errors[0] ); + $this->assertStringContainsString( 'audio content must have a mimeType field', $errors[0] ); } public function test_validate_prompt_messages_with_valid_resource_content(): void { @@ -929,7 +957,7 @@ public function test_validate_prompt_messages_with_resource_link_non_string_mime $this->assertStringContainsString( 'resource_link content mimeType must be a string', implode( ' ', $errors ) ); } - public function test_validate_prompt_messages_with_resource_link_invalid_mime_type_format(): void { + public function test_validate_prompt_messages_accepts_any_resource_link_mime_type_string(): void { $messages = array( array( 'role' => 'assistant', @@ -942,9 +970,7 @@ public function test_validate_prompt_messages_with_resource_link_invalid_mime_ty ), ); - $errors = McpPromptValidator::validate_prompt_messages( $messages ); - $this->assertNotEmpty( $errors ); - $this->assertStringContainsString( 'resource_link content mimeType must be a valid MIME type format', implode( ' ', $errors ) ); + $this->assertSame( array(), McpPromptValidator::validate_prompt_messages( $messages ) ); } public function test_validate_prompt_messages_with_resource_link_invalid_size(): void { diff --git a/tests/phpunit/Unit/Domain/Resources/McpResourceValidatorTest.php b/tests/phpunit/Unit/Domain/Resources/McpResourceValidatorTest.php index 0dc1ca21..bf04a79d 100644 --- a/tests/phpunit/Unit/Domain/Resources/McpResourceValidatorTest.php +++ b/tests/phpunit/Unit/Domain/Resources/McpResourceValidatorTest.php @@ -45,7 +45,7 @@ public function test_validate_resource_dto_rejects_invalid_uri(): void { $this->assertStringContainsString( 'URI must be a valid URI', $result->get_error_message() ); } - public function test_validate_resource_dto_rejects_invalid_mime_type(): void { + public function test_validate_resource_dto_accepts_any_mime_type_string(): void { $resource = ResourceDto::fromArray( array( 'uri' => 'test://resource', @@ -54,9 +54,7 @@ public function test_validate_resource_dto_rejects_invalid_mime_type(): void { ) ); - $result = McpResourceValidator::validate_resource_dto( $resource ); - $this->assertWPError( $result ); - $this->assertStringContainsString( 'MIME type is invalid', $result->get_error_message() ); + $this->assertTrue( McpResourceValidator::validate_resource_dto( $resource ) ); } public function test_validate_resource_dto_accepts_valid_mime_type(): void { @@ -299,16 +297,14 @@ public function test_get_validation_errors_with_non_string_mime_type(): void { $this->assertStringContainsString( 'mimeType must be a string', $errors[0] ); } - public function test_get_validation_errors_with_invalid_mime_type_format(): void { + public function test_get_validation_errors_accepts_any_mime_type_string(): void { $resource_data = array( 'uri' => 'test://resource', 'text' => 'Content', 'mimeType' => 'invalid-mime', ); - $errors = McpResourceValidator::get_validation_errors( $resource_data ); - $this->assertNotEmpty( $errors ); - $this->assertStringContainsString( 'mimeType must be a valid MIME type format', $errors[0] ); + $this->assertSame( array(), McpResourceValidator::get_validation_errors( $resource_data ) ); } public function test_get_validation_errors_with_valid_mime_type(): void { @@ -329,12 +325,12 @@ public function test_get_validation_errors_with_valid_mime_type(): void { public function test_get_validation_errors_reports_multiple_errors(): void { $resource_data = array( 'uri' => 'invalid uri', - 'mimeType' => 'invalid-mime', + 'mimeType' => 123, // Not a string. // Missing text/blob content ); $errors = McpResourceValidator::get_validation_errors( $resource_data ); - $this->assertCount( 3, $errors, 'Should report all validation errors: invalid URI, invalid mimeType, and missing content' ); + $this->assertCount( 3, $errors, 'Should report all validation errors: invalid URI, non-string mimeType, and missing content' ); } public function test_get_validation_errors_allows_empty_string_text(): void { diff --git a/tests/phpunit/Unit/Domain/Utils/ContentBlockHelperTest.php b/tests/phpunit/Unit/Domain/Utils/ContentBlockHelperTest.php index 808c5f09..e71e3846 100644 --- a/tests/phpunit/Unit/Domain/Utils/ContentBlockHelperTest.php +++ b/tests/phpunit/Unit/Domain/Utils/ContentBlockHelperTest.php @@ -230,6 +230,117 @@ public function test_embedded_blob_resource_with_annotations(): void { $this->assertSame( $annotations, $content->getAnnotations() ); } + /** + * Test that embeddedTextResource() puts each _meta on its own level of the DTO tree. + */ + public function test_embedded_text_resource_sets_block_and_resource_meta_independently(): void { + $content = ContentBlockHelper::embedded_text_resource( + 'ui://example/app', + '', + 'text/html;profile=mcp-app', + null, + array( 'block' => 'level' ), + array( 'ui' => array( 'prefersBorder' => true ) ) + ); + + $this->assertSame( array( 'block' => 'level' ), $content->get_meta() ); + + $resource = $content->getResource(); + $this->assertSame( array( 'ui' => array( 'prefersBorder' => true ) ), $resource->get_meta() ); + } + + /** + * Test that embeddedBlobResource() puts each _meta on its own level of the DTO tree. + */ + public function test_embedded_blob_resource_sets_block_and_resource_meta_independently(): void { + $content = ContentBlockHelper::embedded_blob_resource( + 'file:///doc.pdf', + 'data', + 'application/pdf', + null, + array( 'block' => 'level' ), + array( 'pages' => 3 ) + ); + + $this->assertSame( array( 'block' => 'level' ), $content->get_meta() ); + + $resource = $content->getResource(); + $this->assertSame( array( 'pages' => 3 ), $resource->get_meta() ); + } + + /** + * Test that omitting the resource meta leaves the nested contents without _meta. + */ + public function test_embedded_text_resource_without_resource_meta_leaves_contents_meta_null(): void { + $content = ContentBlockHelper::embedded_text_resource( 'file:///test.txt', 'content' ); + $resource = $content->getResource(); + + $this->assertNull( $resource->get_meta() ); + } + + /** + * Test that a list-shaped _meta is treated as absent by text(). + * + * A list serializes to a JSON array, and MCP declares `_meta` a JSON object. + */ + public function test_text_with_list_shaped_meta_omits_meta(): void { + $content = ContentBlockHelper::text( 'Test message', null, array( 'first', 'second' ) ); + + $this->assertNull( $content->get_meta() ); + } + + /** + * Test that a list-shaped _meta is treated as absent by image(). + */ + public function test_image_with_list_shaped_meta_omits_meta(): void { + $content = ContentBlockHelper::image( 'dGVzdA==', 'image/png', null, array( 'first', 'second' ) ); + + $this->assertNull( $content->get_meta() ); + } + + /** + * Test that a list-shaped _meta is treated as absent by audio(). + */ + public function test_audio_with_list_shaped_meta_omits_meta(): void { + $content = ContentBlockHelper::audio( 'dGVzdA==', 'audio/mpeg', null, array( 'first', 'second' ) ); + + $this->assertNull( $content->get_meta() ); + } + + /** + * Test that embeddedTextResource() drops a list-shaped _meta on both levels of the DTO tree. + */ + public function test_embedded_text_resource_with_list_shaped_meta_omits_meta_on_both_levels(): void { + $content = ContentBlockHelper::embedded_text_resource( + 'ui://example/app', + '', + 'text/html;profile=mcp-app', + null, + array( 'block', 'level' ), + array( 'resource', 'level' ) + ); + + $this->assertNull( $content->get_meta() ); + $this->assertNull( $content->getResource()->get_meta() ); + } + + /** + * Test that embeddedBlobResource() drops a list-shaped _meta on both levels of the DTO tree. + */ + public function test_embedded_blob_resource_with_list_shaped_meta_omits_meta_on_both_levels(): void { + $content = ContentBlockHelper::embedded_blob_resource( + 'file:///doc.pdf', + 'data', + 'application/pdf', + null, + array( 'block', 'level' ), + array( 'resource', 'level' ) + ); + + $this->assertNull( $content->get_meta() ); + $this->assertNull( $content->getResource()->get_meta() ); + } + /** * Test that errorText() creates a TextContent for error messages. */ diff --git a/tests/phpunit/Unit/Domain/Utils/McpValidatorTest.php b/tests/phpunit/Unit/Domain/Utils/McpValidatorTest.php index 740d6010..051937ad 100644 --- a/tests/phpunit/Unit/Domain/Utils/McpValidatorTest.php +++ b/tests/phpunit/Unit/Domain/Utils/McpValidatorTest.php @@ -188,163 +188,6 @@ public function test_validate_name_rejects_slash(): void { $this->assertFalse( McpValidator::validate_name( 'namespace/tool' ) ); } - // MIME Type Validation Tests - - public function test_validate_mime_type_with_valid_types(): void { - $valid_types = array( - 'text/plain', - 'application/json', - 'image/png', - 'audio/mpeg', - 'video/mp4', - 'application/xml', - ); - - foreach ( $valid_types as $type ) { - $this->assertTrue( McpValidator::validate_mime_type( $type ), "MIME type '{$type}' should be valid" ); - } - } - - public function test_validate_mime_type_rejects_invalid_format(): void { - $invalid_types = array( - 'invalid', - 'text', - '/plain', - 'text/', - '', - 'text plain', - 'text@plain', - ); - - foreach ( $invalid_types as $type ) { - $this->assertFalse( McpValidator::validate_mime_type( $type ), "MIME type '{$type}' should be invalid" ); - } - } - - public function test_validate_mime_type_with_structured_syntax_suffix(): void { - // RFC 6839: Structured syntax suffixes like +json, +xml are valid. - $valid_suffix_types = array( - 'application/vnd.api+json', - 'image/svg+xml', - 'application/atom+xml', - 'application/hal+json', - ); - - foreach ( $valid_suffix_types as $type ) { - $this->assertTrue( McpValidator::validate_mime_type( $type ), "MIME type '{$type}' should be valid" ); - } - } - - public function test_validate_mime_type_with_vendor_types(): void { - // RFC 2045: Vendor-specific types with dots and other characters. - $valid_vendor_types = array( - 'application/vnd.ms-excel', - 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', - 'text/vnd.example.custom', - ); - - foreach ( $valid_vendor_types as $type ) { - $this->assertTrue( McpValidator::validate_mime_type( $type ), "MIME type '{$type}' should be valid" ); - } - } - - public function test_validate_mime_type_rejects_parameters(): void { - // MIME type parameters (after ;) are not supported by the validator. - $types_with_parameters = array( - 'text/html; charset=utf-8', - 'text/plain; format=flowed', - ); - - foreach ( $types_with_parameters as $type ) { - $this->assertFalse( McpValidator::validate_mime_type( $type ), "MIME type with parameter '{$type}' should be rejected" ); - } - } - - // Image MIME Type Validation Tests - - public function test_validate_image_mime_type_with_valid_types(): void { - $valid_image_types = array( - 'image/jpeg', - 'image/jpg', - 'image/png', - 'image/gif', - 'image/webp', - 'image/bmp', - 'image/svg+xml', - 'image/avif', - 'image/heic', - 'image/tiff', - ); - - foreach ( $valid_image_types as $type ) { - $this->assertTrue( McpValidator::validate_image_mime_type( $type ), "Image MIME type '{$type}' should be valid" ); - } - } - - public function test_validate_image_mime_type_case_insensitive(): void { - $this->assertTrue( McpValidator::validate_image_mime_type( 'IMAGE/PNG' ) ); - $this->assertTrue( McpValidator::validate_image_mime_type( 'Image/Jpeg' ) ); - } - - public function test_validate_image_mime_type_rejects_invalid(): void { - $invalid_types = array( - 'text/plain', - 'application/json', - 'audio/mp3', - 'video/mp4', - '', - 'not-an-image', - 'image', // Missing subtype - 'images/png', // Wrong prefix - ); - - foreach ( $invalid_types as $type ) { - $this->assertFalse( McpValidator::validate_image_mime_type( $type ), "Type '{$type}' should not be a valid image MIME type" ); - } - } - - // Audio MIME Type Validation Tests - - public function test_validate_audio_mime_type_with_valid_types(): void { - $valid_audio_types = array( - 'audio/wav', - 'audio/mp3', - 'audio/mpeg', - 'audio/ogg', - 'audio/webm', - 'audio/aac', - 'audio/flac', - 'audio/opus', - 'audio/m4a', - ); - - foreach ( $valid_audio_types as $type ) { - $this->assertTrue( McpValidator::validate_audio_mime_type( $type ), "Audio MIME type '{$type}' should be valid" ); - } - } - - public function test_validate_audio_mime_type_case_insensitive(): void { - $this->assertTrue( McpValidator::validate_audio_mime_type( 'AUDIO/MP3' ) ); - $this->assertTrue( McpValidator::validate_audio_mime_type( 'Audio/Mpeg' ) ); - } - - public function test_validate_audio_mime_type_rejects_invalid(): void { - $invalid_types = array( - 'text/plain', - 'application/json', - 'image/jpeg', - 'video/mp4', - '', - 'not-an-audio', - 'audio', // Missing subtype - 'audios/mp3', // Wrong prefix - ); - - foreach ( $invalid_types as $type ) { - $this->assertFalse( McpValidator::validate_audio_mime_type( $type ), "Type '{$type}' should not be a valid audio MIME type" ); - } - } - // Base64 Validation Tests public function test_validate_base64_with_valid_content(): void { @@ -680,35 +523,6 @@ public function test_validate_icon_src_rejects_invalid_data_uri(): void { $this->assertFalse( McpValidator::validate_icon_src( 'data:image/png' ) ); } - // Icon MIME Type Validation Tests - - public function test_validate_icon_mime_type_with_required_types(): void { - // MUST support per MCP spec. - $this->assertTrue( McpValidator::validate_icon_mime_type( 'image/png' ) ); - $this->assertTrue( McpValidator::validate_icon_mime_type( 'image/jpeg' ) ); - $this->assertTrue( McpValidator::validate_icon_mime_type( 'image/jpg' ) ); - } - - public function test_validate_icon_mime_type_with_recommended_types(): void { - // SHOULD support per MCP spec. - $this->assertTrue( McpValidator::validate_icon_mime_type( 'image/svg+xml' ) ); - $this->assertTrue( McpValidator::validate_icon_mime_type( 'image/webp' ) ); - } - - public function test_validate_icon_mime_type_case_insensitive(): void { - $this->assertTrue( McpValidator::validate_icon_mime_type( 'IMAGE/PNG' ) ); - $this->assertTrue( McpValidator::validate_icon_mime_type( 'Image/Jpeg' ) ); - $this->assertTrue( McpValidator::validate_icon_mime_type( 'IMAGE/SVG+XML' ) ); - } - - public function test_validate_icon_mime_type_rejects_unsupported_types(): void { - $this->assertFalse( McpValidator::validate_icon_mime_type( 'image/gif' ) ); - $this->assertFalse( McpValidator::validate_icon_mime_type( 'image/bmp' ) ); - $this->assertFalse( McpValidator::validate_icon_mime_type( 'image/tiff' ) ); - $this->assertFalse( McpValidator::validate_icon_mime_type( 'text/plain' ) ); - $this->assertFalse( McpValidator::validate_icon_mime_type( 'application/json' ) ); - } - // Icon Size Validation Tests public function test_validate_icon_size_with_valid_sizes(): void { @@ -846,14 +660,24 @@ public function test_get_icon_validation_errors_src_not_string(): void { $this->assertStringContainsString( 'string', $errors[0] ); } - public function test_get_icon_validation_errors_invalid_mime_type(): void { + public function test_get_icon_validation_errors_accepts_any_mime_type_string(): void { $icon = array( 'src' => 'https://example.com/icon.gif', - 'mimeType' => 'image/gif', // Not in allowed list. + 'mimeType' => 'image/gif', + ); + + $this->assertSame( array(), McpValidator::get_icon_validation_errors( $icon ) ); + } + + public function test_get_icon_validation_errors_rejects_non_string_mime_type(): void { + $icon = array( + 'src' => 'https://example.com/icon.png', + 'mimeType' => 123, ); $errors = McpValidator::get_icon_validation_errors( $icon ); $this->assertNotEmpty( $errors ); + $this->assertStringContainsString( 'Icon mimeType must be a string', implode( ' ', $errors ) ); } public function test_get_icon_validation_errors_invalid_sizes_not_array(): void { @@ -966,4 +790,54 @@ public function test_validate_icons_array_preserves_valid_icon_data(): void { $this->assertEquals( array( '48x48' ), $result['valid'][0]['sizes'] ); $this->assertEquals( 'light', $result['valid'][0]['theme'] ); } + + public function test_normalize_meta_keeps_associative_array(): void { + $meta = array( 'ui' => array( 'prefersBorder' => true ) ); + + $this->assertSame( $meta, McpValidator::normalize_meta( $meta ) ); + } + + public function test_normalize_meta_keeps_prefixed_keys(): void { + // Reverse-DNS and vendor prefixes are valid _meta key names per MCP. + $meta = array( + 'com.example/hint' => 'value', + 'openai/outputTemplate' => 'ui://example/app', + ); + + $this->assertSame( $meta, McpValidator::normalize_meta( $meta ) ); + } + + /** + * @dataProvider data_non_object_meta + * + * @param mixed $meta The value to normalize. + */ + public function test_normalize_meta_rejects_values_that_are_not_json_objects( $meta ): void { + $this->assertNull( McpValidator::normalize_meta( $meta ) ); + } + + /** + * @return array + */ + public function data_non_object_meta(): array { + return array( + 'null' => array( null ), + 'string' => array( 'not-an-object' ), + 'int' => array( 42 ), + 'bool' => array( true ), + 'object' => array( new \stdClass() ), + // These are arrays, but they serialize to a JSON array rather than an object. + 'empty array' => array( array() ), + 'list' => array( array( 'a', 'b' ) ), + 'numeric keys' => array( array( 0 => 'a', 1 => 'b' ) ), + 'string digits' => array( array( '0' => 'a', '1' => 'b' ) ), + ); + } + + public function test_normalize_meta_keeps_sparse_numeric_keys(): void { + // Not a list, so it serializes as {"1":"a"} — a valid JSON object. + $meta = array( 1 => 'a' ); + + $this->assertSame( $meta, McpValidator::normalize_meta( $meta ) ); + } } diff --git a/tests/phpunit/Unit/Handlers/PromptsHandlerTest.php b/tests/phpunit/Unit/Handlers/PromptsHandlerTest.php index 36159e50..576dfa83 100644 --- a/tests/phpunit/Unit/Handlers/PromptsHandlerTest.php +++ b/tests/phpunit/Unit/Handlers/PromptsHandlerTest.php @@ -1027,4 +1027,765 @@ public function test_list_prompts_with_filter_returning_non_array_falls_back_to_ remove_filter( 'mcp_adapter_prompts_list', $filter ); } + + // ========================================================================= + // Message Content Block Normalization + // + // Prompt messages carry the same content blocks tool results do, so they carry + // the same two hazards: a `_meta` that would serialize as a JSON array, and an + // annotations object a conforming client rejects. Here the cost is higher than + // on the tool path - a value the schema DTO refuses throws, and the catch in + // get_prompt() turns that into an error response, so one bad hint loses the + // whole prompt rather than the hint. + // ========================================================================= + + public function test_message_content_meta_that_is_a_list_is_omitted(): void { + $result = $this->get_prompt_returning( + array( + 'messages' => array( + array( + 'role' => 'user', + 'content' => array( + 'type' => 'text', + 'text' => 'body', + '_meta' => array( 'a', 'b' ), + ), + ), + ), + ) + ); + + $this->assertInstanceOf( GetPromptResult::class, $result ); + + $block = $this->first_content_block( $result ); + $this->assertArrayNotHasKey( '_meta', $block ); + $this->assertSame( 'body', $block['text'] ); + $this->assertStringNotContainsString( '"_meta":[', (string) wp_json_encode( $block ) ); + } + + public function test_message_content_meta_object_is_preserved(): void { + $result = $this->get_prompt_returning( + array( + 'messages' => array( + array( + 'role' => 'user', + 'content' => array( + 'type' => 'text', + 'text' => 'body', + '_meta' => array( 'ui' => array( 'prefersBorder' => true ) ), + ), + ), + ), + ) + ); + + $this->assertInstanceOf( GetPromptResult::class, $result ); + $this->assertSame( + array( 'ui' => array( 'prefersBorder' => true ) ), + $this->first_content_block( $result )['_meta'] + ); + } + + /** + * A `_meta` of the wrong type reaches asArrayOrNull() and throws, which get_prompt() + * catches and turns into an error response. Metadata must not cost the message. + */ + public function test_message_content_meta_that_is_not_an_array_still_returns_the_prompt(): void { + $result = $this->get_prompt_returning( + array( + 'messages' => array( + array( + 'role' => 'user', + 'content' => array( + 'type' => 'text', + 'text' => 'body', + '_meta' => 'not-an-object', + ), + ), + ), + ) + ); + + $this->assertInstanceOf( GetPromptResult::class, $result ); + $this->assertArrayNotHasKey( '_meta', $this->first_content_block( $result ) ); + } + + /** + * The nested form of a resource content block has two levels that each carry + * `_meta`, and the DTO never sees the inner one - EmbeddedResource takes the + * resource contents as given. + */ + public function test_embedded_resource_contents_meta_that_is_a_list_is_omitted(): void { + $result = $this->get_prompt_returning( + array( + 'messages' => array( + array( + 'role' => 'user', + 'content' => array( + 'type' => 'resource', + '_meta' => array( 'a', 'b' ), + 'resource' => array( + 'uri' => 'WordPress://local/prompt-embedded', + 'text' => 'body', + '_meta' => array( 'c', 'd' ), + ), + ), + ), + ), + ) + ); + + $this->assertInstanceOf( GetPromptResult::class, $result ); + + $block = $this->first_content_block( $result ); + $this->assertArrayNotHasKey( '_meta', $block ); + $this->assertArrayNotHasKey( '_meta', $block['resource'] ); + $this->assertSame( 'body', $block['resource']['text'] ); + } + + public function test_embedded_resource_contents_meta_object_is_preserved(): void { + $result = $this->get_prompt_returning( + array( + 'messages' => array( + array( + 'role' => 'user', + 'content' => array( + 'type' => 'resource', + 'resource' => array( + 'uri' => 'WordPress://local/prompt-embedded', + 'text' => 'body', + '_meta' => array( 'ui' => array( 'prefersBorder' => true ) ), + ), + ), + ), + ), + ) + ); + + $this->assertInstanceOf( GetPromptResult::class, $result ); + $this->assertSame( + array( 'ui' => array( 'prefersBorder' => true ) ), + $this->first_content_block( $result )['resource']['_meta'] + ); + } + + /** + * Content blocks take the shared Annotations vocabulary. Tool hints belong on the + * tool descriptor, and leave an all-null DTO here that serializes as a JSON array. + */ + public function test_message_content_with_tool_annotation_vocabulary_omits_annotations(): void { + $result = $this->get_prompt_returning( + array( + 'messages' => array( + array( + 'role' => 'user', + 'content' => array( + 'type' => 'text', + 'text' => 'body', + 'annotations' => array( 'readOnlyHint' => true ), + ), + ), + ), + ) + ); + + $this->assertInstanceOf( GetPromptResult::class, $result ); + + $block = $this->first_content_block( $result ); + $this->assertArrayNotHasKey( 'annotations', $block ); + $this->assertStringNotContainsString( '"annotations":[]', (string) wp_json_encode( $block ) ); + } + + public function test_message_content_with_out_of_range_priority_omits_annotations(): void { + $result = $this->get_prompt_returning( + array( + 'messages' => array( + array( + 'role' => 'user', + 'content' => array( + 'type' => 'text', + 'text' => 'body', + 'annotations' => array( 'priority' => 5 ), + ), + ), + ), + ) + ); + + $this->assertInstanceOf( GetPromptResult::class, $result ); + $this->assertArrayNotHasKey( 'annotations', $this->first_content_block( $result ) ); + } + + public function test_message_content_with_unknown_audience_role_omits_annotations(): void { + $result = $this->get_prompt_returning( + array( + 'messages' => array( + array( + 'role' => 'user', + 'content' => array( + 'type' => 'text', + 'text' => 'body', + 'annotations' => array( 'audience' => array( 'robot' ) ), + ), + ), + ), + ) + ); + + $this->assertInstanceOf( GetPromptResult::class, $result ); + $this->assertArrayNotHasKey( 'annotations', $this->first_content_block( $result ) ); + } + + /** + * A non-string audience entry is cast to the literal "Array" by the schema DTO. + * Validation has to reject it before the DTO sees it. + */ + public function test_message_content_with_non_string_audience_entry_omits_annotations(): void { + $result = $this->get_prompt_returning( + array( + 'messages' => array( + array( + 'role' => 'user', + 'content' => array( + 'type' => 'text', + 'text' => 'body', + 'annotations' => array( 'audience' => array( array( 'nested' ) ) ), + ), + ), + ), + ) + ); + + $this->assertInstanceOf( GetPromptResult::class, $result ); + + $block = $this->first_content_block( $result ); + $this->assertArrayNotHasKey( 'annotations', $block ); + $this->assertStringNotContainsString( 'Array', (string) wp_json_encode( $block ) ); + } + + /** + * WordPress hands stored numbers back as strings. The schema asserts a strict float, + * so without normalization a usable priority throws and loses the whole prompt. + */ + public function test_message_content_with_loosely_typed_priority_is_normalized(): void { + $result = $this->get_prompt_returning( + array( + 'messages' => array( + array( + 'role' => 'user', + 'content' => array( + 'type' => 'text', + 'text' => 'body', + 'annotations' => array( 'priority' => '0.5' ), + ), + ), + ), + ) + ); + + $this->assertInstanceOf( GetPromptResult::class, $result ); + $this->assertSame( 0.5, $this->first_content_block( $result )['annotations']['priority'] ); + } + + public function test_message_content_with_valid_annotations_emits_them_as_an_object(): void { + $result = $this->get_prompt_returning( + array( + 'messages' => array( + array( + 'role' => 'user', + 'content' => array( + 'type' => 'text', + 'text' => 'body', + 'annotations' => array( + 'audience' => array( 'user' ), + 'priority' => 0.8, + 'lastModified' => '2026-07-28T00:00:00Z', + ), + ), + ), + ), + ) + ); + + $this->assertInstanceOf( GetPromptResult::class, $result ); + $this->assertSame( + array( + 'audience' => array( 'user' ), + 'priority' => 0.8, + 'lastModified' => '2026-07-28T00:00:00Z', + ), + $this->first_content_block( $result )['annotations'] + ); + } + + /** + * A resource_link size is the other caller-supplied number the schema asserts as a + * strict int, so it needs the same treatment stored byte counts get elsewhere. + */ + public function test_resource_link_content_with_numeric_string_size_is_normalized(): void { + $result = $this->get_prompt_returning( + array( + 'messages' => array( + array( + 'role' => 'user', + 'content' => array( + 'type' => 'resource_link', + 'uri' => 'WordPress://local/prompt-link', + 'name' => 'Linked resource', + 'size' => '1024', + ), + ), + ), + ) + ); + + $this->assertInstanceOf( GetPromptResult::class, $result ); + $this->assertSame( 1024, $this->first_content_block( $result )['size'] ); + } + + /** + * Tier 2 builds its own content block but copies the caller's annotations into it, + * so it needs the same normalization the message tiers get. + */ + public function test_tier2_text_with_tool_annotation_vocabulary_omits_annotations(): void { + $result = $this->get_prompt_returning( + array( + 'text' => 'body', + 'annotations' => array( 'readOnlyHint' => true ), + ) + ); + + $this->assertInstanceOf( GetPromptResult::class, $result ); + + $block = $this->first_content_block( $result ); + $this->assertArrayNotHasKey( 'annotations', $block ); + $this->assertSame( 'body', $block['text'] ); + } + + public function test_dropped_message_annotations_are_logged_with_the_prompt_name(): void { + DummyErrorHandler::reset(); + + $this->get_prompt_returning( + array( + 'messages' => array( + array( + 'role' => 'user', + 'content' => array( + 'type' => 'text', + 'text' => 'body', + 'annotations' => array( 'priority' => 5 ), + ), + ), + ), + ) + ); + + $dropped = array_values( + array_filter( + DummyErrorHandler::$logs, + static function ( array $log ): bool { + return false !== strpos( $log['message'], 'Invalid annotations' ); + } + ) + ); + + $this->assertNotEmpty( $dropped ); + $this->assertSame( 'warning', $dropped[0]['type'] ); + $this->assertSame( 'test-prompt', $dropped[0]['context']['prompt_name'] ); + $this->assertArrayHasKey( 'errors', $dropped[0]['context'] ); + } + + // ========================================================================= + // Message-Level Degradation + // + // A content block the schema DTOs refuse used to throw, and get_prompt()'s + // catch turned that into an error response - so one unrenderable message + // cost every message in the prompt. The handler already degrades an unknown + // content type and an invalid role to a text representation; these pin the + // same rule for a valid type carrying a payload the DTO rejects. + // ========================================================================= + + public function test_message_with_non_string_text_degrades_only_that_message(): void { + $result = $this->get_prompt_returning( + array( + 'messages' => array( + array( + 'role' => 'user', + 'content' => array( + 'type' => 'text', + 'text' => 'Summarise this post.', + ), + ), + array( + 'role' => 'assistant', + 'content' => array( + 'type' => 'text', + 'text' => 123, + ), + ), + ), + ) + ); + + $this->assertInstanceOf( GetPromptResult::class, $result ); + $this->assertCount( 2, $result->getMessages() ); + + $this->assertSame( 'Summarise this post.', $this->first_content_block( $result )['text'] ); + + $degraded = $this->content_block_at( $result, 1 ); + $this->assertSame( 'text', $degraded['type'] ); + $this->assertStringContainsString( '123', $degraded['text'] ); + } + + public function test_message_with_image_missing_data_degrades_to_text(): void { + $result = $this->get_prompt_returning( + array( + 'messages' => array( + array( + 'role' => 'user', + 'content' => array( + 'type' => 'image', + 'mimeType' => 'image/png', + ), + ), + ), + ) + ); + + $this->assertInstanceOf( GetPromptResult::class, $result ); + + $block = $this->first_content_block( $result ); + $this->assertSame( 'text', $block['type'] ); + $this->assertStringContainsString( '"type": "image"', $block['text'] ); + } + + public function test_message_with_malformed_icon_entry_degrades_to_text(): void { + $result = $this->get_prompt_returning( + array( + 'messages' => array( + array( + 'role' => 'user', + 'content' => array( + 'type' => 'resource_link', + 'uri' => 'WordPress://local/thing', + 'name' => 'thing', + 'icons' => array( array( 'sizes' => '48x48' ) ), + ), + ), + ), + ) + ); + + $this->assertInstanceOf( GetPromptResult::class, $result ); + $this->assertSame( 'text', $this->first_content_block( $result )['type'] ); + } + + public function test_degraded_message_is_logged_with_the_prompt_name(): void { + DummyErrorHandler::reset(); + + $this->get_prompt_returning( + array( + 'messages' => array( + array( + 'role' => 'user', + 'content' => array( + 'type' => 'text', + 'text' => 123, + ), + ), + ), + ) + ); + + $degraded = array_values( + array_filter( + DummyErrorHandler::$logs, + static function ( array $log ): bool { + return false !== strpos( $log['message'], 'rejected by the schema' ); + } + ) + ); + + $this->assertNotEmpty( $degraded ); + $this->assertSame( 'warning', $degraded[0]['type'] ); + $this->assertSame( 'test-prompt', $degraded[0]['context']['prompt_name'] ); + $this->assertArrayHasKey( 'exception', $degraded[0]['context'] ); + } + + /** + * EmbeddedResource takes its contents as given, so these never threw - they + * reached the wire, where a conforming client rejects the whole message and + * nothing tells the author. Opposite failure mode, same remedy. + */ + public function test_embedded_resource_contents_that_are_a_string_degrade_to_text(): void { + $result = $this->get_prompt_returning( + array( + 'messages' => array( + array( + 'role' => 'user', + 'content' => array( + 'type' => 'resource', + 'resource' => 'just-a-string', + ), + ), + ), + ) + ); + + $this->assertInstanceOf( GetPromptResult::class, $result ); + + $block = $this->first_content_block( $result ); + $this->assertSame( 'text', $block['type'] ); + $this->assertArrayNotHasKey( 'resource', $block ); + } + + public function test_embedded_resource_contents_without_a_uri_degrade_to_text(): void { + $result = $this->get_prompt_returning( + array( + 'messages' => array( + array( + 'role' => 'user', + 'content' => array( + 'type' => 'resource', + 'resource' => array( 'text' => 'body' ), + ), + ), + ), + ) + ); + + $this->assertInstanceOf( GetPromptResult::class, $result ); + $this->assertSame( 'text', $this->first_content_block( $result )['type'] ); + } + + public function test_embedded_resource_contents_without_text_or_blob_degrade_to_text(): void { + $result = $this->get_prompt_returning( + array( + 'messages' => array( + array( + 'role' => 'user', + 'content' => array( + 'type' => 'resource', + 'resource' => array( 'uri' => 'WordPress://local/thing' ), + ), + ), + ), + ) + ); + + $this->assertInstanceOf( GetPromptResult::class, $result ); + $this->assertSame( 'text', $this->first_content_block( $result )['type'] ); + } + + /** + * A block with no type falls back to its `text`, but only when that is something a + * string cast can represent - an array would raise a conversion warning and emit the + * literal "Array". + */ + public function test_missing_content_type_with_non_scalar_text_degrades_to_text(): void { + $result = $this->get_prompt_returning( + array( + 'messages' => array( + array( + 'role' => 'user', + 'content' => array( 'text' => array( 'nested' ) ), + ), + ), + ) + ); + + $this->assertInstanceOf( GetPromptResult::class, $result ); + + $block = $this->first_content_block( $result ); + $this->assertSame( 'text', $block['type'] ); + $this->assertStringNotContainsString( 'Array', $block['text'] ); + $this->assertStringContainsString( 'nested', $block['text'] ); + } + + public function test_missing_content_type_with_scalar_text_keeps_the_text(): void { + $result = $this->get_prompt_returning( + array( + 'messages' => array( + array( + 'role' => 'user', + 'content' => array( 'text' => 'plain body' ), + ), + ), + ) + ); + + $this->assertInstanceOf( GetPromptResult::class, $result ); + + $block = $this->first_content_block( $result ); + $this->assertSame( 'text', $block['type'] ); + $this->assertSame( 'plain body', $block['text'] ); + } + + public function test_resource_link_with_a_non_numeric_size_omits_size(): void { + $result = $this->get_prompt_returning( + array( + 'messages' => array( + array( + 'role' => 'user', + 'content' => array( + 'type' => 'resource_link', + 'uri' => 'WordPress://local/thing', + 'name' => 'thing', + 'size' => 'big', + ), + ), + ), + ) + ); + + $this->assertInstanceOf( GetPromptResult::class, $result ); + + $block = $this->first_content_block( $result ); + $this->assertSame( 'resource_link', $block['type'] ); + $this->assertArrayNotHasKey( 'size', $block ); + } + + /** + * Over-degradation guard: a well-formed embedded resource must survive intact. + */ + public function test_valid_embedded_resource_is_not_degraded(): void { + $result = $this->get_prompt_returning( + array( + 'messages' => array( + array( + 'role' => 'user', + 'content' => array( + 'type' => 'resource', + 'resource' => array( + 'uri' => 'WordPress://local/thing', + 'text' => 'body', + ), + ), + ), + ), + ) + ); + + $this->assertInstanceOf( GetPromptResult::class, $result ); + + $block = $this->first_content_block( $result ); + $this->assertSame( 'resource', $block['type'] ); + $this->assertSame( 'body', $block['resource']['text'] ); + } + + /** + * Run a prompt whose result is replaced by $shape, so any result shape can be driven + * through the handler's normalization without registering a new ability per case. + * + * @param array $shape The prompt result to normalize. + * + * @return \WP\McpSchema\Server\Prompts\DTO\GetPromptResult|\WP\McpSchema\Common\JsonRpc\DTO\JSONRPCErrorResponse + */ + private function get_prompt_returning( array $shape ) { + $server = $this->makeServer( array(), array(), array( 'test/prompt' ) ); + $handler = new PromptsHandler( $server ); + + $filter = static function () use ( $shape ) { + return $shape; + }; + add_filter( 'mcp_adapter_prompt_get_result', $filter ); + + $result = $handler->get_prompt( + array( + 'params' => array( + 'name' => 'test-prompt', + 'arguments' => array( 'code' => 'x' ), + ), + ), + 1 + ); + + remove_filter( 'mcp_adapter_prompt_get_result', $filter ); + + return $result; + } + + /** + * The emitted array of the first message's content block. + * + * Asserts on what goes on the wire rather than on DTO getters, which report a + * healthy object for input that serializes to a JSON array. + * + * @param \WP\McpSchema\Server\Prompts\DTO\GetPromptResult $result The prompt result. + * + * @return array + */ + private function first_content_block( GetPromptResult $result ): array { + return $this->content_block_at( $result, 0 ); + } + + /** + * The emitted array of the content block of the message at $index. + * + * @param \WP\McpSchema\Server\Prompts\DTO\GetPromptResult $result The prompt result. + * @param int $index Message index. + * + * @return array + */ + private function content_block_at( GetPromptResult $result, int $index ): array { + return $result->getMessages()[ $index ]->getContent()->toArray(); + } + + /** + * A conforming client strips metadata it does not recognize, so a `_meta` that could + * not be emitted is reported nowhere else. Both levels of an embedded resource log + * separately, because they name different objects. + */ + public function test_message_content_meta_that_is_a_list_logs_the_drop_at_each_level(): void { + $result = $this->get_prompt_returning( + array( + 'messages' => array( + array( + 'role' => 'user', + 'content' => array( + 'type' => 'resource', + '_meta' => array( 'a', 'b' ), + 'resource' => array( + 'uri' => 'WordPress://local/prompt-embedded', + 'text' => 'body', + '_meta' => array( 'c', 'd' ), + ), + ), + ), + ), + ) + ); + + $this->assertInstanceOf( GetPromptResult::class, $result ); + + $messages = array_column( DummyErrorHandler::$logs, 'message' ); + $this->assertContains( 'Invalid _meta on prompt message content block, dropping it', $messages ); + $this->assertContains( 'Invalid _meta on prompt message resource contents, dropping it', $messages ); + } + + /** + * An absent `_meta` is the ordinary case and must stay quiet, or the log fills with + * noise from every message that never asked for metadata. + */ + public function test_message_content_without_meta_does_not_log(): void { + $result = $this->get_prompt_returning( + array( + 'messages' => array( + array( + 'role' => 'user', + 'content' => array( + 'type' => 'text', + 'text' => 'body', + ), + ), + ), + ) + ); + + $this->assertInstanceOf( GetPromptResult::class, $result ); + + $messages = array_column( DummyErrorHandler::$logs, 'message' ); + $this->assertNotContains( 'Invalid _meta on prompt message content block, dropping it', $messages ); + $this->assertNotContains( 'Invalid _meta on prompt message resource contents, dropping it', $messages ); + } } diff --git a/tests/phpunit/Unit/Handlers/ResourcesHandlerReadTest.php b/tests/phpunit/Unit/Handlers/ResourcesHandlerReadTest.php index f91ff746..37bfc8a9 100644 --- a/tests/phpunit/Unit/Handlers/ResourcesHandlerReadTest.php +++ b/tests/phpunit/Unit/Handlers/ResourcesHandlerReadTest.php @@ -5,6 +5,7 @@ namespace WP\MCP\Tests\Unit\Handlers; use WP\MCP\Handlers\Resources\ResourcesHandler; +use WP\MCP\Tests\Fixtures\DummyErrorHandler; use WP\MCP\Tests\TestCase; use WP\McpSchema\Common\JsonRpc\DTO\JSONRPCErrorResponse; use WP\McpSchema\Common\Protocol\DTO\BlobResourceContents; @@ -327,4 +328,302 @@ public function test_read_resource_with_throwing_result_filter_triggers_catch_bl remove_filter( 'mcp_adapter_resource_read_result', $filter ); } + + public function test_read_resource_preserves_meta_on_text_contents(): void { + wp_set_current_user( 1 ); + $server = $this->makeServer( array(), array( 'test/resource' ) ); + $handler = new ResourcesHandler( $server ); + + $filter = static function () { + return array( + array( + 'uri' => 'ui://example/app', + 'mimeType' => 'text/html;profile=mcp-app', + 'text' => '', + '_meta' => array( 'ui' => array( 'prefersBorder' => true ) ), + ), + ); + }; + add_filter( 'mcp_adapter_resource_read_result', $filter ); + + $result = $handler->read_resource( + array( 'params' => array( 'uri' => 'WordPress://local/resource-1' ) ) + ); + + remove_filter( 'mcp_adapter_resource_read_result', $filter ); + + $this->assertInstanceOf( ReadResourceResult::class, $result ); + + $contents = $result->getContents(); + $this->assertInstanceOf( TextResourceContents::class, $contents[0] ); + $this->assertSame( array( 'ui' => array( 'prefersBorder' => true ) ), $contents[0]->get_meta() ); + } + + public function test_read_resource_preserves_meta_on_blob_contents(): void { + wp_set_current_user( 1 ); + $server = $this->makeServer( array(), array( 'test/resource' ) ); + $handler = new ResourcesHandler( $server ); + + $filter = static function () { + return array( + array( + 'uri' => 'WordPress://local/resource-1', + 'mimeType' => 'application/pdf', + 'blob' => 'ZGF0YQ==', + '_meta' => array( 'pages' => 3 ), + ), + ); + }; + add_filter( 'mcp_adapter_resource_read_result', $filter ); + + $result = $handler->read_resource( + array( 'params' => array( 'uri' => 'WordPress://local/resource-1' ) ) + ); + + remove_filter( 'mcp_adapter_resource_read_result', $filter ); + + $this->assertInstanceOf( ReadResourceResult::class, $result ); + + $contents = $result->getContents(); + $this->assertInstanceOf( BlobResourceContents::class, $contents[0] ); + $this->assertSame( array( 'pages' => 3 ), $contents[0]->get_meta() ); + } + + public function test_read_resource_with_non_array_meta_still_returns_contents(): void { + wp_set_current_user( 1 ); + $server = $this->makeServer( array(), array( 'test/resource' ) ); + $handler = new ResourcesHandler( $server ); + + $filter = static function () { + return array( + array( + 'uri' => 'WordPress://local/resource-1', + 'text' => 'body', + '_meta' => 'not-an-object', + ), + ); + }; + add_filter( 'mcp_adapter_resource_read_result', $filter ); + + $result = $handler->read_resource( + array( 'params' => array( 'uri' => 'WordPress://local/resource-1' ) ) + ); + + remove_filter( 'mcp_adapter_resource_read_result', $filter ); + + // A malformed _meta is dropped, not raised: the resource body still reaches the client. + $this->assertInstanceOf( ReadResourceResult::class, $result ); + + $contents = $result->getContents(); + $this->assertInstanceOf( TextResourceContents::class, $contents[0] ); + $this->assertSame( 'body', $contents[0]->getText() ); + $this->assertNull( $contents[0]->get_meta() ); + } + + public function test_read_resource_with_list_meta_omits_it_from_the_wire(): void { + wp_set_current_user( 1 ); + $server = $this->makeServer( array(), array( 'test/resource' ) ); + $handler = new ResourcesHandler( $server ); + + $filter = static function () { + return array( + array( + 'uri' => 'WordPress://local/resource-1', + 'text' => 'body', + '_meta' => array( 'a', 'b' ), + ), + ); + }; + add_filter( 'mcp_adapter_resource_read_result', $filter ); + + $result = $handler->read_resource( + array( 'params' => array( 'uri' => 'WordPress://local/resource-1' ) ) + ); + + remove_filter( 'mcp_adapter_resource_read_result', $filter ); + + $this->assertInstanceOf( ReadResourceResult::class, $result ); + + $contents = $result->getContents(); + $this->assertNull( $contents[0]->get_meta() ); + + // MCP declares _meta as a JSON object; a list would serialize as `"_meta": ["a","b"]`. + $this->assertArrayNotHasKey( '_meta', $contents[0]->toArray() ); + } + + public function test_read_resource_without_meta_leaves_contents_meta_null(): void { + wp_set_current_user( 1 ); + $server = $this->makeServer( array(), array( 'test/resource' ) ); + $handler = new ResourcesHandler( $server ); + + $result = $handler->read_resource( + array( 'params' => array( 'uri' => 'WordPress://local/resource-1' ) ) + ); + + $this->assertInstanceOf( ReadResourceResult::class, $result ); + + $contents = $result->getContents(); + $this->assertInstanceOf( TextResourceContents::class, $contents[0] ); + $this->assertNull( $contents[0]->get_meta() ); + } + + /** + * A client strips metadata it does not recognize, so nothing downstream reports a + * `_meta` that could not be emitted. The log is the only place it surfaces. + */ + public function test_read_resource_with_list_meta_logs_the_drop(): void { + wp_set_current_user( 1 ); + $server = $this->makeServer( array(), array( 'test/resource' ) ); + $handler = new ResourcesHandler( $server ); + + $filter = static function () { + return array( + array( + 'uri' => 'WordPress://local/resource-1', + 'text' => 'body', + '_meta' => array( 'a', 'b' ), + ), + ); + }; + add_filter( 'mcp_adapter_resource_read_result', $filter ); + + $handler->read_resource( + array( 'params' => array( 'uri' => 'WordPress://local/resource-1' ) ) + ); + + remove_filter( 'mcp_adapter_resource_read_result', $filter ); + + $messages = array_column( DummyErrorHandler::$logs, 'message' ); + $this->assertContains( 'Invalid _meta on resource contents, dropping it', $messages ); + } + + /** + * An absent `_meta` is the ordinary case and must stay quiet, or the log fills with + * noise from every resource that never asked for metadata. + */ + public function test_read_resource_without_meta_does_not_log(): void { + wp_set_current_user( 1 ); + $server = $this->makeServer( array(), array( 'test/resource' ) ); + $handler = new ResourcesHandler( $server ); + + $filter = static function () { + return array( + array( + 'uri' => 'WordPress://local/resource-1', + 'text' => 'body', + ), + ); + }; + add_filter( 'mcp_adapter_resource_read_result', $filter ); + + $handler->read_resource( + array( 'params' => array( 'uri' => 'WordPress://local/resource-1' ) ) + ); + + remove_filter( 'mcp_adapter_resource_read_result', $filter ); + + $messages = array_column( DummyErrorHandler::$logs, 'message' ); + $this->assertNotContains( 'Invalid _meta on resource contents, dropping it', $messages ); + } + + /** + * `blob` alone is enough to describe resource contents: the URI falls back to the + * resource's own, and binary contents carry no `text`. + */ + public function test_read_resource_with_blob_only_item_returns_blob_contents(): void { + wp_set_current_user( 1 ); + $server = $this->makeServer( array(), array( 'test/resource' ) ); + $handler = new ResourcesHandler( $server ); + + $filter = static function () { + return array( + array( + 'mimeType' => 'application/pdf', + 'blob' => 'ZGF0YQ==', + ), + ); + }; + add_filter( 'mcp_adapter_resource_read_result', $filter ); + + $result = $handler->read_resource( + array( 'params' => array( 'uri' => 'WordPress://local/resource-1' ) ) + ); + + remove_filter( 'mcp_adapter_resource_read_result', $filter ); + + $this->assertInstanceOf( ReadResourceResult::class, $result ); + + $contents = $result->getContents(); + $this->assertInstanceOf( BlobResourceContents::class, $contents[0] ); + $this->assertSame( 'ZGF0YQ==', $contents[0]->getBlob() ); + $this->assertSame( 'application/pdf', $contents[0]->getMimeType() ); + $this->assertSame( 'WordPress://local/resource-1', $contents[0]->getUri() ); + } + + public function test_read_resource_with_blob_only_item_preserves_meta(): void { + wp_set_current_user( 1 ); + $server = $this->makeServer( array(), array( 'test/resource' ) ); + $handler = new ResourcesHandler( $server ); + + $filter = static function () { + return array( + array( + 'mimeType' => 'application/pdf', + 'blob' => 'ZGF0YQ==', + '_meta' => array( 'pages' => 3 ), + ), + ); + }; + add_filter( 'mcp_adapter_resource_read_result', $filter ); + + $result = $handler->read_resource( + array( 'params' => array( 'uri' => 'WordPress://local/resource-1' ) ) + ); + + remove_filter( 'mcp_adapter_resource_read_result', $filter ); + + $this->assertInstanceOf( ReadResourceResult::class, $result ); + + $contents = $result->getContents(); + $this->assertInstanceOf( BlobResourceContents::class, $contents[0] ); + $this->assertSame( array( 'pages' => 3 ), $contents[0]->get_meta() ); + } + + /** + * Only the first item is inspected to decide whether the return is a list of content + * items, so a first item the check does not recognize costs every sibling as well. + */ + public function test_read_resource_with_blob_only_first_item_keeps_its_siblings(): void { + wp_set_current_user( 1 ); + $server = $this->makeServer( array(), array( 'test/resource' ) ); + $handler = new ResourcesHandler( $server ); + + $filter = static function () { + return array( + array( + 'mimeType' => 'application/pdf', + 'blob' => 'ZGF0YQ==', + ), + array( + 'uri' => 'WordPress://local/resource-2', + 'text' => 'sibling', + ), + ); + }; + add_filter( 'mcp_adapter_resource_read_result', $filter ); + + $result = $handler->read_resource( + array( 'params' => array( 'uri' => 'WordPress://local/resource-1' ) ) + ); + + remove_filter( 'mcp_adapter_resource_read_result', $filter ); + + $this->assertInstanceOf( ReadResourceResult::class, $result ); + + $contents = $result->getContents(); + $this->assertCount( 2, $contents ); + $this->assertInstanceOf( BlobResourceContents::class, $contents[0] ); + $this->assertInstanceOf( TextResourceContents::class, $contents[1] ); + $this->assertSame( 'sibling', $contents[1]->getText() ); + } } diff --git a/tests/phpunit/Unit/Handlers/ToolsHandlerCallTest.php b/tests/phpunit/Unit/Handlers/ToolsHandlerCallTest.php index 99302e06..75735010 100644 --- a/tests/phpunit/Unit/Handlers/ToolsHandlerCallTest.php +++ b/tests/phpunit/Unit/Handlers/ToolsHandlerCallTest.php @@ -10,6 +10,7 @@ use WP\McpSchema\Common\Content\DTO\ImageContent; use WP\McpSchema\Common\Content\DTO\TextContent; use WP\McpSchema\Common\JsonRpc\DTO\JSONRPCErrorResponse; +use WP\McpSchema\Common\Protocol\DTO\Annotations; use WP\McpSchema\Common\Protocol\DTO\BlobResourceContents; use WP\McpSchema\Common\Protocol\DTO\EmbeddedResource; use WP\McpSchema\Common\Protocol\DTO\TextResourceContents; @@ -123,6 +124,28 @@ public function test_image_result_is_converted_to_base64_with_mime_type(): void $this->assertNotEmpty( $content[0]->getMimeType() ); } + public function test_image_result_without_results_key_logs_warning_and_falls_through_to_tool_data(): void { + $server = $this->makeServer( array( 'test/image-without-results' ) ); + $handler = new ToolsHandler( $server ); + $result = $handler->call_tool( + array( + 'params' => array( 'name' => 'test-image-without-results' ), + ) + ); + + // The image branch reads `results`, so this payload stays tool data. + $this->assertInstanceOf( CallToolResult::class, $result ); + $content = $result->getContent(); + $this->assertNotEmpty( $content, 'Content array should not be empty' ); + $this->assertInstanceOf( TextContent::class, $content[0] ); + + $messages = array_column( DummyErrorHandler::$logs, 'message' ); + $this->assertContains( + 'Tool result marked type "image" has no "results" key, returning it as tool data', + $messages + ); + } + public function test_embedded_text_resource_result_is_converted_to_embedded_resource_content_block(): void { $server = $this->makeServer( array( 'test/embedded-text-resource' ) ); $handler = new ToolsHandler( $server ); @@ -368,4 +391,603 @@ public function test_call_tool_with_missing_arguments_succeeds(): void { $this->assertInstanceOf( CallToolResult::class, $result ); $this->assertFalse( (bool) $result->getIsError() ); } + + /** + * Runs a tool whose raw result is replaced by the given embedded-resource shape. + * + * @param array $shape The embedded resource result to substitute. + * + * @return \WP\McpSchema\Server\Tools\DTO\CallToolResult|\WP\McpSchema\Common\JsonRpc\DTO\JSONRPCErrorResponse + */ + private function call_tool_returning( array $shape ) { + $server = $this->makeServer( array( 'test/always-allowed' ) ); + $handler = new ToolsHandler( $server ); + + $filter = static function () use ( $shape ) { + return $shape; + }; + add_filter( 'mcp_adapter_tool_call_result', $filter ); + + $result = $handler->call_tool( + array( + 'params' => array( + 'name' => 'test-always-allowed', + 'arguments' => array(), + ), + ), + 1 + ); + + remove_filter( 'mcp_adapter_tool_call_result', $filter ); + + return $result; + } + + public function test_embedded_resource_nested_shape_preserves_meta_on_both_levels(): void { + $result = $this->call_tool_returning( + array( + 'type' => 'resource', + 'annotations' => array( 'audience' => array( 'user' ) ), + '_meta' => array( 'block' => 'level' ), + 'resource' => array( + 'uri' => 'ui://example/app', + 'mimeType' => 'text/html;profile=mcp-app', + 'text' => '', + '_meta' => array( 'ui' => array( 'prefersBorder' => true ) ), + ), + ) + ); + + $this->assertInstanceOf( CallToolResult::class, $result ); + + $content = $result->getContent(); + $this->assertInstanceOf( EmbeddedResource::class, $content[0] ); + + // Outer keys belong to the content block. + $this->assertSame( array( 'block' => 'level' ), $content[0]->get_meta() ); + $this->assertNotNull( $content[0]->getAnnotations() ); + $this->assertSame( array( 'user' ), $content[0]->getAnnotations()->getAudience() ); + + // The nested _meta belongs to the resource contents. + $resource = $content[0]->getResource(); + $this->assertInstanceOf( TextResourceContents::class, $resource ); + $this->assertSame( array( 'ui' => array( 'prefersBorder' => true ) ), $resource->get_meta() ); + } + + public function test_embedded_resource_nested_blob_shape_preserves_meta_on_both_levels(): void { + $result = $this->call_tool_returning( + array( + 'type' => 'resource', + '_meta' => array( 'block' => 'level' ), + 'resource' => array( + 'uri' => 'WordPress://local/tool-embedded-blob', + 'mimeType' => 'application/pdf', + 'blob' => 'ZGF0YQ==', + '_meta' => array( 'pages' => 3 ), + ), + ) + ); + + $this->assertInstanceOf( CallToolResult::class, $result ); + + $content = $result->getContent(); + $this->assertInstanceOf( EmbeddedResource::class, $content[0] ); + $this->assertSame( array( 'block' => 'level' ), $content[0]->get_meta() ); + + $resource = $content[0]->getResource(); + $this->assertInstanceOf( BlobResourceContents::class, $resource ); + $this->assertSame( array( 'pages' => 3 ), $resource->get_meta() ); + } + + public function test_embedded_resource_flat_shape_assigns_meta_to_the_resource_contents(): void { + $result = $this->call_tool_returning( + array( + 'type' => 'resource', + 'uri' => 'ui://example/app', + 'mimeType' => 'text/html;profile=mcp-app', + 'text' => '', + '_meta' => array( 'ui' => array( 'prefersBorder' => true ) ), + ) + ); + + $this->assertInstanceOf( CallToolResult::class, $result ); + + $content = $result->getContent(); + $this->assertInstanceOf( EmbeddedResource::class, $content[0] ); + + // Strip `type` and the flat shape is a ResourceContents literal, so its `_meta` + // describes the resource. The block carries none; the nested form is how a caller + // addresses the block level. + $this->assertNull( $content[0]->get_meta() ); + $this->assertSame( array( 'ui' => array( 'prefersBorder' => true ) ), $content[0]->getResource()->get_meta() ); + } + + public function test_embedded_resource_flat_blob_shape_assigns_meta_to_the_resource_contents(): void { + $result = $this->call_tool_returning( + array( + 'type' => 'resource', + 'uri' => 'WordPress://local/tool-embedded-blob', + 'mimeType' => 'application/pdf', + 'blob' => 'ZGF0YQ==', + '_meta' => array( 'pages' => 3 ), + ) + ); + + $this->assertInstanceOf( CallToolResult::class, $result ); + + $content = $result->getContent(); + $this->assertInstanceOf( EmbeddedResource::class, $content[0] ); + + $this->assertNull( $content[0]->get_meta() ); + $this->assertSame( array( 'pages' => 3 ), $content[0]->getResource()->get_meta() ); + } + + /** + * `annotations` has no ResourceContents field to descend into, so it stays on the + * block while `_meta` moves. Pins that only `_meta` follows the flat form's siblings. + */ + public function test_embedded_resource_flat_shape_keeps_annotations_on_the_content_block(): void { + $result = $this->call_tool_returning( + array( + 'type' => 'resource', + 'uri' => 'ui://example/app', + 'text' => '', + 'annotations' => array( 'audience' => array( 'user' ) ), + '_meta' => array( 'ui' => array( 'prefersBorder' => true ) ), + ) + ); + + $this->assertInstanceOf( CallToolResult::class, $result ); + + $content = $result->getContent(); + $this->assertInstanceOf( EmbeddedResource::class, $content[0] ); + + $this->assertNotNull( $content[0]->getAnnotations() ); + $this->assertSame( array( 'user' ), $content[0]->getAnnotations()->getAudience() ); + $this->assertNull( $content[0]->get_meta() ); + $this->assertSame( array( 'ui' => array( 'prefersBorder' => true ) ), $content[0]->getResource()->get_meta() ); + } + + public function test_embedded_resource_with_invalid_annotations_still_returns_result(): void { + $result = $this->call_tool_returning( + array( + 'type' => 'resource', + 'uri' => 'WordPress://local/tool-embedded-text', + 'text' => 'body', + 'annotations' => array( 'audience' => 'not-an-array' ), + ) + ); + + // Malformed annotations are dropped, not raised: the resource still reaches the client. + $this->assertInstanceOf( CallToolResult::class, $result ); + + $content = $result->getContent(); + $this->assertInstanceOf( EmbeddedResource::class, $content[0] ); + $this->assertNull( $content[0]->getAnnotations() ); + $this->assertSame( 'body', $content[0]->getResource()->getText() ); + } + + public function test_embedded_resource_without_meta_leaves_both_levels_null(): void { + $result = $this->call_tool_returning( + array( + 'type' => 'resource', + 'uri' => 'WordPress://local/tool-embedded-text', + 'text' => 'body', + ) + ); + + $this->assertInstanceOf( CallToolResult::class, $result ); + + $content = $result->getContent(); + $this->assertInstanceOf( EmbeddedResource::class, $content[0] ); + $this->assertNull( $content[0]->get_meta() ); + $this->assertNull( $content[0]->getResource()->get_meta() ); + } + + /** + * Tool-result annotations are content annotations (audience, priority, lastModified), + * not the ToolAnnotations vocabulary the guide documents for tool descriptors. A tool + * reusing the descriptor vocabulary here must not put an empty `annotations` on the + * wire: PHP serializes an empty array as `[]`, and MCP declares annotations an object. + * + * Asserted on the emitted array rather than getAnnotations(), because the DTO getter + * returns a perfectly good all-null object and cannot see the defect. + */ + public function test_embedded_resource_with_tool_annotation_vocabulary_omits_annotations(): void { + $result = $this->call_tool_returning( + array( + 'type' => 'resource', + 'uri' => 'WordPress://local/tool-embedded-text', + 'text' => 'body', + 'annotations' => array( + 'readOnlyHint' => true, + 'openWorldHint' => false, + ), + ) + ); + + $this->assertInstanceOf( CallToolResult::class, $result ); + + $block = $result->getContent()[0]->toArray(); + $this->assertArrayNotHasKey( 'annotations', $block ); + $this->assertStringNotContainsString( '"annotations":[]', (string) wp_json_encode( $block ) ); + } + + /** + * MCP constrains priority to 0.0-1.0. An out-of-range value is rejected by conforming + * clients along with the whole content block, so it must not reach the wire. + */ + public function test_embedded_resource_with_out_of_range_priority_omits_annotations(): void { + $result = $this->call_tool_returning( + array( + 'type' => 'resource', + 'uri' => 'WordPress://local/tool-embedded-text', + 'text' => 'body', + 'annotations' => array( 'priority' => 5 ), + ) + ); + + $this->assertInstanceOf( CallToolResult::class, $result ); + + $block = $result->getContent()[0]->toArray(); + $this->assertArrayNotHasKey( 'annotations', $block ); + $this->assertSame( 'body', $result->getContent()[0]->getResource()->getText() ); + } + + /** + * MCP declares audience as a list of "user" or "assistant". Anything else is rejected + * by conforming clients along with the whole content block. + */ + public function test_embedded_resource_with_unknown_audience_role_omits_annotations(): void { + $result = $this->call_tool_returning( + array( + 'type' => 'resource', + 'uri' => 'WordPress://local/tool-embedded-text', + 'text' => 'body', + 'annotations' => array( 'audience' => array( 'robot' ) ), + ) + ); + + $this->assertInstanceOf( CallToolResult::class, $result ); + $this->assertArrayNotHasKey( 'annotations', $result->getContent()[0]->toArray() ); + } + + /** + * A non-string audience entry must be rejected by validation, before it reaches the + * schema DTO. The DTO casts entries to string, which raises a PHP warning rather than + * throwing, so in production execution continues and the literal "Array" goes on the + * wire. + * + * The emitted shape alone cannot prove this: phpunit.xml.dist sets + * convertWarningsToExceptions, so under test the cast throws and the catch below drops + * the annotations anyway. Both the fixed and the unfixed code emit no annotations here. + * What distinguishes them is which path dropped it, so this asserts the log context + * carries validation errors and not a downstream exception. + */ + public function test_embedded_resource_with_non_string_audience_entry_is_rejected_by_validation(): void { + $result = $this->call_tool_returning( + array( + 'type' => 'resource', + 'uri' => 'WordPress://local/tool-embedded-text', + 'text' => 'body', + 'annotations' => array( 'audience' => array( array( 'nested' ) ) ), + ) + ); + + $this->assertInstanceOf( CallToolResult::class, $result ); + + $block = $result->getContent()[0]->toArray(); + $this->assertArrayNotHasKey( 'annotations', $block ); + $this->assertStringNotContainsString( 'Array', (string) wp_json_encode( $block ) ); + + $dropped = array_values( + array_filter( + DummyErrorHandler::$logs, + static function ( array $entry ): bool { + return 'Invalid annotations in tool result, dropping them' === $entry['message']; + } + ) + ); + + $this->assertCount( 1, $dropped ); + $this->assertArrayHasKey( 'errors', $dropped[0]['context'] ); + $this->assertArrayNotHasKey( 'exception', $dropped[0]['context'] ); + } + + /** + * The guard above must not over-filter: every field MCP's content Annotations models + * still reaches the wire, as a JSON object. + */ + public function test_embedded_resource_with_valid_annotations_emits_them_as_an_object(): void { + $result = $this->call_tool_returning( + array( + 'type' => 'resource', + 'uri' => 'WordPress://local/tool-embedded-text', + 'text' => 'body', + 'annotations' => array( + 'audience' => array( 'user', 'assistant' ), + 'priority' => 0.8, + 'lastModified' => '2025-01-12T15:00:58Z', + ), + ) + ); + + $this->assertInstanceOf( CallToolResult::class, $result ); + + $block = $result->getContent()[0]->toArray(); + $this->assertSame( + array( + 'audience' => array( 'user', 'assistant' ), + 'priority' => 0.8, + 'lastModified' => '2025-01-12T15:00:58Z', + ), + $block['annotations'] + ); + $this->assertStringContainsString( '"annotations":{"audience":', (string) wp_json_encode( $block ) ); + } + + /** + * WordPress hands back numeric values as strings all over the place - get_post_meta() + * and get_option() both do - so a tool computing priority from stored data commonly + * returns "0.5" rather than 0.5. That is a valid priority, and it must reach the wire + * as a JSON number rather than costing the tool its annotations. + */ + public function test_embedded_resource_with_numeric_string_priority_emits_it_as_a_number(): void { + $result = $this->call_tool_returning( + array( + 'type' => 'resource', + 'uri' => 'WordPress://local/tool-embedded-text', + 'text' => 'body', + 'annotations' => array( 'priority' => '0.5' ), + ) + ); + + $this->assertInstanceOf( CallToolResult::class, $result ); + + $block = $result->getContent()[0]->toArray(); + $this->assertSame( array( 'priority' => 0.5 ), $block['annotations'] ); + $this->assertStringContainsString( '"priority":0.5', (string) wp_json_encode( $block ) ); + + $messages = array_column( DummyErrorHandler::$logs, 'message' ); + $this->assertNotContains( 'Invalid annotations in tool result, dropping them', $messages ); + } + + /** + * Without a URI the result is not an embedded resource at all, so it falls through to + * the generic JSON path where no annotations were ever going to be attached. Warning + * that annotations were "dropped" there sends the reader after the wrong problem. + */ + public function test_resource_result_without_uri_does_not_warn_about_annotations(): void { + $result = $this->call_tool_returning( + array( + 'type' => 'resource', + 'text' => 'body', + 'annotations' => array( 'priority' => 5 ), + ) + ); + + $this->assertInstanceOf( CallToolResult::class, $result ); + $this->assertInstanceOf( TextContent::class, $result->getContent()[0] ); + + $messages = array_column( DummyErrorHandler::$logs, 'message' ); + $this->assertNotContains( 'Invalid annotations in tool result, dropping them', $messages ); + } + + /** + * A result filter may hand back an already-built DTO, which is passed through as-is + * rather than re-validated. + */ + public function test_embedded_resource_accepts_an_already_built_annotations_dto(): void { + $result = $this->call_tool_returning( + array( + 'type' => 'resource', + 'uri' => 'WordPress://local/tool-embedded-text', + 'text' => 'body', + 'annotations' => new Annotations( array( 'assistant' ), 0.4 ), + ) + ); + + $this->assertInstanceOf( CallToolResult::class, $result ); + + $block = $result->getContent()[0]->toArray(); + $this->assertSame( + array( + 'audience' => array( 'assistant' ), + 'priority' => 0.4, + ), + $block['annotations'] + ); + } + + /** + * A conforming client strips metadata it does not recognize, so a `_meta` that could + * not be emitted is reported nowhere else. Both levels of an embedded resource log + * separately, because they name different objects. + */ + public function test_embedded_resource_with_list_meta_logs_the_drop_at_each_level(): void { + $result = $this->call_tool_returning( + array( + 'type' => 'resource', + '_meta' => array( 'a', 'b' ), + 'resource' => array( + 'uri' => 'WordPress://local/tool-embedded-text', + 'text' => 'body', + '_meta' => array( 'c', 'd' ), + ), + ) + ); + + $this->assertInstanceOf( CallToolResult::class, $result ); + + $messages = array_column( DummyErrorHandler::$logs, 'message' ); + $this->assertContains( 'Invalid _meta on tool result content block, dropping it', $messages ); + $this->assertContains( 'Invalid _meta on tool result resource contents, dropping it', $messages ); + } + + public function test_embedded_resource_without_meta_does_not_log(): void { + $result = $this->call_tool_returning( + array( + 'type' => 'resource', + 'uri' => 'WordPress://local/tool-embedded-text', + 'text' => 'body', + ) + ); + + $this->assertInstanceOf( CallToolResult::class, $result ); + + $messages = array_column( DummyErrorHandler::$logs, 'message' ); + $this->assertNotContains( 'Invalid _meta on tool result content block, dropping it', $messages ); + $this->assertNotContains( 'Invalid _meta on tool result resource contents, dropping it', $messages ); + } + + /** + * `type` marks an image result as a description of a content block, so its sibling + * `annotations` and `_meta` are the block's, exactly as they are for `type: resource`. + */ + public function test_image_result_emits_valid_annotations_as_an_object(): void { + $result = $this->call_tool_returning( + array( + 'type' => 'image', + 'results' => 'binary', + 'mimeType' => 'image/png', + 'annotations' => array( + 'audience' => array( 'user' ), + 'priority' => 0.8, + ), + ) + ); + + $this->assertInstanceOf( CallToolResult::class, $result ); + + $block = $result->getContent()[0]->toArray(); + $this->assertSame( + array( + 'audience' => array( 'user' ), + 'priority' => 0.8, + ), + $block['annotations'] + ); + } + + public function test_image_result_carries_meta(): void { + $result = $this->call_tool_returning( + array( + 'type' => 'image', + 'results' => 'binary', + 'mimeType' => 'image/png', + '_meta' => array( 'ui' => array( 'prefersBorder' => true ) ), + ) + ); + + $this->assertInstanceOf( CallToolResult::class, $result ); + + $block = $result->getContent()[0]->toArray(); + $this->assertSame( array( 'ui' => array( 'prefersBorder' => true ) ), $block['_meta'] ); + } + + /** + * The image branch routes annotations through the same seam the resource branch uses, + * so an out-of-range value is dropped as a group and logged rather than reaching the + * wire, where a conforming client rejects the whole content block. + */ + public function test_image_result_with_out_of_range_priority_omits_annotations_and_logs(): void { + $result = $this->call_tool_returning( + array( + 'type' => 'image', + 'results' => 'binary', + 'mimeType' => 'image/png', + 'annotations' => array( 'priority' => 5 ), + ) + ); + + $this->assertInstanceOf( CallToolResult::class, $result ); + + $block = $result->getContent()[0]->toArray(); + $this->assertArrayNotHasKey( 'annotations', $block ); + $this->assertNotEmpty( $block['data'] ); + + $dropped = array_values( + array_filter( + DummyErrorHandler::$logs, + static function ( array $entry ): bool { + return 'Invalid annotations in tool result, dropping them' === $entry['message']; + } + ) + ); + + $this->assertCount( 1, $dropped ); + $this->assertArrayHasKey( 'errors', $dropped[0]['context'] ); + } + + /** + * Tool hints describe a tool, not a content block, so the mapper's content vocabulary + * leaves nothing behind and the key is omitted rather than emitted as an empty object. + */ + public function test_image_result_with_tool_annotation_vocabulary_omits_annotations(): void { + $result = $this->call_tool_returning( + array( + 'type' => 'image', + 'results' => 'binary', + 'mimeType' => 'image/png', + 'annotations' => array( 'readOnlyHint' => true ), + ) + ); + + $this->assertInstanceOf( CallToolResult::class, $result ); + + $block = $result->getContent()[0]->toArray(); + $this->assertArrayNotHasKey( 'annotations', $block ); + $this->assertStringNotContainsString( '"annotations":[]', (string) wp_json_encode( $block ) ); + } + + /** + * MCP declares `_meta` an object, so a list is omitted rather than emitted as a JSON + * array, which a conforming client rejects along with the block carrying it. + */ + public function test_image_result_with_list_meta_omits_meta_and_logs(): void { + $result = $this->call_tool_returning( + array( + 'type' => 'image', + 'results' => 'binary', + 'mimeType' => 'image/png', + '_meta' => array( 'a', 'b' ), + ) + ); + + $this->assertInstanceOf( CallToolResult::class, $result ); + + $block = $result->getContent()[0]->toArray(); + $this->assertArrayNotHasKey( '_meta', $block ); + $this->assertNotEmpty( $block['data'] ); + + $messages = array_column( DummyErrorHandler::$logs, 'message' ); + $this->assertContains( 'Invalid _meta on tool result content block, dropping it', $messages ); + } + + /** + * The generic JSON fallback carries no `type` marker, so every key it holds is tool + * data. `annotations` there is already emitted inside the text block and in + * `structuredContent`, and reading it as a block annotation would give one key two + * meanings with nothing to tell them apart. + */ + public function test_untyped_result_treats_annotations_as_data_not_block_annotations(): void { + $result = $this->call_tool_returning( + array( + 'annotations' => array( 'audience' => array( 'user' ) ), + 'rows' => array( 1, 2 ), + ) + ); + + $this->assertInstanceOf( CallToolResult::class, $result ); + + $block = $result->getContent()[0]->toArray(); + $this->assertArrayNotHasKey( 'annotations', $block ); + $this->assertSame( + array( + 'annotations' => array( 'audience' => array( 'user' ) ), + 'rows' => array( 1, 2 ), + ), + $result->getStructuredContent() + ); + } } diff --git a/tests/phpunit/Unit/Prompts/McpPromptBuilderTest.php b/tests/phpunit/Unit/Prompts/McpPromptBuilderTest.php index 479d1410..bd7ff606 100644 --- a/tests/phpunit/Unit/Prompts/McpPromptBuilderTest.php +++ b/tests/phpunit/Unit/Prompts/McpPromptBuilderTest.php @@ -87,6 +87,21 @@ public function handle( array $arguments ): array { } } +// Test prompt whose _meta is a list, which cannot serialize as a JSON object +class TestPromptWithListMeta extends McpPromptBuilder { + + protected function configure(): void { + $this->name = 'test-prompt-list-meta'; + $this->title = 'Test Prompt List Meta'; + $this->description = 'A test prompt whose _meta is a list'; + $this->set_meta( array( 'first', 'second' ) ); + } + + public function handle( array $arguments ): array { + return array( 'result' => 'success' ); + } +} + // Test prompt with both icons and _meta class TestPromptWithIconsAndMeta extends McpPromptBuilder { @@ -342,6 +357,16 @@ public function test_builder_without_user_meta_has_no_meta_field(): void { $this->assertArrayNotHasKey( '_meta', $arr ); } + public function test_builder_with_list_shaped_meta_omits_meta(): void { + $builder = new TestPromptWithListMeta(); + $prompt = $builder->build(); + + $arr = $prompt->toArray(); + + // A list would serialize as a JSON array, which MCP does not allow for `_meta`. + $this->assertArrayNotHasKey( '_meta', $arr ); + } + public function test_get_meta_returns_configured_meta(): void { $builder = new TestPromptWithMeta(); diff --git a/tests/phpunit/Unit/Prompts/McpPromptTest.php b/tests/phpunit/Unit/Prompts/McpPromptTest.php index e317d944..850e4c7f 100644 --- a/tests/phpunit/Unit/Prompts/McpPromptTest.php +++ b/tests/phpunit/Unit/Prompts/McpPromptTest.php @@ -166,6 +166,57 @@ public function test_fromArray_with_meta(): void { $this->assertSame( array( 'allowed' => true ), $arr['_meta']['mcp_adapter'] ); } + public function test_from_ability_with_list_shaped_meta_omits_meta(): void { + $this->register_ability_in_hook( + 'test/prompt-list-meta', + array( + 'label' => 'Prompt List Meta', + 'description' => 'Test MCP prompt', + 'category' => 'test', + 'input_schema' => array( 'type' => 'object' ), + 'execute_callback' => static function () { + return array( 'messages' => array() ); + }, + 'permission_callback' => static function () { + return true; + }, + 'meta' => array( + 'mcp' => array( + '_meta' => array( 'first', 'second' ), + ), + ), + ) + ); + + $ability = wp_get_ability( 'test/prompt-list-meta' ); + $this->assertNotNull( $ability ); + + $mcp_prompt = McpPrompt::fromAbility( $ability ); + $this->assertNotWPError( $mcp_prompt ); + + $arr = $mcp_prompt->get_protocol_dto()->toArray(); + + // A list would serialize as a JSON array, which MCP does not allow for `_meta`. + $this->assertArrayNotHasKey( '_meta', $arr ); + + wp_unregister_ability( 'test/prompt-list-meta' ); + } + + public function test_fromArray_with_list_shaped_meta_omits_meta(): void { + $prompt = McpPrompt::fromArray( + array( + 'name' => 'list-meta-prompt', + 'handler' => static fn( $args ) => array(), + 'meta' => array( 'first', 'second' ), + ) + ); + + $arr = $prompt->get_protocol_dto()->toArray(); + + // A list would serialize as a JSON array, which MCP does not allow for `_meta`. + $this->assertArrayNotHasKey( '_meta', $arr ); + } + public function test_fromArray_returns_WP_Error_without_name(): void { $result = McpPrompt::fromArray( array( diff --git a/tests/phpunit/Unit/Resources/McpResourceTest.php b/tests/phpunit/Unit/Resources/McpResourceTest.php index 6c2ece9b..8c81ffbb 100644 --- a/tests/phpunit/Unit/Resources/McpResourceTest.php +++ b/tests/phpunit/Unit/Resources/McpResourceTest.php @@ -87,6 +87,60 @@ public function test_fromArray_meta_preserves_all_keys(): void { $this->assertSame( array( 'should_not' => 'leak' ), $arr['_meta']['mcp_adapter'] ); } + public function test_from_ability_with_list_shaped_meta_omits_meta(): void { + $this->register_ability_in_hook( + 'test/resource-list-meta', + array( + 'label' => 'Resource List Meta', + 'description' => 'Test MCP resource', + 'category' => 'test', + 'input_schema' => array( 'type' => 'object' ), + 'execute_callback' => static function () { + return array( 'ok' => true ); + }, + 'permission_callback' => static function () { + return true; + }, + 'meta' => array( + 'mcp' => array( + 'uri' => 'WordPress://local/resource-list-meta', + '_meta' => array( 'first', 'second' ), + ), + ), + ) + ); + + $ability = wp_get_ability( 'test/resource-list-meta' ); + $this->assertNotNull( $ability ); + + $mcp_resource = McpResource::fromAbility( $ability ); + $this->assertNotWPError( $mcp_resource ); + + $arr = $mcp_resource->get_protocol_dto()->toArray(); + + // A list would serialize as a JSON array, which MCP does not allow for `_meta`. + $this->assertArrayNotHasKey( '_meta', $arr ); + + wp_unregister_ability( 'test/resource-list-meta' ); + } + + public function test_fromArray_with_list_shaped_meta_omits_meta(): void { + $mcp_resource = McpResource::fromArray( + array( + 'uri' => 'WordPress://local/list-meta', + 'meta' => array( 'first', 'second' ), + 'handler' => static function ( $args ) { + return $args; + }, + ) + ); + + $arr = $mcp_resource->get_protocol_dto()->toArray(); + + // A list would serialize as a JSON array, which MCP does not allow for `_meta`. + $this->assertArrayNotHasKey( '_meta', $arr ); + } + // ========================================================================= // fromArray Tests // ========================================================================= @@ -255,22 +309,184 @@ public function test_check_permission_catches_exceptions(): void { $this->assertSame( 'Permission check exploded', $result->get_error_message() ); } - public function test_fromArray_returns_wp_error_when_annotations_throw(): void { - // Pass invalid annotations data that causes Annotations::fromArray() to throw. - // The 'priority' field expects a float, not a string. - $result = McpResource::fromArray( + /** + * An unusable annotation value costs the annotation, not the resource. Registration + * failing outright over a rendering hint takes the whole resource off the server, which + * is a far larger outage than the hint was worth. + */ + public function test_fromArray_drops_unparseable_annotation_values_instead_of_failing(): void { + $resource = McpResource::fromArray( array( 'uri' => 'WordPress://local/invalid-annotations', 'handler' => static fn() => 'content', + 'permission' => static fn() => true, 'annotations' => array( - 'priority' => 'not-a-float', // This will cause Annotations::fromArray() to throw. + 'audience' => array( 'user' ), + 'priority' => 'not-a-float', ), ) ); + $this->assertNotWPError( $resource ); + + $data = $resource->get_protocol_dto()->toArray(); + + // The sibling survives; only the unusable field is dropped. + $this->assertSame( array( 'audience' => array( 'user' ) ), $data['annotations'] ); + } + + /** + * get_post_meta() and get_option() return numbers as strings, so a resource built from + * stored data commonly carries "0.5". It is a valid priority and belongs on the wire as + * a JSON number. + */ + public function test_fromArray_coerces_numeric_string_priority_to_a_number(): void { + $resource = McpResource::fromArray( + array( + 'uri' => 'WordPress://local/string-priority', + 'handler' => static fn() => 'content', + 'permission' => static fn() => true, + 'annotations' => array( 'priority' => '0.5' ), + ) + ); + + $this->assertNotWPError( $resource ); + + $data = $resource->get_protocol_dto()->toArray(); + $this->assertSame( array( 'priority' => 0.5 ), $data['annotations'] ); + $this->assertStringContainsString( '"priority":0.5', (string) wp_json_encode( $data ) ); + } + + /** + * Mapping fixes the value's type but says nothing about its range. MCP constrains + * priority to 0.0-1.0 and audience to "user"/"assistant", and a conforming client + * rejects the whole resource over either - so a well-typed but out-of-spec value must + * not reach the wire. + * + * @dataProvider data_out_of_spec_annotations + * + * @param array $annotations Caller-supplied annotations. + */ + public function test_fromArray_omits_annotations_that_are_out_of_spec( array $annotations ): void { + $resource = McpResource::fromArray( + array( + 'uri' => 'WordPress://local/out-of-spec', + 'handler' => static fn() => 'content', + 'permission' => static fn() => true, + 'annotations' => $annotations, + ) + ); + + $this->assertNotWPError( $resource ); + $this->assertArrayNotHasKey( 'annotations', $resource->get_protocol_dto()->toArray() ); + } + + /** + * @return array}> + */ + public function data_out_of_spec_annotations(): array { + return array( + 'priority above range' => array( array( 'priority' => 5 ) ), + 'priority below range' => array( array( 'priority' => -1 ) ), + 'unknown audience' => array( array( 'audience' => array( 'robot' ) ) ), + 'bad lastModified' => array( array( 'lastModified' => 'not-a-timestamp' ) ), + ); + } + + /** + * Coercion is deliberately limited to values whose intent is unambiguous - a number + * written as a string, a boolean written as "1". A field given an entirely wrong kind + * of value has no defensible reading, so the DTO guard still catches it and the caller + * gets a WP_Error naming the problem rather than a silently mangled resource. + */ + public function test_fromArray_returns_wp_error_for_a_wrongly_typed_field(): void { + $result = McpResource::fromArray( + array( + 'uri' => 'WordPress://local/bad-title', + 'handler' => static fn() => 'content', + 'permission' => static fn() => true, + 'title' => array( 'not', 'a', 'string' ), + ) + ); + $this->assertInstanceOf( WP_Error::class, $result ); $this->assertSame( 'mcp_resource_dto_creation_failed', $result->get_error_code() ); - $this->assertStringContainsString( 'Expected float', $result->get_error_message() ); + $this->assertStringContainsString( 'Expected string', $result->get_error_message() ); + } + + /** + * size is the other field the schema asserts a strict type on, and it is a byte count - + * exactly the sort of value that arrives from stored data as "1024", or from arithmetic + * as 1024.0. Both are usable sizes and neither should cost the resource its + * registration. + * + * @dataProvider data_loosely_typed_sizes + * + * @param mixed $size Caller-supplied size value. + */ + public function test_fromArray_coerces_loosely_typed_size_to_an_integer( $size ): void { + $resource = McpResource::fromArray( + array( + 'uri' => 'WordPress://local/loose-size', + 'handler' => static fn() => 'content', + 'permission' => static fn() => true, + 'size' => $size, + ) + ); + + $this->assertNotWPError( $resource ); + $this->assertSame( 1024, $resource->get_protocol_dto()->getSize() ); + } + + /** + * @return array + */ + public function data_loosely_typed_sizes(): array { + return array( + 'integer' => array( 1024 ), + 'numeric string' => array( '1024' ), + 'float' => array( 1024.0 ), + ); + } + + /** + * A size that is not a number at all has no usable value to fall back to, so it is + * dropped rather than guessed at - and still must not fail the registration. + */ + public function test_fromArray_drops_non_numeric_size_instead_of_failing(): void { + $resource = McpResource::fromArray( + array( + 'uri' => 'WordPress://local/bad-size', + 'handler' => static fn() => 'content', + 'permission' => static fn() => true, + 'size' => 'not-a-number', + ) + ); + + $this->assertNotWPError( $resource ); + $this->assertArrayNotHasKey( 'size', $resource->get_protocol_dto()->toArray() ); + } + + /** + * Resources carry the shared Annotations vocabulary. Handing them the tool-descriptor + * hints leaves nothing the type models, and an empty PHP array serializes to `[]` where + * MCP declares an object - which a conforming client rejects along with the resource. + */ + public function test_fromArray_omits_annotations_for_unmodelled_vocabulary(): void { + $resource = McpResource::fromArray( + array( + 'uri' => 'WordPress://local/tool-vocabulary', + 'handler' => static fn() => 'content', + 'permission' => static fn() => true, + 'annotations' => array( 'readOnlyHint' => true ), + ) + ); + + $this->assertNotWPError( $resource ); + + $data = $resource->get_protocol_dto()->toArray(); + $this->assertArrayNotHasKey( 'annotations', $data ); + $this->assertStringNotContainsString( '"annotations":[]', (string) wp_json_encode( $data ) ); } // ========================================================================= diff --git a/tests/phpunit/Unit/Resources/RegisterAbilityAsMcpResourceTest.php b/tests/phpunit/Unit/Resources/RegisterAbilityAsMcpResourceTest.php index 82f2b248..3812d6ad 100644 --- a/tests/phpunit/Unit/Resources/RegisterAbilityAsMcpResourceTest.php +++ b/tests/phpunit/Unit/Resources/RegisterAbilityAsMcpResourceTest.php @@ -212,7 +212,7 @@ public function test_invalid_uri_returns_wp_error(): void { $this->assertSame( 'resource_uri_invalid', $resource->get_error_code() ); } - public function test_invalid_mimetype_is_silently_skipped(): void { + public function test_unusual_mimetype_is_emitted_as_declared(): void { $ability = wp_get_ability( 'test/resource-invalid-mimetype' ); $this->assertNotNull( $ability, 'Ability test/resource-invalid-mimetype should be registered' ); @@ -221,8 +221,9 @@ public function test_invalid_mimetype_is_silently_skipped(): void { $arr = $resource->toArray(); - // mimeType should NOT be present (invalid format was skipped). - $this->assertArrayNotHasKey( 'mimeType', $arr ); + // The descriptor carries whatever the author declared. + $this->assertArrayHasKey( 'mimeType', $arr ); + $this->assertSame( 'not//valid', $arr['mimeType'] ); } public function test_size_field_is_included(): void { diff --git a/tests/phpunit/Unit/Tools/McpToolTest.php b/tests/phpunit/Unit/Tools/McpToolTest.php index 660b70ba..1915fd26 100644 --- a/tests/phpunit/Unit/Tools/McpToolTest.php +++ b/tests/phpunit/Unit/Tools/McpToolTest.php @@ -62,6 +62,74 @@ public function test_fromAbility_builds_mcp_tool_and_preserves_user_meta(): void wp_unregister_ability( 'test/mcptool-from-ability' ); } + public function test_fromAbility_with_list_shaped_meta_omits_meta(): void { + $this->register_ability_in_hook( + 'test/mcptool-list-meta', + array( + 'label' => 'McpTool List Meta', + 'description' => 'Test MCP tool', + 'category' => 'test', + 'input_schema' => array( 'type' => 'object' ), + 'execute_callback' => static function () { + return array( 'ok' => true ); + }, + 'permission_callback' => static function () { + return true; + }, + 'meta' => array( + 'mcp' => array( + '_meta' => array( 'first', 'second' ), + ), + ), + ) + ); + + $ability = wp_get_ability( 'test/mcptool-list-meta' ); + $this->assertNotNull( $ability ); + + $mcp_tool = McpTool::fromAbility( $ability ); + $this->assertNotWPError( $mcp_tool ); + + $data = $mcp_tool->get_protocol_dto()->toArray(); + + // A list would serialize as a JSON array, which MCP does not allow for `_meta`. + $this->assertArrayNotHasKey( '_meta', $data ); + + wp_unregister_ability( 'test/mcptool-list-meta' ); + } + + public function test_fromArray_with_list_shaped_meta_omits_meta(): void { + $mcp_tool = McpTool::fromArray( + array( + 'name' => 'list-meta-tool', + 'meta' => array( 'first', 'second' ), + 'handler' => static fn() => 'ok', + ) + ); + $this->assertNotWPError( $mcp_tool ); + + $data = $mcp_tool->get_protocol_dto()->toArray(); + + $this->assertArrayNotHasKey( '_meta', $data ); + } + + public function test_fromArray_with_numeric_string_keyed_meta_keeps_meta(): void { + $mcp_tool = McpTool::fromArray( + array( + 'name' => 'numeric-key-meta-tool', + 'meta' => array( 1 => 'value' ), + 'handler' => static fn() => 'ok', + ) + ); + $this->assertNotWPError( $mcp_tool ); + + $data = $mcp_tool->get_protocol_dto()->toArray(); + + // Not a list, so it still serializes as the JSON object {"1":"value"}. + $this->assertArrayHasKey( '_meta', $data ); + $this->assertSame( 'value', $data['_meta'][1] ); + } + public function test_execute_unwraps_input_and_wraps_output_when_transformed(): void { $this->register_ability_in_hook( 'test/mcptool-flat-schemas', @@ -488,22 +556,116 @@ public function test_check_permission_catches_exceptions(): void { $this->assertSame( 'Permission check exploded', $result->get_error_message() ); } - public function test_fromArray_returns_wp_error_when_annotations_throw(): void { - // Pass invalid annotations data that causes ToolAnnotations::fromArray() to throw. - // The 'readOnlyHint' field expects a bool, not a string. + /** + * Coercion is deliberately limited to values whose intent is unambiguous - a boolean + * written as "1", a number written as a string. A field given an entirely wrong kind of + * value has no defensible reading, so the DTO guard still catches it and the caller + * gets a WP_Error naming the problem rather than a silently mangled tool. + */ + public function test_fromArray_returns_wp_error_for_a_wrongly_typed_field(): void { $result = McpTool::fromArray( + array( + 'name' => 'bad-title-tool', + 'handler' => static fn( $args ) => array( 'ok' => true ), + 'title' => array( 'not', 'a', 'string' ), + ) + ); + + $this->assertInstanceOf( WP_Error::class, $result ); + $this->assertSame( 'mcp_tool_dto_creation_failed', $result->get_error_code() ); + $this->assertStringContainsString( 'Expected string', $result->get_error_message() ); + } + + /** + * Tool hints are booleans in the schema, but WordPress stores and returns them as "1" + * and "0". A hint written from stored data must still register, and must reach the wire + * as a JSON boolean. + * + * @dataProvider data_loosely_typed_hints + * + * @param mixed $value Caller-supplied hint value. + * @param bool $expected Boolean the hint should normalize to. + */ + public function test_fromArray_coerces_loosely_typed_hints_to_booleans( $value, bool $expected ): void { + $tool = McpTool::fromArray( + array( + 'name' => 'loose-hint-tool', + 'handler' => static fn( $args ) => array( 'ok' => true ), + 'annotations' => array( 'readOnlyHint' => $value ), + ) + ); + + $this->assertNotWPError( $tool ); + + $data = $tool->get_protocol_dto()->toArray(); + $this->assertSame( $expected, $data['annotations']['readOnlyHint'] ); + } + + /** + * @return array + */ + public function data_loosely_typed_hints(): array { + return array( + 'boolean true' => array( true, true ), + 'integer one' => array( 1, true ), + 'string one' => array( '1', true ), + 'string true' => array( 'true', true ), + 'boolean false' => array( false, false ), + 'integer zero' => array( 0, false ), + 'string zero' => array( '0', false ), + 'string false' => array( 'false', false ), + ); + } + + /** + * A hint value with no defensible boolean reading is dropped. Failing registration over + * a rendering hint would take the whole tool off the server, which is a far larger + * outage than the hint was worth. + */ + public function test_fromArray_drops_unusable_hint_values_instead_of_failing(): void { + $tool = McpTool::fromArray( array( 'name' => 'invalid-annotations-tool', 'handler' => static fn( $args ) => array( 'ok' => true ), 'annotations' => array( - 'readOnlyHint' => 'not-a-boolean', // This will cause ToolAnnotations::fromArray() to throw. + 'readOnlyHint' => 'not-a-boolean', + 'idempotentHint' => true, ), ) ); - $this->assertInstanceOf( WP_Error::class, $result ); - $this->assertSame( 'mcp_tool_dto_creation_failed', $result->get_error_code() ); - $this->assertStringContainsString( 'Expected bool', $result->get_error_message() ); + $this->assertNotWPError( $tool ); + + $data = $tool->get_protocol_dto()->toArray(); + + // The usable sibling survives; only the unreadable hint is dropped. + $this->assertArrayNotHasKey( 'readOnlyHint', $data['annotations'] ); + $this->assertTrue( $data['annotations']['idempotentHint'] ); + } + + /** + * Tools carry ToolAnnotations, which does not model the shared content vocabulary. + * Handing it those fields leaves nothing behind, and an empty PHP array serializes to + * `[]` where MCP declares an object - which a conforming client rejects along with the + * whole tool. + */ + public function test_fromArray_omits_annotations_for_unmodelled_vocabulary(): void { + $tool = McpTool::fromArray( + array( + 'name' => 'resource-vocabulary-tool', + 'handler' => static fn( $args ) => array( 'ok' => true ), + 'annotations' => array( + 'audience' => array( 'user' ), + 'priority' => 0.8, + ), + ) + ); + + $this->assertNotWPError( $tool ); + + $data = $tool->get_protocol_dto()->toArray(); + $this->assertArrayNotHasKey( 'annotations', $data ); + $this->assertStringNotContainsString( '"annotations":[]', (string) wp_json_encode( $data ) ); } // =========================================================================