Skip to content

[MCP] resources/read returns a CallToolResult instead of ReadResourceResult for McpResource operations #8428

Description

@JacquesMougin

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

  1. 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
{
}
  1. Call resources/read on app://countries (via POST /mcp, or through the MCP Inspector connected over Streamable HTTP).
  2. Observe the response has result.content / result.structuredContent instead of result.contents.
  3. 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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions