Skip to content

Commit ffedb71

Browse files
committed
fix(mcp): list tools/resources at request time
The SDK Builder runs MCP loaders once, when mcp.server is first built, into a shared registry that tools/list reads from. Under a persistent runtime (FrankenPHP worker mode) that single build can capture an empty registry (cold metadata cache) and stays empty for the whole process, so tools/list returns []. tools/call keeps working because the request-time Handler resolves operations from metadata directly. Add a ListHandler (tagged mcp.request_handler, so it takes precedence over the SDK's registry-backed handler) that builds tools/list and resources/list from the api-platform Loader on each request, keeping discovery as resilient as invocation. Closes #8370
1 parent fe84af2 commit ffedb71

3 files changed

Lines changed: 189 additions & 0 deletions

File tree

src/Mcp/Server/ListHandler.php

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the API Platform project.
5+
*
6+
* (c) Kévin Dunglas <dunglas@gmail.com>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
declare(strict_types=1);
13+
14+
namespace ApiPlatform\Mcp\Server;
15+
16+
use Mcp\Capability\Registry;
17+
use Mcp\Capability\Registry\Loader\LoaderInterface;
18+
use Mcp\Schema\JsonRpc\Request;
19+
use Mcp\Schema\JsonRpc\Response;
20+
use Mcp\Schema\Request\ListResourcesRequest;
21+
use Mcp\Schema\Request\ListToolsRequest;
22+
use Mcp\Schema\Result\ListResourcesResult;
23+
use Mcp\Schema\Result\ListToolsResult;
24+
use Mcp\Server\Handler\Request\RequestHandlerInterface;
25+
use Mcp\Server\Session\SessionInterface;
26+
27+
/**
28+
* Lists MCP tools and resources from API Platform metadata at request time.
29+
*
30+
* The SDK's built-in list handlers read from a registry populated once, when mcp.server is
31+
* built. Under a persistent runtime (e.g. FrankenPHP worker mode) that single build can capture
32+
* an empty registry — leaving tools/list empty for the whole process while tools/call keeps
33+
* working through the request-time {@see Handler}. Rebuilding from the loader on each request
34+
* keeps discovery and invocation consistent.
35+
*
36+
* @experimental
37+
*
38+
* @implements RequestHandlerInterface<ListToolsResult|ListResourcesResult>
39+
*/
40+
final class ListHandler implements RequestHandlerInterface
41+
{
42+
public function __construct(
43+
private readonly LoaderInterface $loader,
44+
private readonly int $pageSize = 20,
45+
) {
46+
}
47+
48+
public function supports(Request $request): bool
49+
{
50+
return $request instanceof ListToolsRequest || $request instanceof ListResourcesRequest;
51+
}
52+
53+
/**
54+
* @return Response<ListToolsResult|ListResourcesResult>
55+
*/
56+
public function handle(Request $request, SessionInterface $session): Response
57+
{
58+
$registry = new Registry();
59+
$this->loader->load($registry);
60+
61+
if ($request instanceof ListResourcesRequest) {
62+
$page = $registry->getResources($this->pageSize, $request->cursor);
63+
$result = new ListResourcesResult($page->references, $page->nextCursor);
64+
} else {
65+
\assert($request instanceof ListToolsRequest);
66+
$page = $registry->getTools($this->pageSize, $request->cursor);
67+
$result = new ListToolsResult($page->references, $page->nextCursor);
68+
}
69+
70+
return new Response($request->getId(), $result);
71+
}
72+
}
Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the API Platform project.
5+
*
6+
* (c) Kévin Dunglas <dunglas@gmail.com>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
declare(strict_types=1);
13+
14+
namespace ApiPlatform\Mcp\Tests\Server;
15+
16+
use ApiPlatform\JsonSchema\Schema;
17+
use ApiPlatform\JsonSchema\SchemaFactoryInterface;
18+
use ApiPlatform\Mcp\Capability\Registry\Loader;
19+
use ApiPlatform\Mcp\Server\ListHandler;
20+
use ApiPlatform\Metadata\ApiResource;
21+
use ApiPlatform\Metadata\McpResource;
22+
use ApiPlatform\Metadata\McpTool;
23+
use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface;
24+
use ApiPlatform\Metadata\Resource\Factory\ResourceNameCollectionFactoryInterface;
25+
use ApiPlatform\Metadata\Resource\ResourceMetadataCollection;
26+
use ApiPlatform\Metadata\Resource\ResourceNameCollection;
27+
use Mcp\Schema\Request\ListResourcesRequest;
28+
use Mcp\Schema\Request\ListToolsRequest;
29+
use Mcp\Schema\Result\ListResourcesResult;
30+
use Mcp\Schema\Result\ListToolsResult;
31+
use Mcp\Server\Session\SessionInterface;
32+
use PHPUnit\Framework\TestCase;
33+
34+
class ListHandlerTest extends TestCase
35+
{
36+
public function testListToolsIsBuiltFromMetadataAtRequestTime(): void
37+
{
38+
$inputSchema = new Schema(Schema::VERSION_JSON_SCHEMA);
39+
unset($inputSchema['$schema']);
40+
$inputSchema['type'] = 'object';
41+
$inputSchema['properties'] = ['query' => ['type' => 'string']];
42+
43+
$schemaFactory = $this->createMock(SchemaFactoryInterface::class);
44+
$schemaFactory->method('buildSchema')->willReturn($inputSchema);
45+
46+
$mcpTool = new McpTool(
47+
name: 'search',
48+
description: 'Search things',
49+
structuredContent: false,
50+
class: \stdClass::class,
51+
);
52+
53+
$resource = (new ApiResource(class: \stdClass::class))->withMcp(['search' => $mcpTool]);
54+
55+
$handler = new ListHandler($this->createLoader($resource, $schemaFactory));
56+
57+
$request = (new ListToolsRequest())->withId(1);
58+
$response = $handler->handle($request, $this->createMock(SessionInterface::class));
59+
60+
$result = $response->result;
61+
$this->assertInstanceOf(ListToolsResult::class, $result);
62+
$this->assertCount(1, $result->tools);
63+
$this->assertSame('search', $result->tools[0]->name);
64+
}
65+
66+
public function testListResourcesIsBuiltFromMetadataAtRequestTime(): void
67+
{
68+
$mcpResource = new McpResource(
69+
uri: 'dummy://docs',
70+
name: 'docs',
71+
description: 'Documentation resource',
72+
mimeType: 'text/plain',
73+
class: \stdClass::class,
74+
);
75+
76+
$resource = (new ApiResource(class: \stdClass::class))->withMcp(['docs' => $mcpResource]);
77+
78+
$handler = new ListHandler($this->createLoader($resource, $this->createMock(SchemaFactoryInterface::class)));
79+
80+
$request = (new ListResourcesRequest())->withId(1);
81+
$response = $handler->handle($request, $this->createMock(SessionInterface::class));
82+
83+
$result = $response->result;
84+
$this->assertInstanceOf(ListResourcesResult::class, $result);
85+
$this->assertCount(1, $result->resources);
86+
$this->assertSame('dummy://docs', $result->resources[0]->uri);
87+
}
88+
89+
public function testSupportsListRequests(): void
90+
{
91+
$handler = new ListHandler($this->createMock(\Mcp\Capability\Registry\Loader\LoaderInterface::class));
92+
93+
$this->assertTrue($handler->supports(new ListToolsRequest()));
94+
$this->assertTrue($handler->supports(new ListResourcesRequest()));
95+
}
96+
97+
private function createLoader(ApiResource $resource, SchemaFactoryInterface $schemaFactory): Loader
98+
{
99+
$nameCollectionFactory = $this->createMock(ResourceNameCollectionFactoryInterface::class);
100+
$nameCollectionFactory->method('create')->willReturn(new ResourceNameCollection([\stdClass::class]));
101+
102+
$metadataCollectionFactory = $this->createMock(ResourceMetadataCollectionFactoryInterface::class);
103+
$metadataCollectionFactory->method('create')->willReturn(new ResourceMetadataCollection(\stdClass::class, [$resource]));
104+
105+
return new Loader($nameCollectionFactory, $metadataCollectionFactory, $schemaFactory);
106+
}
107+
}

src/Symfony/Bundle/Resources/config/mcp/mcp.php

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
use ApiPlatform\Mcp\JsonSchema\SchemaFactory;
1818
use ApiPlatform\Mcp\Metadata\Operation\Factory\OperationMetadataFactory;
1919
use ApiPlatform\Mcp\Routing\IriConverter;
20+
use ApiPlatform\Mcp\Server\ListHandler;
2021
use ApiPlatform\Mcp\State\ToolProvider;
2122

2223
return static function (ContainerConfigurator $container) {
@@ -35,6 +36,15 @@
3536
])
3637
->tag('mcp.loader');
3738

39+
// Serves tools/list and resources/list from metadata at request time, so discovery survives
40+
// a persistent runtime (e.g. FrankenPHP worker mode) where the SDK registry is built once and
41+
// may capture an empty state. Takes precedence over the SDK's registry-backed list handlers.
42+
$services->set('api_platform.mcp.list_handler', ListHandler::class)
43+
->args([
44+
service('api_platform.mcp.loader'),
45+
])
46+
->tag('mcp.request_handler');
47+
3848
$services->set('api_platform.mcp.iri_converter', IriConverter::class)
3949
->decorate('api_platform.iri_converter', null, 300)
4050
->args([

0 commit comments

Comments
 (0)