Bug description
ApiPlatform\Mcp\State\StructuredContentProcessor::process() always wraps the operation's result in a CallToolResult, even when the operation is an McpResource handled through resources/read. Per the MCP spec (and mcp/sdk's own ReadResourceResult), a resources/read response must return a top-level contents array (TextResourceContents/BlobResourceContents), not content/structuredContent.
Concretely, the processor already special-cases McpResource vs McpTool to decide whether to include structuredContent (line 56), but the final return unconditionally builds a CallToolResult regardless of that distinction:
// vendor/api-platform/mcp/State/StructuredContentProcessor.php
$includeStructuredContent = $operation instanceof McpTool || $operation instanceof McpResource ? $operation->getStructuredContent() ?? true : false;
// ...
return new Response(
$context['mcp_request']->getId(),
new CallToolResult(
[new TextContent($result)],
false,
$structuredContent
),
);
As a result, any #[ApiResource(mcp: [new McpResource(...)])] resource returns a payload shaped like a tool call ({"content": [...], "structuredContent": {...}}) instead of the spec-required {"contents": [...]}. Clients that validate the response against the MCP JSON Schema — e.g. the official MCP Inspector — reject it:
[
{
"code": "invalid_type",
"expected": "array",
"received": "undefined",
"path": ["contents"],
"message": "Required"
}
]
How to reproduce
- Define an
ApiResource exposing an McpResource:
#[ApiResource(
operations: [],
mcp: [
'countries' => new McpResource(
uri: 'app://countries',
name: 'countries',
description: 'Lists countries.',
structuredContent: true,
output: CountryOutput::class,
provider: CountryListProvider::class,
),
],
)]
final class CountryMcpResource
{
}
- Call
resources/read on app://countries (via POST /mcp, or through the MCP Inspector connected over Streamable HTTP).
- Observe the response has
result.content / result.structuredContent instead of result.contents.
- Connect the same server with
npx @modelcontextprotocol/inspector and try to read the resource — the response fails Zod validation on the missing contents field.
Expected behavior
For an operation that is an McpResource, the processor should build a Mcp\Schema\Result\ReadResourceResult (which serializes to {"contents": [...]}), not a CallToolResult.
Suggested fix
Branch on $operation instanceof McpResource before falling back to the CallToolResult path, e.g.:
if ($operation instanceof McpResource) {
return new Response(
$context['mcp_request']->getId(),
new ReadResourceResult([
new TextResourceContents(
$operation->getUri(),
$operation->getMimeType() ?? 'application/json',
$result,
),
]),
);
}
return new Response(
$context['mcp_request']->getId(),
new CallToolResult(
[new TextContent($result)],
false,
$structuredContent
),
);
I applied this locally (patching vendor/api-platform/mcp/State/StructuredContentProcessor.php) and confirmed the resources/read response is now spec-compliant:
{
"contents": [
{
"uri": "app://countries",
"mimeType": "application/json",
"text": "{\"@context\":\"...\",\"member\":[...]}"
}
]
}
Environment
api-platform/mcp: v4.3.17 (same commit as v4.3.16 — no code change between the two tags)
mcp/sdk: ^0.6
- PHP 8.5, Symfony 8.1
Bug description
ApiPlatform\Mcp\State\StructuredContentProcessor::process()always wraps the operation's result in aCallToolResult, even when the operation is anMcpResourcehandled throughresources/read. Per the MCP spec (andmcp/sdk's ownReadResourceResult), aresources/readresponse must return a top-levelcontentsarray (TextResourceContents/BlobResourceContents), notcontent/structuredContent.Concretely, the processor already special-cases
McpResourcevsMcpToolto decide whether to includestructuredContent(line 56), but the finalreturnunconditionally builds aCallToolResultregardless of that distinction:As a result, any
#[ApiResource(mcp: [new McpResource(...)])]resource returns a payload shaped like a tool call ({"content": [...], "structuredContent": {...}}) instead of the spec-required{"contents": [...]}. Clients that validate the response against the MCP JSON Schema — e.g. the official MCP Inspector — reject it:[ { "code": "invalid_type", "expected": "array", "received": "undefined", "path": ["contents"], "message": "Required" } ]How to reproduce
ApiResourceexposing anMcpResource:#[ApiResource( operations: [], mcp: [ 'countries' => new McpResource( uri: 'app://countries', name: 'countries', description: 'Lists countries.', structuredContent: true, output: CountryOutput::class, provider: CountryListProvider::class, ), ], )] final class CountryMcpResource { }resources/readonapp://countries(viaPOST /mcp, or through the MCP Inspector connected over Streamable HTTP).result.content/result.structuredContentinstead ofresult.contents.npx @modelcontextprotocol/inspectorand try to read the resource — the response fails Zod validation on the missingcontentsfield.Expected behavior
For an operation that is an
McpResource, the processor should build aMcp\Schema\Result\ReadResourceResult(which serializes to{"contents": [...]}), not aCallToolResult.Suggested fix
Branch on
$operation instanceof McpResourcebefore falling back to theCallToolResultpath, e.g.:I applied this locally (patching
vendor/api-platform/mcp/State/StructuredContentProcessor.php) and confirmed theresources/readresponse is now spec-compliant:{ "contents": [ { "uri": "app://countries", "mimeType": "application/json", "text": "{\"@context\":\"...\",\"member\":[...]}" } ] }Environment
api-platform/mcp: v4.3.17 (same commit as v4.3.16 — no code change between the two tags)mcp/sdk: ^0.6