Skip to content

Commit f9d3706

Browse files
authored
fix(mcp): make tools/list resilient to an empty registry (#8371)
* fix(mcp): make tools/list resilient to an empty registry tools/list reads from the SDK registry, which is populated once, when mcp.server is built. Under a persistent runtime (e.g. FrankenPHP worker mode) that single build can capture an empty registry and stay empty for the whole process, so tools/list returns [] while tools/call keeps working through the request-time Handler. Add a ListHandler (tagged mcp.request_handler, so it precedes the SDK's registry-backed list handlers) that loads API Platform elements into the registry on first use and reads back through the shared registry, so runtime registrations and registry decorators are preserved. Refs #8370 * Apply suggestions from code review Co-authored-by: Antoine Bluchet <soyuka@users.noreply.github.com>
1 parent a9c1b53 commit f9d3706

3 files changed

Lines changed: 235 additions & 0 deletions

File tree

src/Mcp/Server/ListHandler.php

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
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\Loader\LoaderInterface;
17+
use Mcp\Capability\RegistryInterface;
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+
* Serves tools/list and resources/list from the MCP registry, loading API Platform elements
29+
* into it on first use.
30+
*
31+
* The SDK populates the registry once, when mcp.server is built. Under a persistent runtime
32+
* (e.g. FrankenPHP worker mode) that single build can capture an empty registry (cold metadata
33+
* cache) and stays empty for the whole process, so tools/list returns [] while tools/call keeps
34+
* working through the request-time {@see Handler}. Loading the API Platform elements lazily here
35+
* heals that: it runs once per process (registrations are idempotent by name) and reads back
36+
* through the shared registry, so runtime registrations and registry decorators are preserved.
37+
*
38+
* Tagged mcp.request_handler, it takes precedence over the SDK's registry-backed list handlers.
39+
*
40+
* @experimental
41+
* TODO: remove once php-sdk:^0.7 has https://github.com/modelcontextprotocol/php-sdk/pull/389/changes
42+
*
43+
* @implements RequestHandlerInterface<ListToolsResult|ListResourcesResult>
44+
*/
45+
final class ListHandler implements RequestHandlerInterface
46+
{
47+
private bool $loaded = false;
48+
49+
public function __construct(
50+
private readonly RegistryInterface $registry,
51+
private readonly LoaderInterface $loader,
52+
private readonly int $pageSize = 20,
53+
) {
54+
}
55+
56+
public function supports(Request $request): bool
57+
{
58+
return $request instanceof ListToolsRequest || $request instanceof ListResourcesRequest;
59+
}
60+
61+
/**
62+
* @return Response<ListToolsResult|ListResourcesResult>
63+
*/
64+
public function handle(Request $request, SessionInterface $session): Response
65+
{
66+
if (!$this->loaded) {
67+
$this->loader->load($this->registry);
68+
$this->loaded = true;
69+
}
70+
71+
if ($request instanceof ListResourcesRequest) {
72+
$page = $this->registry->getResources($this->pageSize, $request->cursor);
73+
$result = new ListResourcesResult($page->references, $page->nextCursor);
74+
} else {
75+
\assert($request instanceof ListToolsRequest);
76+
$page = $this->registry->getTools($this->pageSize, $request->cursor);
77+
$result = new ListToolsResult($page->references, $page->nextCursor);
78+
}
79+
80+
return new Response($request->getId(), $result);
81+
}
82+
}
Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
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\Capability\Registry;
28+
use Mcp\Capability\Registry\Loader\LoaderInterface;
29+
use Mcp\Capability\RegistryInterface;
30+
use Mcp\Schema\Request\ListResourcesRequest;
31+
use Mcp\Schema\Request\ListToolsRequest;
32+
use Mcp\Schema\Result\ListResourcesResult;
33+
use Mcp\Schema\Result\ListToolsResult;
34+
use Mcp\Schema\Tool;
35+
use Mcp\Server\Session\SessionInterface;
36+
use PHPUnit\Framework\TestCase;
37+
38+
class ListHandlerTest extends TestCase
39+
{
40+
public function testListToolsLoadsApiPlatformElementsIntoTheRegistry(): void
41+
{
42+
$inputSchema = new Schema(Schema::VERSION_JSON_SCHEMA);
43+
unset($inputSchema['$schema']);
44+
$inputSchema['type'] = 'object';
45+
$inputSchema['properties'] = ['query' => ['type' => 'string']];
46+
47+
$schemaFactory = $this->createMock(SchemaFactoryInterface::class);
48+
$schemaFactory->method('buildSchema')->willReturn($inputSchema);
49+
50+
$mcpTool = new McpTool(
51+
name: 'search',
52+
description: 'Search things',
53+
structuredContent: false,
54+
class: \stdClass::class,
55+
);
56+
57+
$resource = (new ApiResource(class: \stdClass::class))->withMcp(['search' => $mcpTool]);
58+
59+
$registry = new Registry();
60+
$handler = new ListHandler($registry, $this->createLoader($resource, $schemaFactory));
61+
62+
$result = $handler->handle((new ListToolsRequest())->withId(1), $this->createMock(SessionInterface::class))->result;
63+
64+
$this->assertInstanceOf(ListToolsResult::class, $result);
65+
$this->assertCount(1, $result->tools);
66+
$this->assertSame('search', $result->tools[0]->name);
67+
}
68+
69+
public function testListResourcesLoadsApiPlatformElementsIntoTheRegistry(): void
70+
{
71+
$mcpResource = new McpResource(
72+
uri: 'dummy://docs',
73+
name: 'docs',
74+
description: 'Documentation resource',
75+
mimeType: 'text/plain',
76+
class: \stdClass::class,
77+
);
78+
79+
$resource = (new ApiResource(class: \stdClass::class))->withMcp(['docs' => $mcpResource]);
80+
81+
$registry = new Registry();
82+
$handler = new ListHandler($registry, $this->createLoader($resource, $this->createMock(SchemaFactoryInterface::class)));
83+
84+
$result = $handler->handle((new ListResourcesRequest())->withId(1), $this->createMock(SessionInterface::class))->result;
85+
86+
$this->assertInstanceOf(ListResourcesResult::class, $result);
87+
$this->assertCount(1, $result->resources);
88+
$this->assertSame('dummy://docs', $result->resources[0]->uri);
89+
}
90+
91+
/**
92+
* Reading through the shared registry (rather than a private one) keeps tools registered at
93+
* runtime — e.g. dynamically discovered affordances — visible in tools/list.
94+
*/
95+
public function testListToolsIncludesToolsRegisteredAtRuntime(): void
96+
{
97+
$registry = new Registry();
98+
$registry->registerTool(new Tool(name: 'runtime_tool', title: null, inputSchema: ['type' => 'object', 'properties' => [], 'required' => null], description: null, annotations: null), 'runtime_handler');
99+
100+
$loader = $this->createMock(LoaderInterface::class);
101+
$handler = new ListHandler($registry, $loader);
102+
103+
$result = $handler->handle((new ListToolsRequest())->withId(1), $this->createMock(SessionInterface::class))->result;
104+
105+
$names = array_map(static fn (Tool $t): string => $t->name, $result->tools);
106+
$this->assertContains('runtime_tool', $names);
107+
}
108+
109+
public function testElementsAreLoadedOncePerProcess(): void
110+
{
111+
$registry = $this->createMock(RegistryInterface::class);
112+
$registry->method('getTools')->willReturn(new \Mcp\Schema\Page([], null));
113+
114+
$loader = $this->createMock(LoaderInterface::class);
115+
$loader->expects($this->once())->method('load');
116+
117+
$handler = new ListHandler($registry, $loader);
118+
$handler->handle((new ListToolsRequest())->withId(1), $this->createMock(SessionInterface::class));
119+
$handler->handle((new ListToolsRequest())->withId(2), $this->createMock(SessionInterface::class));
120+
}
121+
122+
public function testSupportsListRequests(): void
123+
{
124+
$handler = new ListHandler($this->createMock(RegistryInterface::class), $this->createMock(LoaderInterface::class));
125+
126+
$this->assertTrue($handler->supports(new ListToolsRequest()));
127+
$this->assertTrue($handler->supports(new ListResourcesRequest()));
128+
}
129+
130+
private function createLoader(ApiResource $resource, SchemaFactoryInterface $schemaFactory): Loader
131+
{
132+
$nameCollectionFactory = $this->createMock(ResourceNameCollectionFactoryInterface::class);
133+
$nameCollectionFactory->method('create')->willReturn(new ResourceNameCollection([\stdClass::class]));
134+
135+
$metadataCollectionFactory = $this->createMock(ResourceMetadataCollectionFactoryInterface::class);
136+
$metadataCollectionFactory->method('create')->willReturn(new ResourceMetadataCollection(\stdClass::class, [$resource]));
137+
138+
return new Loader($nameCollectionFactory, $metadataCollectionFactory, $schemaFactory);
139+
}
140+
}

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

Lines changed: 13 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,18 @@
3536
])
3637
->tag('mcp.loader');
3738

39+
// Serves tools/list and resources/list, loading API Platform elements into the registry on
40+
// first use. This heals a persistent runtime (e.g. FrankenPHP worker mode) where the SDK
41+
// builds the registry once and may capture an empty state. Reads back through the shared
42+
// registry so runtime registrations and decorators are preserved. Takes precedence over the
43+
// SDK's registry-backed list handlers.
44+
$services->set('api_platform.mcp.list_handler', ListHandler::class)
45+
->args([
46+
service('mcp.registry'),
47+
service('api_platform.mcp.loader'),
48+
])
49+
->tag('mcp.request_handler');
50+
3851
$services->set('api_platform.mcp.iri_converter', IriConverter::class)
3952
->decorate('api_platform.iri_converter', null, 300)
4053
->args([

0 commit comments

Comments
 (0)