From e04b4bb2ed3db0d518bb12b9327c2e4e1ca9e9d6 Mon Sep 17 00:00:00 2001 From: Ovidiu Galatan Date: Mon, 27 Jul 2026 23:55:27 +0300 Subject: [PATCH 01/22] fix: carry _meta through resource contents and embedded resources `resources/read` and the `tools/call` embedded-resource path build their DTOs from the item a handler returns. Both now copy `_meta` from that item onto the DTO, and the tools path also copies `annotations` onto the content block. `ContentBlockHelper::embedded_text_resource()` and `embedded_blob_resource()` take a trailing `resource_meta` argument. The DTO tree has two levels that each carry `_meta`, the content block and the resource contents nested inside it, and the existing `_meta` argument sets the block's. A tool may write the embedded resource nested, with the resource fields under a `resource` key, or flat, with them inlined. The nested form maps onto both levels, so its outer keys belong to the block and its inner `_meta` to the contents. The flat form is the block itself and has a single level for `_meta` to mean, so it belongs to the block; reading it into the contents as well would put the same metadata on both levels of one response. `McpValidator::normalize_meta()` returns a `_meta` value only when it serializes as a JSON object, which is what MCP declares the field to be. PHP represents a JSON object and a JSON array with one type, so an empty or sequential array reaches the wire as `[]`. Such values are treated as absent, as are non-arrays and malformed `annotations`, so that metadata never costs a client the payload it accompanies. `array_is_list()` requires PHP 8.1 and this package supports 7.4, hence the key comparison. Fixes #245. --- docs/guides/creating-abilities.md | 39 +++++ includes/Domain/Utils/ContentBlockHelper.php | 24 ++- includes/Domain/Utils/McpValidator.php | 30 ++++ .../Handlers/Resources/ResourcesHandler.php | 12 ++ includes/Handlers/Tools/ToolsHandler.php | 69 ++++++++- .../Domain/Utils/ContentBlockHelperTest.php | 48 ++++++ .../Unit/Domain/Utils/McpValidatorTest.php | 50 ++++++ .../Handlers/ResourcesHandlerReadTest.php | 138 +++++++++++++++++ .../Unit/Handlers/ToolsHandlerCallTest.php | 145 ++++++++++++++++++ 9 files changed, 549 insertions(+), 6 deletions(-) diff --git a/docs/guides/creating-abilities.md b/docs/guides/creating-abilities.md index 0e53b79b..1cf972b5 100644 --- a/docs/guides/creating-abilities.md +++ b/docs/guides/creating-abilities.md @@ -671,6 +671,45 @@ wp_register_ability('my-plugin/site-config', [ ]); ``` +### 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. + +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. Anything else is dropped, and the rest of the content item 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. + +Tools can return the same contents embedded in a `resource` content block. The nested form keeps the two `_meta` levels distinct — the outer one belongs to the content block, the inner one to the resource contents: + +```php +return [ + 'type' => 'resource', + '_meta' => ['block' => 'level'], + 'resource' => [ + 'uri' => 'ui://my-plugin/app', + 'text' => '...', + '_meta' => ['ui' => ['prefersBorder' => true]], + ], +]; +``` + +The flat form (`['type' => 'resource', 'uri' => ..., 'text' => ..., '_meta' => ...]`) has only one level, so its `_meta` belongs to the content block. + ## 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. diff --git a/includes/Domain/Utils/ContentBlockHelper.php b/includes/Domain/Utils/ContentBlockHelper.php index d598e8f2..14dfd12d 100644 --- a/includes/Domain/Utils/ContentBlockHelper.php +++ b/includes/Domain/Utils/ContentBlockHelper.php @@ -82,11 +82,17 @@ 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. + * * @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 +101,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' => $resource_meta, ) ); @@ -120,11 +128,17 @@ 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. + * * @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 +147,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' => $resource_meta, ) ); diff --git a/includes/Domain/Utils/McpValidator.php b/includes/Domain/Utils/McpValidator.php index 0192a809..77b3cb7a 100644 --- a/includes/Domain/Utils/McpValidator.php +++ b/includes/Domain/Utils/McpValidator.php @@ -501,6 +501,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 The value if it serializes as a JSON object, null otherwise. + */ + 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. * diff --git a/includes/Handlers/Resources/ResourcesHandler.php b/includes/Handlers/Resources/ResourcesHandler.php index c49e5916..717388ca 100644 --- a/includes/Handlers/Resources/ResourcesHandler.php +++ b/includes/Handlers/Resources/ResourcesHandler.php @@ -10,6 +10,7 @@ namespace WP\MCP\Handlers\Resources; use WP\MCP\Core\McpServer; +use WP\MCP\Domain\Utils\McpValidator; use WP\MCP\Handlers\HandlerHelperTrait; use WP\MCP\Infrastructure\ErrorHandling\McpErrorFactory; use WP\McpSchema\Common\Protocol\DTO\BlobResourceContents; @@ -270,6 +271,14 @@ function ( $item ) use ( $uri ) { /** * Create a content DTO from an array item. * + * `_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. + * See {@see McpValidator::normalize_meta()}. + * * @param array $item The content item array. * @param string $default_uri The default URI to use if not specified. * @@ -278,6 +287,7 @@ function ( $item ) use ( $uri ) { private function create_content_dto( array $item, string $default_uri ) { $item_uri = $item['uri'] ?? $default_uri; $mime_type = $item['mimeType'] ?? null; + $meta = McpValidator::normalize_meta( $item['_meta'] ?? null ); // If there's blob data, create BlobResourceContents. if ( isset( $item['blob'] ) ) { @@ -286,6 +296,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 +309,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..11e2b357 100644 --- a/includes/Handlers/Tools/ToolsHandler.php +++ b/includes/Handlers/Tools/ToolsHandler.php @@ -11,9 +11,11 @@ use WP\MCP\Core\McpServer; use WP\MCP\Domain\Utils\ContentBlockHelper; +use WP\MCP\Domain\Utils\McpValidator; use WP\MCP\Handlers\HandlerHelperTrait; use WP\MCP\Infrastructure\ErrorHandling\McpErrorFactory; use WP\MCP\Infrastructure\Observability\FailureReason; +use WP\McpSchema\Common\Protocol\DTO\Annotations; use WP\McpSchema\Server\Tools\DTO\CallToolResult; use WP\McpSchema\Server\Tools\DTO\ListToolsResult; @@ -234,6 +236,16 @@ 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, text, _meta }` is the content block itself, written with + // its resource fields inlined. There is only one level for `_meta` to mean, so + // it belongs to the block. Reading it into the contents as well would duplicate + // the same metadata on both levels of the response. if ( isset( $result['type'] ) && 'resource' === $result['type'] ) { $resource_item = $result; if ( isset( $result['resource'] ) && is_array( $result['resource'] ) ) { @@ -247,6 +259,13 @@ public function call_tool( array $params, $request_id = 0 ) { $uri = trim( $uri ); } + $block_meta = McpValidator::normalize_meta( $result['_meta'] ?? null ); + $resource_meta = $resource_item !== $result + ? McpValidator::normalize_meta( $resource_item['_meta'] ?? null ) + : null; + + $annotations = $this->build_annotations( $result['annotations'] ?? null, $tool_name ); + // Only return an EmbeddedResource if we have a valid URI and some content. if ( is_string( $uri ) && '' !== $uri ) { if ( isset( $resource_item['text'] ) && is_string( $resource_item['text'] ) ) { @@ -256,7 +275,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 +293,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, @@ -321,6 +346,46 @@ public function call_tool( array $params, $request_id = 0 ) { } } + /** + * Build an Annotations DTO from a tool result's `annotations` key. + * + * Malformed annotations are logged and dropped rather than raised. Annotations are + * a rendering hint, and before they were read here a tool returning a malformed one + * still got its result delivered; failing the whole call now would be a regression + * for tools whose output is otherwise valid. + * + * @since n.e.x.t + * + * @param mixed $annotations The raw annotations value from the tool result. + * @param string $tool_name Tool name for logging. + * + * @return \WP\McpSchema\Common\Protocol\DTO\Annotations|null + */ + private function build_annotations( $annotations, string $tool_name ): ?Annotations { + if ( $annotations instanceof Annotations ) { + return $annotations; + } + + if ( ! is_array( $annotations ) ) { + return null; + } + + try { + return Annotations::fromArray( $annotations ); + } catch ( \Throwable $exception ) { + $this->mcp->get_error_handler()->log( + 'Invalid annotations in tool result, dropping them', + array( + 'tool_name' => $tool_name, + 'exception' => $exception->getMessage(), + ), + 'warning' + ); + + return null; + } + } + /** * Create an error CallToolResult from a message string. * diff --git a/tests/phpunit/Unit/Domain/Utils/ContentBlockHelperTest.php b/tests/phpunit/Unit/Domain/Utils/ContentBlockHelperTest.php index 808c5f09..b21cad0d 100644 --- a/tests/phpunit/Unit/Domain/Utils/ContentBlockHelperTest.php +++ b/tests/phpunit/Unit/Domain/Utils/ContentBlockHelperTest.php @@ -230,6 +230,54 @@ 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 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..461c1cb0 100644 --- a/tests/phpunit/Unit/Domain/Utils/McpValidatorTest.php +++ b/tests/phpunit/Unit/Domain/Utils/McpValidatorTest.php @@ -966,4 +966,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 provide_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 provide_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/ResourcesHandlerReadTest.php b/tests/phpunit/Unit/Handlers/ResourcesHandlerReadTest.php index f91ff746..57ae3216 100644 --- a/tests/phpunit/Unit/Handlers/ResourcesHandlerReadTest.php +++ b/tests/phpunit/Unit/Handlers/ResourcesHandlerReadTest.php @@ -327,4 +327,142 @@ 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() ); + } } diff --git a/tests/phpunit/Unit/Handlers/ToolsHandlerCallTest.php b/tests/phpunit/Unit/Handlers/ToolsHandlerCallTest.php index 99302e06..109de536 100644 --- a/tests/phpunit/Unit/Handlers/ToolsHandlerCallTest.php +++ b/tests/phpunit/Unit/Handlers/ToolsHandlerCallTest.php @@ -368,4 +368,149 @@ 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_content_block_only(): 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] ); + + // The flat shape has one level, so _meta lands on the block and is not duplicated + // onto the nested contents. + $this->assertSame( array( 'ui' => array( 'prefersBorder' => true ) ), $content[0]->get_meta() ); + $this->assertNull( $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() ); + } } From e63e4ce7587836a7dfbd1390117a4462a9be6a28 Mon Sep 17 00:00:00 2001 From: Ovidiu Galatan Date: Tue, 28 Jul 2026 00:22:36 +0300 Subject: [PATCH 02/22] fix: normalize _meta on component descriptors and content blocks Every `_meta` the adapter writes into a protocol DTO passes through `McpValidator::normalize_meta()`, which yields a value only when it serializes as a JSON object. PHP represents a JSON object and a JSON array with one type, so a plain array check admits a list, and a list reaches the wire as a JSON array. The seven descriptor sites this covers build the Tool, Resource and Prompt DTOs from an ability's `mcp._meta`, from a `fromArray()` config, and from the prompt builder's own `_meta`. `ContentBlockHelper` normalizes each `_meta` argument it takes, both on the content block and on the resource contents nested inside an embedded resource. Its method signatures are unchanged. MCP declares `_meta` an object, and a client validates it as part of the response that carries it, so a non-object there costs the whole payload rather than only the metadata it accompanies. --- docs/guides/creating-abilities.md | 2 +- includes/Domain/Prompts/McpPrompt.php | 5 +- includes/Domain/Prompts/McpPromptBuilder.php | 5 +- .../Prompts/RegisterAbilityAsMcpPrompt.php | 7 +- includes/Domain/Resources/McpResource.php | 5 +- .../RegisterAbilityAsMcpResource.php | 7 +- includes/Domain/Tools/McpTool.php | 7 +- .../Domain/Tools/RegisterAbilityAsMcpTool.php | 7 +- includes/Domain/Utils/ContentBlockHelper.php | 20 ++++-- .../Domain/Utils/ContentBlockHelperTest.php | 63 +++++++++++++++++ .../Unit/Prompts/McpPromptBuilderTest.php | 25 +++++++ tests/phpunit/Unit/Prompts/McpPromptTest.php | 51 ++++++++++++++ .../Unit/Resources/McpResourceTest.php | 54 +++++++++++++++ tests/phpunit/Unit/Tools/McpToolTest.php | 68 +++++++++++++++++++ 14 files changed, 294 insertions(+), 32 deletions(-) diff --git a/docs/guides/creating-abilities.md b/docs/guides/creating-abilities.md index 1cf972b5..836c9f02 100644 --- a/docs/guides/creating-abilities.md +++ b/docs/guides/creating-abilities.md @@ -692,7 +692,7 @@ The example above returns a plain array, which the adapter JSON-encodes into a s `_meta` travels with the resource but is not part of its body. MCP Apps UI resources use it for CSP config and rendering hints. -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. Anything else is dropped, and the rest of the content item 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. +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. Tools can return the same contents embedded in a `resource` content block. The nested form keeps the two `_meta` levels distinct — the outer one belongs to the content block, the inner one to the resource contents: 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..32a42d31 100644 --- a/includes/Domain/Prompts/McpPromptBuilder.php +++ b/includes/Domain/Prompts/McpPromptBuilder.php @@ -163,8 +163,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. diff --git a/includes/Domain/Prompts/RegisterAbilityAsMcpPrompt.php b/includes/Domain/Prompts/RegisterAbilityAsMcpPrompt.php index 4e200406..fdcf7ad4 100644 --- a/includes/Domain/Prompts/RegisterAbilityAsMcpPrompt.php +++ b/includes/Domain/Prompts/RegisterAbilityAsMcpPrompt.php @@ -182,11 +182,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..a49c5841 100644 --- a/includes/Domain/Resources/McpResource.php +++ b/includes/Domain/Resources/McpResource.php @@ -170,8 +170,9 @@ 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. diff --git a/includes/Domain/Resources/RegisterAbilityAsMcpResource.php b/includes/Domain/Resources/RegisterAbilityAsMcpResource.php index cf504dbf..dcdd78ee 100644 --- a/includes/Domain/Resources/RegisterAbilityAsMcpResource.php +++ b/includes/Domain/Resources/RegisterAbilityAsMcpResource.php @@ -207,11 +207,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..b69f7eb5 100644 --- a/includes/Domain/Tools/McpTool.php +++ b/includes/Domain/Tools/McpTool.php @@ -162,9 +162,10 @@ 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. 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 14dfd12d..37568b0f 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 { @@ -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 ), ) ); } @@ -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 ), ) ); } @@ -109,7 +115,7 @@ public static function embedded_text_resource( 'uri' => $uri, 'text' => $text, 'mimeType' => $mime_type, - '_meta' => $resource_meta, + '_meta' => McpValidator::normalize_meta( $resource_meta ), ) ); @@ -118,7 +124,7 @@ public static function embedded_text_resource( 'type' => EmbeddedResource::TYPE, 'resource' => $resource, 'annotations' => $annotations, - '_meta' => $_meta, + '_meta' => McpValidator::normalize_meta( $_meta ), ) ); } @@ -155,7 +161,7 @@ public static function embedded_blob_resource( 'uri' => $uri, 'blob' => $blob, 'mimeType' => $mime_type, - '_meta' => $resource_meta, + '_meta' => McpValidator::normalize_meta( $resource_meta ), ) ); @@ -164,7 +170,7 @@ public static function embedded_blob_resource( 'type' => EmbeddedResource::TYPE, 'resource' => $resource, 'annotations' => $annotations, - '_meta' => $_meta, + '_meta' => McpValidator::normalize_meta( $_meta ), ) ); } @@ -200,7 +206,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 ), ) ); } diff --git a/tests/phpunit/Unit/Domain/Utils/ContentBlockHelperTest.php b/tests/phpunit/Unit/Domain/Utils/ContentBlockHelperTest.php index b21cad0d..e71e3846 100644 --- a/tests/phpunit/Unit/Domain/Utils/ContentBlockHelperTest.php +++ b/tests/phpunit/Unit/Domain/Utils/ContentBlockHelperTest.php @@ -278,6 +278,69 @@ public function test_embedded_text_resource_without_resource_meta_leaves_content $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/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..da2acebf 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 // ========================================================================= diff --git a/tests/phpunit/Unit/Tools/McpToolTest.php b/tests/phpunit/Unit/Tools/McpToolTest.php index 660b70ba..2e23f433 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', From bda672624b1f1be5c8e34ef8e6c4754ea912b563 Mon Sep 17 00:00:00 2001 From: Ovidiu Galatan Date: Tue, 28 Jul 2026 08:30:07 +0300 Subject: [PATCH 03/22] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- includes/Domain/Utils/McpValidator.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/includes/Domain/Utils/McpValidator.php b/includes/Domain/Utils/McpValidator.php index 77b3cb7a..1d0e6907 100644 --- a/includes/Domain/Utils/McpValidator.php +++ b/includes/Domain/Utils/McpValidator.php @@ -516,7 +516,7 @@ public static function validate_priority( $priority ): bool { * * @param mixed $meta The raw `_meta` value. * - * @return array|null The value if it serializes as a JSON object, null otherwise. + * @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 ) { From 07db0c6d55a06210eb16256128ab5220804881ad Mon Sep 17 00:00:00 2001 From: Ovidiu Galatan Date: Tue, 28 Jul 2026 09:59:36 +0300 Subject: [PATCH 04/22] fix: validate tool result annotations before emitting them Tool results carry content annotations (audience, priority, lastModified), not the ToolAnnotations vocabulary used on tool descriptors. Annotations are validated as part of the content block that carries them, so a value the schema rejects costs the whole block rather than only itself. build_annotations() runs McpValidator::get_annotation_validation_errors() before constructing the DTO and drops the annotations when it reports errors. It also omits the field when the resulting DTO holds no values, since an annotations object with nothing in it serializes to a JSON array where MCP declares an object. Both cases are logged, so the rest of the result still reaches the client. The call sits inside the URI guard: without a URI the result is not an embedded resource and falls through to the generic JSON path, where no annotations are attached. --- includes/Handlers/Tools/ToolsHandler.php | 48 ++++- .../Unit/Handlers/ToolsHandlerCallTest.php | 190 ++++++++++++++++++ 2 files changed, 231 insertions(+), 7 deletions(-) diff --git a/includes/Handlers/Tools/ToolsHandler.php b/includes/Handlers/Tools/ToolsHandler.php index 11e2b357..7ca980ca 100644 --- a/includes/Handlers/Tools/ToolsHandler.php +++ b/includes/Handlers/Tools/ToolsHandler.php @@ -264,10 +264,13 @@ public function call_tool( array $params, $request_id = 0 ) { ? McpValidator::normalize_meta( $resource_item['_meta'] ?? null ) : null; - $annotations = $this->build_annotations( $result['annotations'] ?? null, $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_annotations( $result['annotations'] ?? null, $tool_name ); + if ( isset( $resource_item['text'] ) && is_string( $resource_item['text'] ) ) { return CallToolResult::fromArray( array( @@ -349,10 +352,18 @@ public function call_tool( array $params, $request_id = 0 ) { /** * Build an Annotations DTO from a tool result's `annotations` key. * - * Malformed annotations are logged and dropped rather than raised. Annotations are - * a rendering hint, and before they were read here a tool returning a malformed one - * still got its result delivered; failing the whole call now would be a regression - * for tools whose output is otherwise valid. + * Tool results carry *content* annotations (`audience`, `priority`, `lastModified`), + * not the ToolAnnotations vocabulary used on tool descriptors. Anything a conforming + * client would reject is dropped here, because it is validated as part of the content + * block that carries it, 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 before + * they were read here a tool returning a malformed one still got its result delivered; + * failing the whole call now would be a regression for tools whose output is otherwise + * valid. * * @since n.e.x.t * @@ -370,8 +381,31 @@ private function build_annotations( $annotations, string $tool_name ): ?Annotati return null; } + // Tool 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( $annotations ); + if ( ! empty( $errors ) ) { + $this->mcp->get_error_handler()->log( + 'Invalid annotations in tool result, dropping them', + array( + 'tool_name' => $tool_name, + 'errors' => $errors, + ), + 'warning' + ); + + return null; + } + + // Validation above should leave nothing for fromArray() to reject, but it lives in a + // separately versioned package, so the guard stays. try { - return Annotations::fromArray( $annotations ); + $dto = Annotations::fromArray( $annotations ); + + // Vocabulary the content Annotations type does not model - most likely the tool + // hints, which belong on the descriptor - leaves an all-null DTO behind. That + // serializes to `[]`, so omit the field rather than emit a JSON array. + return array() === $dto->toArray() ? null : $dto; } catch ( \Throwable $exception ) { $this->mcp->get_error_handler()->log( 'Invalid annotations in tool result, dropping them', diff --git a/tests/phpunit/Unit/Handlers/ToolsHandlerCallTest.php b/tests/phpunit/Unit/Handlers/ToolsHandlerCallTest.php index 109de536..7bdec944 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; @@ -513,4 +514,193 @@ public function test_embedded_resource_without_meta_leaves_both_levels_null(): v $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 ) ); + } + + /** + * 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'] + ); + } } From 32021f856b8249a22c09818cc3f1e7940bd63c59 Mon Sep 17 00:00:00 2001 From: Ovidiu Galatan Date: Tue, 28 Jul 2026 10:44:03 +0300 Subject: [PATCH 05/22] fix: normalize annotation and size values before building DTOs The schema DTOs assert strict PHP types: `Annotations::priority` requires a float, `Resource::size` an int, and each `ToolAnnotations` hint a bool. Values reaching the adapter's factories carry whatever type the caller holds, and WordPress returns stored scalars as strings. `McpTool::fromArray()`, `McpResource::fromArray()` and `ToolsHandler::build_annotations()` route `annotations` through `McpAnnotationMapper::map()`, which keeps only the fields the target type models and coerces each value to that field's declared type. `McpResource` casts `size` at its guard and validates the mapped annotations against the ranges MCP defines, dropping them when a value falls outside. Coercion covers values whose intent is unambiguous: a number written as a string, a boolean written as "1", "true", "0" or "false". Any other value for those fields is dropped, and a field the DTO cannot accept at all returns a WP_Error naming it. Mapping filters the vocabulary that yields an empty DTO, and mapping together with validation leaves no value for `Annotations::fromArray()` to reject, so `build_annotations()` returns the DTO directly and relies on the tool-call boundary for exception handling. --- includes/Domain/Resources/McpResource.php | 36 +++- includes/Domain/Tools/McpTool.php | 19 +- includes/Handlers/Tools/ToolsHandler.php | 39 ++-- .../Unit/Handlers/ToolsHandlerCallTest.php | 26 +++ .../Unit/Resources/McpResourceTest.php | 174 +++++++++++++++++- tests/phpunit/Unit/Tools/McpToolTest.php | 108 ++++++++++- 6 files changed, 356 insertions(+), 46 deletions(-) diff --git a/includes/Domain/Resources/McpResource.php b/includes/Domain/Resources/McpResource.php index a49c5841..55df7161 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; @@ -157,9 +158,11 @@ public static function fromArray( array $config ) { } } - // 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. @@ -175,11 +178,30 @@ public static function fromArray( array $config ) { $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/Tools/McpTool.php b/includes/Domain/Tools/McpTool.php index b69f7eb5..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; @@ -168,11 +169,21 @@ public static function fromArray( array $config ) { $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/Handlers/Tools/ToolsHandler.php b/includes/Handlers/Tools/ToolsHandler.php index 7ca980ca..5ff933e9 100644 --- a/includes/Handlers/Tools/ToolsHandler.php +++ b/includes/Handlers/Tools/ToolsHandler.php @@ -11,6 +11,7 @@ use WP\MCP\Core\McpServer; use WP\MCP\Domain\Utils\ContentBlockHelper; +use WP\MCP\Domain\Utils\McpAnnotationMapper; use WP\MCP\Domain\Utils\McpValidator; use WP\MCP\Handlers\HandlerHelperTrait; use WP\MCP\Infrastructure\ErrorHandling\McpErrorFactory; @@ -381,9 +382,21 @@ private function build_annotations( $annotations, string $tool_name ): ?Annotati return null; } + // The mapper is the single seam where raw annotation input becomes DTO-ready: it + // keeps only the fields the content 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 `[]` where MCP declares an object. 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; + } + // Tool 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( $annotations ); + $errors = McpValidator::get_annotation_validation_errors( $mapped ); if ( ! empty( $errors ) ) { $this->mcp->get_error_handler()->log( 'Invalid annotations in tool result, dropping them', @@ -397,27 +410,9 @@ private function build_annotations( $annotations, string $tool_name ): ?Annotati return null; } - // Validation above should leave nothing for fromArray() to reject, but it lives in a - // separately versioned package, so the guard stays. - try { - $dto = Annotations::fromArray( $annotations ); - - // Vocabulary the content Annotations type does not model - most likely the tool - // hints, which belong on the descriptor - leaves an all-null DTO behind. That - // serializes to `[]`, so omit the field rather than emit a JSON array. - return array() === $dto->toArray() ? null : $dto; - } catch ( \Throwable $exception ) { - $this->mcp->get_error_handler()->log( - 'Invalid annotations in tool result, dropping them', - array( - 'tool_name' => $tool_name, - 'exception' => $exception->getMessage(), - ), - 'warning' - ); - - return null; - } + // Mapping and validation between them leave nothing for fromArray() to reject, so + // there is no local catch here. The tool-call boundary still wraps this method. + return Annotations::fromArray( $mapped ); } /** diff --git a/tests/phpunit/Unit/Handlers/ToolsHandlerCallTest.php b/tests/phpunit/Unit/Handlers/ToolsHandlerCallTest.php index 7bdec944..3aa3dcdf 100644 --- a/tests/phpunit/Unit/Handlers/ToolsHandlerCallTest.php +++ b/tests/phpunit/Unit/Handlers/ToolsHandlerCallTest.php @@ -657,6 +657,32 @@ public function test_embedded_resource_with_valid_annotations_emits_them_as_an_o $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 diff --git a/tests/phpunit/Unit/Resources/McpResourceTest.php b/tests/phpunit/Unit/Resources/McpResourceTest.php index da2acebf..8c81ffbb 100644 --- a/tests/phpunit/Unit/Resources/McpResourceTest.php +++ b/tests/phpunit/Unit/Resources/McpResourceTest.php @@ -309,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/Tools/McpToolTest.php b/tests/phpunit/Unit/Tools/McpToolTest.php index 2e23f433..1915fd26 100644 --- a/tests/phpunit/Unit/Tools/McpToolTest.php +++ b/tests/phpunit/Unit/Tools/McpToolTest.php @@ -556,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 ) ); } // ========================================================================= From d7fb9cc5389e3eaeaead3f484ab96afa622f3dc4 Mon Sep 17 00:00:00 2001 From: Ovidiu Galatan Date: Tue, 28 Jul 2026 11:03:13 +0300 Subject: [PATCH 06/22] fix: normalize prompt message content blocks before building DTOs Prompt messages carry the same content blocks tool results do and reach the wire through the same DTOs, so they take the same normalization. `normalize_content_block()` runs `_meta` through `McpValidator::normalize_meta()`, routes `annotations` through `McpAnnotationMapper::map()` and `McpValidator::get_annotation_validation_errors()`, and casts a `resource_link` `size` to the int the schema declares. It covers the message tiers and the text shorthand, which builds its own block from the caller's `annotations`. `EmbeddedResource::fromArray()` takes its resource contents as given, so no DTO inspects the `_meta` nested inside an embedded resource. The handler normalizes that level too. `get_prompt()` wraps normalization in a `\Throwable` catch that returns an internal error, and the schema DTOs throw on a value they cannot accept, so a `_meta` that is not an array, a priority written as "0.5" or a size written as "1024" is resolved or dropped here rather than costing the message. Annotation mapping and validation live in `HandlerHelperTrait::build_content_annotations()`, shared with the tool-result path. The log message and context are arguments, so each handler keeps its own wording. --- includes/Handlers/HandlerHelperTrait.php | 70 +++ includes/Handlers/Prompts/PromptsHandler.php | 84 +++- includes/Handlers/Tools/ToolsHandler.php | 74 +--- .../Unit/Handlers/PromptsHandlerTest.php | 413 ++++++++++++++++++ 4 files changed, 569 insertions(+), 72 deletions(-) diff --git a/includes/Handlers/HandlerHelperTrait.php b/includes/Handlers/HandlerHelperTrait.php index 74dc041d..6b0c3f60 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,71 @@ 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 ); + } } diff --git a/includes/Handlers/Prompts/PromptsHandler.php b/includes/Handlers/Prompts/PromptsHandler.php index 091aacfe..8ef7e4af 100644 --- a/includes/Handlers/Prompts/PromptsHandler.php +++ b/includes/Handlers/Prompts/PromptsHandler.php @@ -10,6 +10,7 @@ namespace WP\MCP\Handlers\Prompts; use WP\MCP\Core\McpServer; +use WP\MCP\Domain\Utils\McpValidator; use WP\MCP\Handlers\HandlerHelperTrait; use WP\MCP\Infrastructure\ErrorHandling\McpErrorFactory; use WP\McpSchema\Server\Prompts\DTO\GetPromptResult; @@ -241,7 +242,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 +322,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 +339,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,6 +516,7 @@ 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( @@ -521,6 +526,77 @@ private function validate_and_create_message( array $message, string $prompt_nam ); } + /** + * 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 = McpValidator::normalize_meta( $content['_meta'] ?? null ); + 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 its `_meta`. This is the only level that + // normalizes it. + if ( isset( $content['resource'] ) && is_array( $content['resource'] ) ) { + $resource = $content['resource']; + $resource_meta = McpValidator::normalize_meta( $resource['_meta'] ?? null ); + 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. * diff --git a/includes/Handlers/Tools/ToolsHandler.php b/includes/Handlers/Tools/ToolsHandler.php index 5ff933e9..88033f7d 100644 --- a/includes/Handlers/Tools/ToolsHandler.php +++ b/includes/Handlers/Tools/ToolsHandler.php @@ -11,12 +11,10 @@ use WP\MCP\Core\McpServer; use WP\MCP\Domain\Utils\ContentBlockHelper; -use WP\MCP\Domain\Utils\McpAnnotationMapper; use WP\MCP\Domain\Utils\McpValidator; use WP\MCP\Handlers\HandlerHelperTrait; use WP\MCP\Infrastructure\ErrorHandling\McpErrorFactory; use WP\MCP\Infrastructure\Observability\FailureReason; -use WP\McpSchema\Common\Protocol\DTO\Annotations; use WP\McpSchema\Server\Tools\DTO\CallToolResult; use WP\McpSchema\Server\Tools\DTO\ListToolsResult; @@ -270,7 +268,12 @@ public function call_tool( array $params, $request_id = 0 ) { // 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_annotations( $result['annotations'] ?? null, $tool_name ); + $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( @@ -350,71 +353,6 @@ public function call_tool( array $params, $request_id = 0 ) { } } - /** - * Build an Annotations DTO from a tool result's `annotations` key. - * - * Tool results carry *content* annotations (`audience`, `priority`, `lastModified`), - * not the ToolAnnotations vocabulary used on tool descriptors. Anything a conforming - * client would reject is dropped here, because it is validated as part of the content - * block that carries it, 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 before - * they were read here a tool returning a malformed one still got its result delivered; - * failing the whole call now would be a regression for tools whose output is otherwise - * valid. - * - * @since n.e.x.t - * - * @param mixed $annotations The raw annotations value from the tool result. - * @param string $tool_name Tool name for logging. - * - * @return \WP\McpSchema\Common\Protocol\DTO\Annotations|null - */ - private function build_annotations( $annotations, string $tool_name ): ?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 content 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 `[]` where MCP declares an object. 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; - } - - // Tool 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 ) ) { - $this->mcp->get_error_handler()->log( - 'Invalid annotations in tool result, dropping them', - array( - 'tool_name' => $tool_name, - 'errors' => $errors, - ), - 'warning' - ); - - return null; - } - - // Mapping and validation between them leave nothing for fromArray() to reject, so - // there is no local catch here. The tool-call boundary still wraps this method. - return Annotations::fromArray( $mapped ); - } - /** * Create an error CallToolResult from a message string. * diff --git a/tests/phpunit/Unit/Handlers/PromptsHandlerTest.php b/tests/phpunit/Unit/Handlers/PromptsHandlerTest.php index 36159e50..dd809388 100644 --- a/tests/phpunit/Unit/Handlers/PromptsHandlerTest.php +++ b/tests/phpunit/Unit/Handlers/PromptsHandlerTest.php @@ -1027,4 +1027,417 @@ 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'] ); + } + + /** + * 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 $result->getMessages()[0]->getContent()->toArray(); + } } From 9ab7c24ae2effeceb18e9f79d417717b49a1afb7 Mon Sep 17 00:00:00 2001 From: Ovidiu Galatan Date: Tue, 28 Jul 2026 12:51:29 +0300 Subject: [PATCH 07/22] fix: assign flat-form `_meta` to the embedded resource contents A tool may write an embedded resource nested, with the resource fields under a `resource` key, or flat, with them inlined. Strip `type` from the flat form and what remains, `uri`, `mimeType`, one of `text` or `blob`, and `_meta`, is a `ResourceContents` literal, and `_meta` is declared on that type alongside its siblings. The flat form's `_meta` therefore describes the resource contents, which is what those same keys mean to `ResourcesHandler::create_content_dto()`. The nested form addresses both levels: its outer `_meta` belongs to the content block and its inner one to the contents. `annotations` describes the content block in either form, because resource contents declare no `annotations` field. A named `$is_nested` flag carries the shape, and both `_meta` assignments read from it. --- docs/guides/creating-abilities.md | 24 +++++--- includes/Handlers/Tools/ToolsHandler.php | 24 ++++---- .../Unit/Handlers/ToolsHandlerCallTest.php | 57 +++++++++++++++++-- 3 files changed, 81 insertions(+), 24 deletions(-) diff --git a/docs/guides/creating-abilities.md b/docs/guides/creating-abilities.md index 836c9f02..5e8f25cb 100644 --- a/docs/guides/creating-abilities.md +++ b/docs/guides/creating-abilities.md @@ -694,22 +694,32 @@ The example above returns a plain array, which the adapter JSON-encodes into a s 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. -Tools can return the same contents embedded in a `resource` content block. The nested form keeps the two `_meta` levels distinct — the outer one belongs to the content block, the inner one to the resource contents: +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', - '_meta' => ['block' => 'level'], - 'resource' => [ + '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]], + '_meta' => ['ui' => ['prefersBorder' => true]], // resource contents ], ]; ``` -The flat form (`['type' => 'resource', 'uri' => ..., 'text' => ..., '_meta' => ...]`) has only one level, so its `_meta` belongs to the content block. - ## 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. diff --git a/includes/Handlers/Tools/ToolsHandler.php b/includes/Handlers/Tools/ToolsHandler.php index 88033f7d..fd721b78 100644 --- a/includes/Handlers/Tools/ToolsHandler.php +++ b/includes/Handlers/Tools/ToolsHandler.php @@ -241,15 +241,17 @@ public function call_tool( array $params, $request_id = 0 ) { // - 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, text, _meta }` is the content block itself, written with - // its resource fields inlined. There is only one level for `_meta` to mean, so - // it belongs to the block. Reading it into the contents as well would duplicate - // the same metadata on both levels of the response. + // - 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; @@ -258,10 +260,8 @@ public function call_tool( array $params, $request_id = 0 ) { $uri = trim( $uri ); } - $block_meta = McpValidator::normalize_meta( $result['_meta'] ?? null ); - $resource_meta = $resource_item !== $result - ? McpValidator::normalize_meta( $resource_item['_meta'] ?? null ) - : null; + $block_meta = $is_nested ? McpValidator::normalize_meta( $result['_meta'] ?? null ) : null; + $resource_meta = McpValidator::normalize_meta( $resource_item['_meta'] ?? null ); // Only return an EmbeddedResource if we have a valid URI and some content. if ( is_string( $uri ) && '' !== $uri ) { diff --git a/tests/phpunit/Unit/Handlers/ToolsHandlerCallTest.php b/tests/phpunit/Unit/Handlers/ToolsHandlerCallTest.php index 3aa3dcdf..f004b533 100644 --- a/tests/phpunit/Unit/Handlers/ToolsHandlerCallTest.php +++ b/tests/phpunit/Unit/Handlers/ToolsHandlerCallTest.php @@ -457,7 +457,7 @@ public function test_embedded_resource_nested_blob_shape_preserves_meta_on_both_ $this->assertSame( array( 'pages' => 3 ), $resource->get_meta() ); } - public function test_embedded_resource_flat_shape_assigns_meta_to_content_block_only(): void { + public function test_embedded_resource_flat_shape_assigns_meta_to_the_resource_contents(): void { $result = $this->call_tool_returning( array( 'type' => 'resource', @@ -473,10 +473,57 @@ public function test_embedded_resource_flat_shape_assigns_meta_to_content_block_ $content = $result->getContent(); $this->assertInstanceOf( EmbeddedResource::class, $content[0] ); - // The flat shape has one level, so _meta lands on the block and is not duplicated - // onto the nested contents. - $this->assertSame( array( 'ui' => array( 'prefersBorder' => true ) ), $content[0]->get_meta() ); - $this->assertNull( $content[0]->getResource()->get_meta() ); + // 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 { From 49e5ae55ccbc93a24cb74e1b8ca8364a38aee2ca Mon Sep 17 00:00:00 2001 From: Ovidiu Galatan Date: Tue, 28 Jul 2026 13:21:42 +0300 Subject: [PATCH 08/22] fix: degrade prompt message content the schema refuses Guard the PromptMessage construction in validate_and_create_message() so a content block whose type is valid but whose payload the schema DTOs reject becomes a text block carrying its JSON, logged as a warning. Construction is the point that sees the DTO's own verdict, so the guard covers every field the DTOs assert on without restating their rules, and the other messages in the prompt are unaffected. This is the substitution the handler already applies to an unrecognized content type and to an invalid role. Validate embedded resource contents in normalize_content_block() through is_valid_resource_contents(): a non-empty string uri, plus text or blob. EmbeddedResource takes its contents as given, so this is the only level that inspects them, and contents outside that shape are rejected by a conforming client together with the message that carries them. Extract degrade_content_to_text() so every substitution site emits the same shape, and guard the missing-type fallback with is_scalar before casting text so a non-scalar value routes to the JSON representation instead of a string cast. --- includes/Handlers/Prompts/PromptsHandler.php | 122 ++++++-- .../Unit/Handlers/PromptsHandlerTest.php | 273 +++++++++++++++++- 2 files changed, 371 insertions(+), 24 deletions(-) diff --git a/includes/Handlers/Prompts/PromptsHandler.php b/includes/Handlers/Prompts/PromptsHandler.php index 8ef7e4af..b6105683 100644 --- a/includes/Handlers/Prompts/PromptsHandler.php +++ b/includes/Handlers/Prompts/PromptsHandler.php @@ -518,14 +518,83 @@ 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. * @@ -571,10 +640,23 @@ private function normalize_content_block( array $content, string $prompt_name ): } // EmbeddedResource takes its resource contents as given, so a nested block never - // reaches a DTO that could reject its `_meta`. This is the only level that - // normalizes it. - if ( isset( $content['resource'] ) && is_array( $content['resource'] ) ) { - $resource = $content['resource']; + // 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 = McpValidator::normalize_meta( $resource['_meta'] ?? null ); if ( null === $resource_meta ) { unset( $resource['_meta'] ); @@ -620,12 +702,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. @@ -641,15 +725,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/tests/phpunit/Unit/Handlers/PromptsHandlerTest.php b/tests/phpunit/Unit/Handlers/PromptsHandlerTest.php index dd809388..c7a7910a 100644 --- a/tests/phpunit/Unit/Handlers/PromptsHandlerTest.php +++ b/tests/phpunit/Unit/Handlers/PromptsHandlerTest.php @@ -1395,6 +1395,265 @@ static function ( array $log ): bool { $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_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. @@ -1438,6 +1697,18 @@ private function get_prompt_returning( array $shape ) { * @return array */ private function first_content_block( GetPromptResult $result ): array { - return $result->getMessages()[0]->getContent()->toArray(); + 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(); } } From c2231e4d51836c2f155d868d4a8f9fab015d0e31 Mon Sep 17 00:00:00 2001 From: Ovidiu Galatan Date: Tue, 28 Jul 2026 13:27:31 +0300 Subject: [PATCH 09/22] test: cover the scalar fallback for a content block with no type A block with no type falls back to its own `text` when that value is scalar, and to the JSON representation otherwise. Pin the scalar side so both branches of the fallback are exercised. --- .../Unit/Handlers/PromptsHandlerTest.php | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/tests/phpunit/Unit/Handlers/PromptsHandlerTest.php b/tests/phpunit/Unit/Handlers/PromptsHandlerTest.php index c7a7910a..8ab90ecc 100644 --- a/tests/phpunit/Unit/Handlers/PromptsHandlerTest.php +++ b/tests/phpunit/Unit/Handlers/PromptsHandlerTest.php @@ -1602,6 +1602,25 @@ public function test_missing_content_type_with_non_scalar_text_degrades_to_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( From 45947ee2d0efad1d620c0ca8da94f03da0b92e22 Mon Sep 17 00:00:00 2001 From: Ovidiu Galatan Date: Tue, 28 Jul 2026 13:38:32 +0300 Subject: [PATCH 10/22] docs: state which annotation vocabulary a tool result takes A tool carries annotations at two levels. The ability's `meta.annotations` describes the tool itself and takes the ToolAnnotations hints. A content block the tool returns is a different object, and the schema models `audience`, `priority` and `lastModified` on a content block, so that is the vocabulary a result takes. The annotation reference gains a "Tool Result Annotations" section carrying that rule with an example, noting that a tool hint written on a result is dropped and that values outside what MCP allows drop the annotations as a group and log. The usage summary names both levels for Tools, matching the Prompts entry. --- docs/guides/creating-abilities.md | 24 +++++++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/docs/guides/creating-abilities.md b/docs/guides/creating-abilities.md index 5e8f25cb..39d49d3c 100644 --- a/docs/guides/creating-abilities.md +++ b/docs/guides/creating-abilities.md @@ -430,6 +430,28 @@ Tools support these MCP specification annotations: - `destructive` → `destructiveHint` - `idempotent` → `idempotentHint` +### 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', + ], + ]; +}, +``` + +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) Resources and Prompts share the same annotation schema per MCP specification: @@ -451,7 +473,7 @@ Resources and Prompts share the same annotation schema per MCP specification: ### Annotation Usage by Component Type -- **Tools**: Use annotations to describe tool behavior and execution characteristics +- **Tools**: Support two types of annotations — `meta.annotations` describes the tool's behavior and execution characteristics on its descriptor, while a returned content block takes content annotations - **Resources**: Use annotations for content metadata and access patterns - **Prompts**: Support two types of annotations (template-level and message content-level) From 63b427058d81c416ef4a0e4a8b5204d32d819b70 Mon Sep 17 00:00:00 2001 From: Ovidiu Galatan Date: Tue, 28 Jul 2026 13:43:40 +0300 Subject: [PATCH 11/22] fix: carry annotations and `_meta` on an image tool result A `type` key marks a tool result as a description of a content block rather than as tool data, so its sibling `annotations` and `_meta` belong to that block. The image branch reads both and passes them to `ContentBlockHelper::image()`, which already accepts them. Annotations go through `build_content_annotations()`, so the image branch applies the mapping, range validation and drop-and-log the resource branch applies, under the same log message. `_meta` goes through `McpValidator::normalize_meta()`, which yields a value only when it serializes as a JSON object and accepts any type, so a malformed one costs the client its metadata and not the image. The generic fallback keeps reading neither. It has no `type` marker, and the result it holds is JSON-encoded into the text block and returned verbatim as `structuredContent`, so an `annotations` key there is already part of the payload and reading it as a block annotation would leave one key with two meanings. A comment at the call site records that. --- includes/Handlers/Tools/ToolsHandler.php | 25 ++- .../Unit/Handlers/ToolsHandlerCallTest.php | 148 ++++++++++++++++++ 2 files changed, 172 insertions(+), 1 deletion(-) diff --git a/includes/Handlers/Tools/ToolsHandler.php b/includes/Handlers/Tools/ToolsHandler.php index fd721b78..4a58ad18 100644 --- a/includes/Handlers/Tools/ToolsHandler.php +++ b/includes/Handlers/Tools/ToolsHandler.php @@ -314,19 +314,42 @@ public function call_tool( array $params, $request_id = 0 ) { } // Handle image 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. if ( isset( $result['type'] ) && 'image' === $result['type'] && 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, + McpValidator::normalize_meta( $result['_meta'] ?? null ) + ), + ), '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/Unit/Handlers/ToolsHandlerCallTest.php b/tests/phpunit/Unit/Handlers/ToolsHandlerCallTest.php index f004b533..9d53fd43 100644 --- a/tests/phpunit/Unit/Handlers/ToolsHandlerCallTest.php +++ b/tests/phpunit/Unit/Handlers/ToolsHandlerCallTest.php @@ -776,4 +776,152 @@ public function test_embedded_resource_accepts_an_already_built_annotations_dto( $block['annotations'] ); } + + /** + * `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(): 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'] ); + } + + /** + * 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() + ); + } } From 074a999f0cb66c0d34a1bf890665410699acbcfd Mon Sep 17 00:00:00 2001 From: Ovidiu Galatan Date: Tue, 28 Jul 2026 13:52:27 +0300 Subject: [PATCH 12/22] docs: state when a prompt builder's `_meta` is emitted MCP declares `_meta` an object, and `build()` emits the builder's only when it is a non-empty associative array, so a list or an empty array is omitted rather than sent as a JSON array. The property and setter docblocks say so and point at `build()`, where the rule is applied. Key names are still kept as written, which is the part of the earlier wording that holds. --- includes/Domain/Prompts/McpPromptBuilder.php | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/includes/Domain/Prompts/McpPromptBuilder.php b/includes/Domain/Prompts/McpPromptBuilder.php index 32a42d31..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 * @@ -259,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. * From 38d26c0d30acae2ff4ce0794df18c5804839de83 Mon Sep 17 00:00:00 2001 From: Ovidiu Galatan Date: Tue, 28 Jul 2026 13:53:04 +0300 Subject: [PATCH 13/22] fix: recognize blob-only items as resource contents `convert_contents_to_dtos()` decides whether an ability returned a list of content items by looking at the first item for a field such an item is built from. `blob` is one of those fields: `create_content_dto()` branches on it before anything else, and the URI falls back to the resource's own, so a blob and a MIME type are all a caller has to write for binary contents. Without it a blob-only first item sends the whole list down the fallback path, where it is JSON-encoded into one text item. Only the first item is inspected, so that costs every sibling in the list as well. --- .../Handlers/Resources/ResourcesHandler.php | 8 +- .../Handlers/ResourcesHandlerReadTest.php | 101 ++++++++++++++++++ 2 files changed, 107 insertions(+), 2 deletions(-) diff --git a/includes/Handlers/Resources/ResourcesHandler.php b/includes/Handlers/Resources/ResourcesHandler.php index 717388ca..1823f441 100644 --- a/includes/Handlers/Resources/ResourcesHandler.php +++ b/includes/Handlers/Resources/ResourcesHandler.php @@ -236,9 +236,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 ); diff --git a/tests/phpunit/Unit/Handlers/ResourcesHandlerReadTest.php b/tests/phpunit/Unit/Handlers/ResourcesHandlerReadTest.php index 57ae3216..9f18eaf4 100644 --- a/tests/phpunit/Unit/Handlers/ResourcesHandlerReadTest.php +++ b/tests/phpunit/Unit/Handlers/ResourcesHandlerReadTest.php @@ -465,4 +465,105 @@ public function test_read_resource_without_meta_leaves_contents_meta_null(): voi $this->assertInstanceOf( TextResourceContents::class, $contents[0] ); $this->assertNull( $contents[0]->get_meta() ); } + + /** + * `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() ); + } } From 6ae45629b100d24dd3c404656bfa6df972eb3c88 Mon Sep 17 00:00:00 2001 From: Ovidiu Galatan Date: Tue, 28 Jul 2026 13:53:30 +0300 Subject: [PATCH 14/22] fix: log a `_meta` the handlers cannot emit `HandlerHelperTrait::normalize_content_meta()` normalizes a `_meta` value and logs a warning when one was written but could not be emitted. MCP declares `_meta` an object, and a conforming client strips metadata it does not recognize, so a value dropped for shape is reported nowhere else and the author has nothing to work from. The helper takes the raw value rather than a pre-normalized one, which is what separates the two cases `McpValidator::normalize_meta()` answers null for: an absent key arrives as null and stays quiet, while anything else that normalizes away is a value someone wrote. No guard is needed at the call sites. The six handler call sites use it, each naming the object the metadata was written on, so a tool result's content block and the resource contents nested inside it report separately. Dropping is logged rather than raised, matching the annotations path in the same trait: `_meta` travels alongside a payload, and withholding the payload over its metadata is the worse outcome. --- includes/Handlers/HandlerHelperTrait.php | 39 ++++++++++++ includes/Handlers/Prompts/PromptsHandler.php | 15 ++++- .../Handlers/Resources/ResourcesHandler.php | 11 +++- includes/Handlers/Tools/ToolsHandler.php | 24 ++++++-- .../Unit/Handlers/PromptsHandlerTest.php | 58 ++++++++++++++++++ .../Handlers/ResourcesHandlerReadTest.php | 60 +++++++++++++++++++ .../Unit/Handlers/ToolsHandlerCallTest.php | 46 +++++++++++++- 7 files changed, 242 insertions(+), 11 deletions(-) diff --git a/includes/Handlers/HandlerHelperTrait.php b/includes/Handlers/HandlerHelperTrait.php index 6b0c3f60..4aadf263 100644 --- a/includes/Handlers/HandlerHelperTrait.php +++ b/includes/Handlers/HandlerHelperTrait.php @@ -131,4 +131,43 @@ protected function build_content_annotations( // 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 b6105683..7c57680a 100644 --- a/includes/Handlers/Prompts/PromptsHandler.php +++ b/includes/Handlers/Prompts/PromptsHandler.php @@ -10,7 +10,6 @@ namespace WP\MCP\Handlers\Prompts; use WP\MCP\Core\McpServer; -use WP\MCP\Domain\Utils\McpValidator; use WP\MCP\Handlers\HandlerHelperTrait; use WP\MCP\Infrastructure\ErrorHandling\McpErrorFactory; use WP\McpSchema\Server\Prompts\DTO\GetPromptResult; @@ -617,7 +616,12 @@ private function is_valid_resource_contents( $contents ): bool { * @return array Content block safe to hand to PromptMessage::fromArray(). */ private function normalize_content_block( array $content, string $prompt_name ): array { - $block_meta = McpValidator::normalize_meta( $content['_meta'] ?? null ); + $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 { @@ -657,7 +661,12 @@ private function normalize_content_block( array $content, string $prompt_name ): return $this->degrade_content_to_text( $content ); } - $resource_meta = McpValidator::normalize_meta( $resource['_meta'] ?? null ); + $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 { diff --git a/includes/Handlers/Resources/ResourcesHandler.php b/includes/Handlers/Resources/ResourcesHandler.php index 1823f441..baf54335 100644 --- a/includes/Handlers/Resources/ResourcesHandler.php +++ b/includes/Handlers/Resources/ResourcesHandler.php @@ -10,7 +10,6 @@ namespace WP\MCP\Handlers\Resources; use WP\MCP\Core\McpServer; -use WP\MCP\Domain\Utils\McpValidator; use WP\MCP\Handlers\HandlerHelperTrait; use WP\MCP\Infrastructure\ErrorHandling\McpErrorFactory; use WP\McpSchema\Common\Protocol\DTO\BlobResourceContents; @@ -281,7 +280,8 @@ function ( $item ) use ( $uri ) { * * 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. - * See {@see McpValidator::normalize_meta()}. + * The drop is logged, since a conforming client strips metadata it does not + * recognize and would report nothing. See {@see HandlerHelperTrait::normalize_content_meta()}. * * @param array $item The content item array. * @param string $default_uri The default URI to use if not specified. @@ -291,7 +291,12 @@ function ( $item ) use ( $uri ) { private function create_content_dto( array $item, string $default_uri ) { $item_uri = $item['uri'] ?? $default_uri; $mime_type = $item['mimeType'] ?? null; - $meta = McpValidator::normalize_meta( $item['_meta'] ?? 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'] ) ) { diff --git a/includes/Handlers/Tools/ToolsHandler.php b/includes/Handlers/Tools/ToolsHandler.php index 4a58ad18..9dfe2807 100644 --- a/includes/Handlers/Tools/ToolsHandler.php +++ b/includes/Handlers/Tools/ToolsHandler.php @@ -11,7 +11,6 @@ use WP\MCP\Core\McpServer; use WP\MCP\Domain\Utils\ContentBlockHelper; -use WP\MCP\Domain\Utils\McpValidator; use WP\MCP\Handlers\HandlerHelperTrait; use WP\MCP\Infrastructure\ErrorHandling\McpErrorFactory; use WP\MCP\Infrastructure\Observability\FailureReason; @@ -260,8 +259,20 @@ public function call_tool( array $params, $request_id = 0 ) { $uri = trim( $uri ); } - $block_meta = $is_nested ? McpValidator::normalize_meta( $result['_meta'] ?? null ) : null; - $resource_meta = McpValidator::normalize_meta( $resource_item['_meta'] ?? null ); + $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 ) { @@ -336,7 +347,12 @@ public function call_tool( array $params, $request_id = 0 ) { $image_data, $mime_type, $annotations, - McpValidator::normalize_meta( $result['_meta'] ?? null ) + $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, diff --git a/tests/phpunit/Unit/Handlers/PromptsHandlerTest.php b/tests/phpunit/Unit/Handlers/PromptsHandlerTest.php index 8ab90ecc..576dfa83 100644 --- a/tests/phpunit/Unit/Handlers/PromptsHandlerTest.php +++ b/tests/phpunit/Unit/Handlers/PromptsHandlerTest.php @@ -1730,4 +1730,62 @@ private function first_content_block( GetPromptResult $result ): 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 9f18eaf4..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; @@ -466,6 +467,65 @@ public function test_read_resource_without_meta_leaves_contents_meta_null(): voi $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`. diff --git a/tests/phpunit/Unit/Handlers/ToolsHandlerCallTest.php b/tests/phpunit/Unit/Handlers/ToolsHandlerCallTest.php index 9d53fd43..30c9a6eb 100644 --- a/tests/phpunit/Unit/Handlers/ToolsHandlerCallTest.php +++ b/tests/phpunit/Unit/Handlers/ToolsHandlerCallTest.php @@ -777,6 +777,47 @@ public function test_embedded_resource_accepts_an_already_built_annotations_dto( ); } + /** + * 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`. @@ -881,7 +922,7 @@ public function test_image_result_with_tool_annotation_vocabulary_omits_annotati * 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(): void { + public function test_image_result_with_list_meta_omits_meta_and_logs(): void { $result = $this->call_tool_returning( array( 'type' => 'image', @@ -896,6 +937,9 @@ public function test_image_result_with_list_meta_omits_meta(): void { $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 ); } /** From e97daadab611fa3e62874a382685538cdfa2686c Mon Sep 17 00:00:00 2001 From: Ovidiu Galatan Date: Tue, 28 Jul 2026 14:21:09 +0300 Subject: [PATCH 15/22] docs: state the trust contract for resource metadata `_meta` is a request to the client rather than a control the adapter applies. The adapter checks only that the value serializes as a JSON object and forwards it as written, interpreting none of the keys, so a CSP declared there is enforced by whatever renders the resource. --- docs/guides/creating-abilities.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/guides/creating-abilities.md b/docs/guides/creating-abilities.md index 39d49d3c..9e909498 100644 --- a/docs/guides/creating-abilities.md +++ b/docs/guides/creating-abilities.md @@ -714,6 +714,8 @@ The example above returns a plain array, which the adapter JSON-encodes into a s `_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. 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: From c2070a0dc5a147fae29615d2a9d5a15b580b48b0 Mon Sep 17 00:00:00 2001 From: Ovidiu Galatan Date: Tue, 28 Jul 2026 14:21:14 +0300 Subject: [PATCH 16/22] docs: describe the content block metadata arguments Every `_meta` argument on ContentBlockHelper names the content block it sets, matching the two embedded-resource helpers that already distinguished the block from the resource contents nested inside it. Those two also record the version that added `$resource_meta`. ResourcesHandler::create_content_dto() gains the shape of the item it reads. Every key is optional and loosely typed because the method casts `blob` and `text`, keeps `mimeType` only when it already is a string, and falls back to the resource's own URI, so it requires no type from its caller. --- includes/Domain/Utils/ContentBlockHelper.php | 14 +++++++++----- includes/Handlers/Resources/ResourcesHandler.php | 8 ++++++-- 2 files changed, 15 insertions(+), 7 deletions(-) diff --git a/includes/Domain/Utils/ContentBlockHelper.php b/includes/Domain/Utils/ContentBlockHelper.php index 37568b0f..3b48bf32 100644 --- a/includes/Domain/Utils/ContentBlockHelper.php +++ b/includes/Domain/Utils/ContentBlockHelper.php @@ -45,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. */ @@ -67,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. */ @@ -93,6 +93,8 @@ public static function audio( string $data, string $mime_type, ?Annotations $ann * 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. @@ -139,6 +141,8 @@ public static function embedded_text_resource( * 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. @@ -183,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. */ @@ -196,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. */ @@ -220,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/Handlers/Resources/ResourcesHandler.php b/includes/Handlers/Resources/ResourcesHandler.php index baf54335..07591221 100644 --- a/includes/Handlers/Resources/ResourcesHandler.php +++ b/includes/Handlers/Resources/ResourcesHandler.php @@ -283,8 +283,12 @@ function ( $item ) use ( $uri ) { * The drop is logged, since a conforming client strips metadata it does not * recognize and would report nothing. See {@see HandlerHelperTrait::normalize_content_meta()}. * - * @param array $item The content item array. - * @param string $default_uri The default URI to use if not specified. + * 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 */ From 961f470b0bd77034ddd480d9c445aa73790b9f18 Mon Sep 17 00:00:00 2001 From: Ovidiu Galatan Date: Tue, 28 Jul 2026 14:21:18 +0300 Subject: [PATCH 17/22] test: follow the data provider naming convention Data providers in this suite are named `data_*`. --- tests/phpunit/Unit/Domain/Utils/McpValidatorTest.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/phpunit/Unit/Domain/Utils/McpValidatorTest.php b/tests/phpunit/Unit/Domain/Utils/McpValidatorTest.php index 461c1cb0..8711970c 100644 --- a/tests/phpunit/Unit/Domain/Utils/McpValidatorTest.php +++ b/tests/phpunit/Unit/Domain/Utils/McpValidatorTest.php @@ -984,7 +984,7 @@ public function test_normalize_meta_keeps_prefixed_keys(): void { } /** - * @dataProvider provide_non_object_meta + * @dataProvider data_non_object_meta * * @param mixed $meta The value to normalize. */ @@ -995,7 +995,7 @@ public function test_normalize_meta_rejects_values_that_are_not_json_objects( $m /** * @return array */ - public function provide_non_object_meta(): array { + public function data_non_object_meta(): array { return array( 'null' => array( null ), 'string' => array( 'not-an-object' ), From c7f9ba62fb441dac4304742f49454d4456e381fb Mon Sep 17 00:00:00 2001 From: Ovidiu Galatan Date: Tue, 28 Jul 2026 17:50:27 +0300 Subject: [PATCH 18/22] fix: emit a mimeType as declared MCP places no format constraint on `mimeType` on any object that carries one, so presence and type are the only checks the adapter applies. Remove `validate_mime_type()`, `validate_image_mime_type()`, `validate_audio_mime_type()`, `validate_icon_mime_type()` and the icon MIME allow-list, together with their call sites in the resource registrar, the resource and prompt validators, and icon validation. `image` and `audio` content blocks still require a `mimeType`, which the schema marks required there. --- .../Domain/Prompts/McpPromptValidator.php | 34 +-- includes/Domain/Resources/McpResource.php | 4 +- .../Domain/Resources/McpResourceValidator.php | 16 +- .../RegisterAbilityAsMcpResource.php | 5 +- includes/Domain/Utils/McpValidator.php | 90 +------- .../Domain/Prompts/McpPromptValidatorTest.php | 46 +++- .../Resources/McpResourceValidatorTest.php | 16 +- .../Unit/Domain/Utils/McpValidatorTest.php | 200 ++---------------- .../RegisterAbilityAsMcpResourceTest.php | 7 +- 9 files changed, 77 insertions(+), 341 deletions(-) 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/Resources/McpResource.php b/includes/Domain/Resources/McpResource.php index 55df7161..4b90c936 100644 --- a/includes/Domain/Resources/McpResource.php +++ b/includes/Domain/Resources/McpResource.php @@ -150,10 +150,10 @@ 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; } } 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 dcdd78ee..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; } } diff --git a/includes/Domain/Utils/McpValidator.php b/includes/Domain/Utils/McpValidator.php index 1d0e6907..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. * @@ -578,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/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/McpValidatorTest.php b/tests/phpunit/Unit/Domain/Utils/McpValidatorTest.php index 8711970c..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 { 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 { From 0cb81ec6113fac43549adb352078f1b61b1288c0 Mon Sep 17 00:00:00 2001 From: Ovidiu Galatan Date: Tue, 28 Jul 2026 18:49:46 +0300 Subject: [PATCH 19/22] docs: state where annotations go per component type Tools read annotations from `meta.annotations`, resources from `meta.mcp.annotations`, and prompts carry none on the descriptor: the MCP Prompt object has no annotations field, so only message content blocks take them. Tool annotations are written with the Abilities API names `readonly`, `destructive` and `idempotent`, which the adapter emits as `readOnlyHint`, `destructiveHint` and `idempotentHint`. WordPress core defines these names on every ability and reads them back in its REST layer, so an ability that uses them stays consistent across Abilities API consumers. `openWorldHint` and `title` have no Abilities API equivalent and keep their MCP names. When both spellings are present, the Abilities API name takes precedence. Resource examples carry `uri`, `mimeType` and `annotations` under `meta.mcp`, and the prompt example carries `arguments` there. --- docs/getting-started/basic-examples.md | 60 +++---- docs/guides/creating-abilities.md | 167 ++++++++++-------- .../Prompts/RegisterAbilityAsMcpPrompt.php | 1 - 3 files changed, 121 insertions(+), 107 deletions(-) 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 9e909498..63acb5a0 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,47 +406,58 @@ 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 | -**WordPress → MCP Field Conversion**: For backward compatibility, Tools support WordPress-format field names that are automatically converted: -- `readonly` → `readOnlyHint` -- `destructive` → `destructiveHint` -- `idempotent` → `idempotentHint` +`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 @@ -452,30 +481,35 @@ The annotations above describe the tool itself and belong on its descriptor, in 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**: Support two types of annotations — `meta.annotations` describes the tool's behavior and execution characteristics on its descriptor, while a returned content block takes content annotations -- **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 @@ -512,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', @@ -540,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' ] @@ -659,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', [ @@ -680,14 +709,14 @@ 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 + ] ] ] ]); @@ -812,10 +841,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 ] @@ -878,11 +903,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 ] @@ -892,19 +912,16 @@ wp_register_ability('my-plugin/analysis-prompt', [ ### Prompt Annotations Summary -**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` - -**Message Content Annotations** (in message `content.annotations`): +**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/RegisterAbilityAsMcpPrompt.php b/includes/Domain/Prompts/RegisterAbilityAsMcpPrompt.php index fdcf7ad4..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(...) * ) * ) * ); From cdc61a5fc46a3c5e5feb3d3b341d4fafd7733d94 Mon Sep 17 00:00:00 2001 From: Ovidiu Galatan Date: Tue, 28 Jul 2026 18:52:59 +0300 Subject: [PATCH 20/22] docs: separate embedded resources from the MCP Apps route An MCP App is predeclared: the UI resource is registered so it appears in `resources/list` under its `ui://` URI, and the tool descriptor binds it with `mcp._meta.ui.resourceUri`. The host fetches the template over `resources/read` and renders it in a sandboxed frame, so the tool result carries data only. Embedding the resource in the tool result instead yields plain text. --- docs/guides/creating-abilities.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/guides/creating-abilities.md b/docs/guides/creating-abilities.md index 63acb5a0..4bf2c78c 100644 --- a/docs/guides/creating-abilities.md +++ b/docs/guides/creating-abilities.md @@ -747,6 +747,8 @@ What you write there is a request to the client, not a control the adapter appli 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. +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 From c438ba8c5527a733d18245f0f48b9989891ad7d2 Mon Sep 17 00:00:00 2001 From: Ovidiu Galatan Date: Tue, 28 Jul 2026 18:59:15 +0300 Subject: [PATCH 21/22] fix: warn when an image result omits its bytes A tool result marked `type: "image"` carries its raw bytes under `results`, which the handler base64-encodes into the `data` field MCP expects. Without that key there is nothing to encode, so the result falls through to the generic path and reaches the client as a JSON text block. Log a warning naming the tool at that point, since the `type` marker states an intent the payload does not carry out. Document the image return shape alongside the other content-block forms. --- docs/guides/creating-abilities.md | 20 ++++++++++++++ includes/Handlers/Tools/ToolsHandler.php | 15 ++++++++++- tests/phpunit/Fixtures/DummyAbility.php | 26 +++++++++++++++++++ .../Unit/Handlers/ToolsHandlerCallTest.php | 22 ++++++++++++++++ 4 files changed, 82 insertions(+), 1 deletion(-) diff --git a/docs/guides/creating-abilities.md b/docs/guides/creating-abilities.md index 4bf2c78c..0265df33 100644 --- a/docs/guides/creating-abilities.md +++ b/docs/guides/creating-abilities.md @@ -775,6 +775,26 @@ return [ ]; ``` +### 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. + ## 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. diff --git a/includes/Handlers/Tools/ToolsHandler.php b/includes/Handlers/Tools/ToolsHandler.php index 9dfe2807..c8795cca 100644 --- a/includes/Handlers/Tools/ToolsHandler.php +++ b/includes/Handlers/Tools/ToolsHandler.php @@ -329,7 +329,20 @@ public function call_tool( array $params, $request_id = 0 ) { // `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. - if ( isset( $result['type'] ) && 'image' === $result['type'] && isset( $result['results'] ) ) { + $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; 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/Handlers/ToolsHandlerCallTest.php b/tests/phpunit/Unit/Handlers/ToolsHandlerCallTest.php index 30c9a6eb..75735010 100644 --- a/tests/phpunit/Unit/Handlers/ToolsHandlerCallTest.php +++ b/tests/phpunit/Unit/Handlers/ToolsHandlerCallTest.php @@ -124,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 ); From 0f3efd77c9bc415b151b93652cf32b85a8a1ea34 Mon Sep 17 00:00:00 2001 From: Ovidiu Galatan Date: Tue, 28 Jul 2026 21:02:02 +0300 Subject: [PATCH 22/22] docs: add examples for MCP App tools, binary resources and prompt blocks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a two-step recipe for a tool that renders a UI: register the HTML template as a resource under a `ui://` URI carrying the `text/html;profile=mcp-app` MIME type, then bind a tool to it through `meta.mcp._meta.ui.resourceUri`, which reaches the tool descriptor unaltered. The host fetches and renders the template itself and the tool returns data alone, so the two are registered separately. Tabulate the `_meta.ui` keys a template declares — `csp`, `permissions`, `domain` and `prefersBorder` — and the `visibility` values deciding which of the model and the app see a tool. Add a resource returning binary contents, where `blob` takes base64 the ability encodes while a tool's image result takes raw bytes in `results`. Add a prompt returning the four non-text content block types with the fields each requires: `uri` plus `text` or `blob` for an embedded resource, `uri` and `name` for a resource link, `data` and `mimeType` for an image. Link the MCP Apps section from the documentation index. --- docs/README.md | 1 + docs/guides/creating-abilities.md | 224 ++++++++++++++++++++++++++++++ 2 files changed, 225 insertions(+) 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/guides/creating-abilities.md b/docs/guides/creating-abilities.md index 0265df33..fc681882 100644 --- a/docs/guides/creating-abilities.md +++ b/docs/guides/creating-abilities.md @@ -747,6 +747,47 @@ What you write there is a request to the client, not a control the adapter appli 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: @@ -795,6 +836,119 @@ A tool returns an image by marking the result `type` as `image` and putting the `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. @@ -932,6 +1086,76 @@ wp_register_ability('my-plugin/analysis-prompt', [ ]); ``` +### Messages Carrying Resources and Images + +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: + +```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: