Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
82 changes: 82 additions & 0 deletions src/Mcp/Server/ListHandler.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
<?php

/*
* This file is part of the API Platform project.
*
* (c) Kévin Dunglas <dunglas@gmail.com>
*
* 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
Comment thread
soyuka marked this conversation as resolved.
* TODO: remove once php-sdk:^0.7 has https://github.com/modelcontextprotocol/php-sdk/pull/389/changes
*
* @implements RequestHandlerInterface<ListToolsResult|ListResourcesResult>
*/
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<ListToolsResult|ListResourcesResult>
*/
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);
}
}
140 changes: 140 additions & 0 deletions src/Mcp/Tests/Server/ListHandlerTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
<?php

/*
* This file is part of the API Platform project.
*
* (c) Kévin Dunglas <dunglas@gmail.com>
*
* 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);
}
}
13 changes: 13 additions & 0 deletions src/Symfony/Bundle/Resources/config/mcp/mcp.php
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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([
Expand Down
Loading