From 5544a15d3c2340c62441ef226034510431b37ac6 Mon Sep 17 00:00:00 2001 From: soyuka Date: Tue, 30 Jun 2026 22:37:17 +0200 Subject: [PATCH 1/2] 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 --- src/Mcp/Server/ListHandler.php | 81 ++++++++++ src/Mcp/Tests/Server/ListHandlerTest.php | 140 ++++++++++++++++++ .../Bundle/Resources/config/mcp/mcp.php | 13 ++ 3 files changed, 234 insertions(+) create mode 100644 src/Mcp/Server/ListHandler.php create mode 100644 src/Mcp/Tests/Server/ListHandlerTest.php diff --git a/src/Mcp/Server/ListHandler.php b/src/Mcp/Server/ListHandler.php new file mode 100644 index 00000000000..4649e445bed --- /dev/null +++ b/src/Mcp/Server/ListHandler.php @@ -0,0 +1,81 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Mcp\Server; + +use Mcp\Capability\Registry\Loader\LoaderInterface; +use Mcp\Capability\RegistryInterface; +use Mcp\Schema\JsonRpc\Request; +use Mcp\Schema\JsonRpc\Response; +use Mcp\Schema\Request\ListResourcesRequest; +use Mcp\Schema\Request\ListToolsRequest; +use Mcp\Schema\Result\ListResourcesResult; +use Mcp\Schema\Result\ListToolsResult; +use Mcp\Server\Handler\Request\RequestHandlerInterface; +use Mcp\Server\Session\SessionInterface; + +/** + * Serves tools/list and resources/list from the MCP registry, loading API Platform elements + * into it on first use. + * + * The SDK populates the registry once, when mcp.server is built. Under a persistent runtime + * (e.g. 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 [] while tools/call keeps + * working through the request-time {@see Handler}. Loading the API Platform elements lazily here + * heals that: it runs once per process (registrations are idempotent by name) and reads back + * through the shared registry, so runtime registrations and registry decorators are preserved. + * + * Tagged mcp.request_handler, it takes precedence over the SDK's registry-backed list handlers. + * + * @experimental + * + * @implements RequestHandlerInterface + */ +final class ListHandler implements RequestHandlerInterface +{ + private bool $loaded = false; + + public function __construct( + private readonly RegistryInterface $registry, + private readonly LoaderInterface $loader, + private readonly int $pageSize = 20, + ) { + } + + public function supports(Request $request): bool + { + return $request instanceof ListToolsRequest || $request instanceof ListResourcesRequest; + } + + /** + * @return Response + */ + public function handle(Request $request, SessionInterface $session): Response + { + if (!$this->loaded) { + $this->loader->load($this->registry); + $this->loaded = true; + } + + if ($request instanceof ListResourcesRequest) { + $page = $this->registry->getResources($this->pageSize, $request->cursor); + $result = new ListResourcesResult($page->references, $page->nextCursor); + } else { + \assert($request instanceof ListToolsRequest); + $page = $this->registry->getTools($this->pageSize, $request->cursor); + $result = new ListToolsResult($page->references, $page->nextCursor); + } + + return new Response($request->getId(), $result); + } +} diff --git a/src/Mcp/Tests/Server/ListHandlerTest.php b/src/Mcp/Tests/Server/ListHandlerTest.php new file mode 100644 index 00000000000..a8bfd4bdf4a --- /dev/null +++ b/src/Mcp/Tests/Server/ListHandlerTest.php @@ -0,0 +1,140 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Mcp\Tests\Server; + +use ApiPlatform\JsonSchema\Schema; +use ApiPlatform\JsonSchema\SchemaFactoryInterface; +use ApiPlatform\Mcp\Capability\Registry\Loader; +use ApiPlatform\Mcp\Server\ListHandler; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\McpResource; +use ApiPlatform\Metadata\McpTool; +use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface; +use ApiPlatform\Metadata\Resource\Factory\ResourceNameCollectionFactoryInterface; +use ApiPlatform\Metadata\Resource\ResourceMetadataCollection; +use ApiPlatform\Metadata\Resource\ResourceNameCollection; +use Mcp\Capability\Registry; +use Mcp\Capability\Registry\Loader\LoaderInterface; +use Mcp\Capability\RegistryInterface; +use Mcp\Schema\Request\ListResourcesRequest; +use Mcp\Schema\Request\ListToolsRequest; +use Mcp\Schema\Result\ListResourcesResult; +use Mcp\Schema\Result\ListToolsResult; +use Mcp\Schema\Tool; +use Mcp\Server\Session\SessionInterface; +use PHPUnit\Framework\TestCase; + +class ListHandlerTest extends TestCase +{ + public function testListToolsLoadsApiPlatformElementsIntoTheRegistry(): void + { + $inputSchema = new Schema(Schema::VERSION_JSON_SCHEMA); + unset($inputSchema['$schema']); + $inputSchema['type'] = 'object'; + $inputSchema['properties'] = ['query' => ['type' => 'string']]; + + $schemaFactory = $this->createMock(SchemaFactoryInterface::class); + $schemaFactory->method('buildSchema')->willReturn($inputSchema); + + $mcpTool = new McpTool( + name: 'search', + description: 'Search things', + structuredContent: false, + class: \stdClass::class, + ); + + $resource = (new ApiResource(class: \stdClass::class))->withMcp(['search' => $mcpTool]); + + $registry = new Registry(); + $handler = new ListHandler($registry, $this->createLoader($resource, $schemaFactory)); + + $result = $handler->handle((new ListToolsRequest())->withId(1), $this->createMock(SessionInterface::class))->result; + + $this->assertInstanceOf(ListToolsResult::class, $result); + $this->assertCount(1, $result->tools); + $this->assertSame('search', $result->tools[0]->name); + } + + public function testListResourcesLoadsApiPlatformElementsIntoTheRegistry(): void + { + $mcpResource = new McpResource( + uri: 'dummy://docs', + name: 'docs', + description: 'Documentation resource', + mimeType: 'text/plain', + class: \stdClass::class, + ); + + $resource = (new ApiResource(class: \stdClass::class))->withMcp(['docs' => $mcpResource]); + + $registry = new Registry(); + $handler = new ListHandler($registry, $this->createLoader($resource, $this->createMock(SchemaFactoryInterface::class))); + + $result = $handler->handle((new ListResourcesRequest())->withId(1), $this->createMock(SessionInterface::class))->result; + + $this->assertInstanceOf(ListResourcesResult::class, $result); + $this->assertCount(1, $result->resources); + $this->assertSame('dummy://docs', $result->resources[0]->uri); + } + + /** + * Reading through the shared registry (rather than a private one) keeps tools registered at + * runtime — e.g. dynamically discovered affordances — visible in tools/list. + */ + public function testListToolsIncludesToolsRegisteredAtRuntime(): void + { + $registry = new Registry(); + $registry->registerTool(new Tool(name: 'runtime_tool', title: null, inputSchema: ['type' => 'object', 'properties' => [], 'required' => null], description: null, annotations: null), 'runtime_handler'); + + $loader = $this->createMock(LoaderInterface::class); + $handler = new ListHandler($registry, $loader); + + $result = $handler->handle((new ListToolsRequest())->withId(1), $this->createMock(SessionInterface::class))->result; + + $names = array_map(static fn (Tool $t): string => $t->name, $result->tools); + $this->assertContains('runtime_tool', $names); + } + + public function testElementsAreLoadedOncePerProcess(): void + { + $registry = $this->createMock(RegistryInterface::class); + $registry->method('getTools')->willReturn(new \Mcp\Schema\Page([], null)); + + $loader = $this->createMock(LoaderInterface::class); + $loader->expects($this->once())->method('load'); + + $handler = new ListHandler($registry, $loader); + $handler->handle((new ListToolsRequest())->withId(1), $this->createMock(SessionInterface::class)); + $handler->handle((new ListToolsRequest())->withId(2), $this->createMock(SessionInterface::class)); + } + + public function testSupportsListRequests(): void + { + $handler = new ListHandler($this->createMock(RegistryInterface::class), $this->createMock(LoaderInterface::class)); + + $this->assertTrue($handler->supports(new ListToolsRequest())); + $this->assertTrue($handler->supports(new ListResourcesRequest())); + } + + private function createLoader(ApiResource $resource, SchemaFactoryInterface $schemaFactory): Loader + { + $nameCollectionFactory = $this->createMock(ResourceNameCollectionFactoryInterface::class); + $nameCollectionFactory->method('create')->willReturn(new ResourceNameCollection([\stdClass::class])); + + $metadataCollectionFactory = $this->createMock(ResourceMetadataCollectionFactoryInterface::class); + $metadataCollectionFactory->method('create')->willReturn(new ResourceMetadataCollection(\stdClass::class, [$resource])); + + return new Loader($nameCollectionFactory, $metadataCollectionFactory, $schemaFactory); + } +} diff --git a/src/Symfony/Bundle/Resources/config/mcp/mcp.php b/src/Symfony/Bundle/Resources/config/mcp/mcp.php index 97e042661b3..4740dde1f38 100644 --- a/src/Symfony/Bundle/Resources/config/mcp/mcp.php +++ b/src/Symfony/Bundle/Resources/config/mcp/mcp.php @@ -17,6 +17,7 @@ use ApiPlatform\Mcp\JsonSchema\SchemaFactory; use ApiPlatform\Mcp\Metadata\Operation\Factory\OperationMetadataFactory; use ApiPlatform\Mcp\Routing\IriConverter; +use ApiPlatform\Mcp\Server\ListHandler; use ApiPlatform\Mcp\State\ToolProvider; return static function (ContainerConfigurator $container) { @@ -35,6 +36,18 @@ ]) ->tag('mcp.loader'); + // Serves tools/list and resources/list, loading API Platform elements into the registry on + // first use. This heals a persistent runtime (e.g. FrankenPHP worker mode) where the SDK + // builds the registry once and may capture an empty state. Reads back through the shared + // registry so runtime registrations and decorators are preserved. Takes precedence over the + // SDK's registry-backed list handlers. + $services->set('api_platform.mcp.list_handler', ListHandler::class) + ->args([ + service('mcp.registry'), + service('api_platform.mcp.loader'), + ]) + ->tag('mcp.request_handler'); + $services->set('api_platform.mcp.iri_converter', IriConverter::class) ->decorate('api_platform.iri_converter', null, 300) ->args([ From 22b15c0994d9cd6874673f0b6654fa90ccc5f0fc Mon Sep 17 00:00:00 2001 From: Antoine Bluchet Date: Wed, 1 Jul 2026 08:28:53 +0200 Subject: [PATCH 2/2] Apply suggestions from code review Co-authored-by: Antoine Bluchet --- src/Mcp/Server/ListHandler.php | 1 + 1 file changed, 1 insertion(+) diff --git a/src/Mcp/Server/ListHandler.php b/src/Mcp/Server/ListHandler.php index 4649e445bed..3ac710dc8d1 100644 --- a/src/Mcp/Server/ListHandler.php +++ b/src/Mcp/Server/ListHandler.php @@ -38,6 +38,7 @@ * Tagged mcp.request_handler, it takes precedence over the SDK's registry-backed list handlers. * * @experimental + * TODO: remove once php-sdk:^0.7 has https://github.com/modelcontextprotocol/php-sdk/pull/389/changes * * @implements RequestHandlerInterface */