From 6320bec49ce66ce2967cea579bfa8e7a547e0a42 Mon Sep 17 00:00:00 2001 From: soyuka Date: Tue, 27 Jan 2026 14:50:17 +0100 Subject: [PATCH] feat(laravel): mcp support --- src/Laravel/ApiPlatformDeferredProvider.php | 12 +- src/Laravel/ApiPlatformProvider.php | 148 ++++++- .../SerializerClassMetadataFactory.php.bak | 40 -- src/Laravel/Tests/McpTest.php | 411 ++++++++++++++++++ src/Laravel/composer.json | 16 +- src/Laravel/config/api-platform.php | 9 +- src/Laravel/routes/api.php | 18 + .../app/ApiResource/McpToolAttribute.php | 58 +++ .../workbench/app/ApiResource/McpTools.php | 116 +++++ .../app/ApiResource/McpWithMarkdown.php | 86 ++++ src/Laravel/workbench/app/Models/McpBook.php | 89 ++++ ...26_01_27_000000_create_mcp_books_table.php | 34 ++ src/Mcp/Server/Handler.php | 3 +- .../Bundle/Resources/config/mcp/mcp.php | 4 +- 14 files changed, 992 insertions(+), 52 deletions(-) delete mode 100644 src/Laravel/Eloquent/Serializer/SerializerClassMetadataFactory.php.bak create mode 100644 src/Laravel/Tests/McpTest.php create mode 100644 src/Laravel/workbench/app/ApiResource/McpToolAttribute.php create mode 100644 src/Laravel/workbench/app/ApiResource/McpTools.php create mode 100644 src/Laravel/workbench/app/ApiResource/McpWithMarkdown.php create mode 100644 src/Laravel/workbench/app/Models/McpBook.php create mode 100644 src/Laravel/workbench/database/migrations/2026_01_27_000000_create_mcp_books_table.php diff --git a/src/Laravel/ApiPlatformDeferredProvider.php b/src/Laravel/ApiPlatformDeferredProvider.php index 17e771d6d48..265c8afa6ac 100644 --- a/src/Laravel/ApiPlatformDeferredProvider.php +++ b/src/Laravel/ApiPlatformDeferredProvider.php @@ -49,6 +49,7 @@ use ApiPlatform\Laravel\State\ParameterValidatorProvider; use ApiPlatform\Laravel\State\SwaggerUiProcessor; use ApiPlatform\Laravel\State\ValidateProvider; +use ApiPlatform\Mcp\State\ToolProvider; use ApiPlatform\Metadata\IdentifiersExtractorInterface; use ApiPlatform\Metadata\InflectorInterface; use ApiPlatform\Metadata\Laravel\SkipAutoconfigure; @@ -198,7 +199,15 @@ public function register(): void return new CallableProvider(new ServiceLocator($tagged)); }); - $this->autoconfigure($classes, ProviderInterface::class, [ItemProvider::class, CollectionProvider::class, ErrorProvider::class]); + if (class_exists(ToolProvider::class)) { + $this->app->singleton(ToolProvider::class, static function (Application $app) { + return new ToolProvider( + $app->make('api_platform.object_mapper') + ); + }); + } + + $this->autoconfigure($classes, ProviderInterface::class, [ItemProvider::class, CollectionProvider::class, ErrorProvider::class, ToolProvider::class]); $this->app->singleton(ResourceMetadataCollectionFactoryInterface::class, function (Application $app) { /** @var ConfigRepository $config */ @@ -366,6 +375,7 @@ public function provides(): array 'api_platform.graphql.state_provider.parameter', FieldsBuilderEnumInterface::class, ExceptionHandlerInterface::class, + ToolProvider::class, ]; } } diff --git a/src/Laravel/ApiPlatformProvider.php b/src/Laravel/ApiPlatformProvider.php index 8404741c22f..2da845f8e55 100644 --- a/src/Laravel/ApiPlatformProvider.php +++ b/src/Laravel/ApiPlatformProvider.php @@ -106,6 +106,11 @@ use ApiPlatform\Laravel\State\SwaggerUiProcessor; use ApiPlatform\Laravel\State\SwaggerUiProvider; use ApiPlatform\Laravel\State\ValidateProvider; +use ApiPlatform\Mcp\Capability\Registry\Loader as McpLoader; +use ApiPlatform\Mcp\Metadata\Operation\Factory\OperationMetadataFactory as McpOperationMetadataFactory; +use ApiPlatform\Mcp\Routing\IriConverter as McpIriConverter; +use ApiPlatform\Mcp\Server\Handler; +use ApiPlatform\Mcp\State\StructuredContentProcessor; use ApiPlatform\Metadata\IdentifiersExtractor; use ApiPlatform\Metadata\IdentifiersExtractorInterface; use ApiPlatform\Metadata\InflectorInterface; @@ -163,13 +168,22 @@ use ApiPlatform\State\Provider\ReadProvider; use ApiPlatform\State\ProviderInterface; use ApiPlatform\State\SerializerContextBuilderInterface; +use Http\Discovery\Psr17Factory; use Illuminate\Config\Repository as ConfigRepository; use Illuminate\Contracts\Foundation\Application; use Illuminate\Routing\Router; use Illuminate\Support\ServiceProvider; +use Mcp\Capability\Registry; +use Mcp\Server; +use Mcp\Server\Builder; +use Mcp\Server\Session\InMemorySessionStore; use Negotiation\Negotiator; use PHPStan\PhpDocParser\Parser\PhpDocParser; use Psr\Log\LoggerInterface; +use Symfony\AI\McpBundle\Controller\McpController; +use Symfony\Bridge\PsrHttpMessage\Factory\HttpFoundationFactory; +use Symfony\Bridge\PsrHttpMessage\Factory\PsrHttpFactory; +use Symfony\Component\HttpFoundation\RequestStack; use Symfony\Component\ObjectMapper\Metadata\ObjectMapperMetadataFactoryInterface; use Symfony\Component\ObjectMapper\Metadata\ReflectionObjectMapperMetadataFactory; use Symfony\Component\ObjectMapper\ObjectMapperInterface; @@ -425,11 +439,15 @@ public function register(): void if (interface_exists(ObjectMapperInterface::class)) { $this->app->singleton(ObjectMapperMetadataFactoryInterface::class, ReflectionObjectMapperMetadataFactory::class); - $this->app->singleton(ObjectMapper::class, static function (Application $app) { - if (!$app->bound('api_platform.object_mapper')) { - return null; - } + // Register Symfony ObjectMapper + $this->app->singleton('api_platform.object_mapper', static function (Application $app) { + return new \Symfony\Component\ObjectMapper\ObjectMapper( + $app->make(ObjectMapperMetadataFactoryInterface::class) + ); + }); + // Register API Platform ObjectMapper wrapper + $this->app->singleton(ObjectMapper::class, static function (Application $app) { return new ObjectMapper($app->make('api_platform.object_mapper')); }); @@ -884,6 +902,8 @@ public function register(): void $this->registerGraphQl(); } + $this->registerMcp(); + $this->app->singleton(JsonApiEntrypointNormalizer::class, static function (Application $app) { return new JsonApiEntrypointNormalizer( $app->make(ResourceMetadataCollectionFactoryInterface::class), @@ -1032,6 +1052,126 @@ public function register(): void } } + private function registerMcp(): void + { + if (!class_exists(McpController::class)) { + return; + } + + $this->app->singleton(Registry::class, static function (Application $app) { + return new Registry( + null, // event dispatcher (todo) + $app->make(LoggerInterface::class) + ); + }); + + $this->app->singleton(McpLoader::class, static function (Application $app) { + return new McpLoader( + $app->make(ResourceNameCollectionFactoryInterface::class), + $app->make(ResourceMetadataCollectionFactoryInterface::class), + $app->make(SchemaFactoryInterface::class) + ); + }); + $this->app->tag(McpLoader::class, 'mcp.loader'); + + // TODO: add more stores? + $this->app->singleton('mcp.session.store', static function () { + return new InMemorySessionStore(3600); + }); + + $this->app->singleton(Builder::class, static function (Application $app) { + $config = $app['config']; + + $builder = Server::builder() + ->setServerInfo( + $config->get('api-platform.title', 'API Platform'), + $config->get('api-platform.version', '1.0.0'), + $config->get('api-platform.description', 'My awesome API'), + [], // icons + null // website_url todo + ) + ->setPaginationLimit(100) + ->setRegistry($app->make(Registry::class)) + ->setSession($app->make('mcp.session.store')); + + foreach ($app->tagged('mcp.loader') as $loader) { + $builder->addLoader($loader); + } + + $builder->addRequestHandler($app->make(Handler::class)); + + return $builder; + }); + + $this->app->singleton(Server::class, static function (Application $app) { + return $app->make(Builder::class)->build(); + }); + + $this->app->singleton(McpOperationMetadataFactory::class, static function (Application $app) { + return new McpOperationMetadataFactory( + $app->make(ResourceNameCollectionFactoryInterface::class), + $app->make(ResourceMetadataCollectionFactoryInterface::class) + ); + }); + + $this->app->singleton('api_platform.mcp.state_processor.write', static function (Application $app) { + return new WriteProcessor( + null, + $app->make(CallableProcessor::class) + ); + }); + + $this->app->singleton(StructuredContentProcessor::class, static function (Application $app) { + return new StructuredContentProcessor( + $app->make(SerializerInterface::class), + $app->make(SerializerContextBuilderInterface::class), + $app->make('api_platform.mcp.state_processor.write') + ); + }); + + $this->app->singleton(RequestStack::class, static function (Application $app) { + return new RequestStack(); + }); + + $this->app->singleton(Handler::class, static function (Application $app) { + return new Handler( + $app->make(McpOperationMetadataFactory::class), + $app->make(ProviderInterface::class), + $app->make(StructuredContentProcessor::class), + $app->make(RequestStack::class), + $app->make(LoggerInterface::class) + ); + }); + + $this->app->extend(IriConverterInterface::class, static function (IriConverterInterface $iriConverter) { + return new McpIriConverter($iriConverter); + }); + + // Register Symfony MCP controller + $this->app->singleton(McpController::class, static function (Application $app) { + if (!class_exists('Http\Discovery\Psr17Factory')) { + throw new \RuntimeException('PSR-17 HTTP factory implementation not available. Please install php-http/discovery.'); + } + + $psr17Factory = new Psr17Factory(); + $psrHttpFactory = new PsrHttpFactory( + $psr17Factory, + $psr17Factory, + $psr17Factory, + $psr17Factory + ); + $httpFoundationFactory = new HttpFoundationFactory(); + + return new McpController( + $app->make(Server::class), + $psrHttpFactory, + $httpFoundationFactory, + $psr17Factory, + $psr17Factory + ); + }); + } + private function registerGraphQl(): void { $this->app->singleton(GraphQlItemNormalizer::class, static function (Application $app) { diff --git a/src/Laravel/Eloquent/Serializer/SerializerClassMetadataFactory.php.bak b/src/Laravel/Eloquent/Serializer/SerializerClassMetadataFactory.php.bak deleted file mode 100644 index 60d80d34f62..00000000000 --- a/src/Laravel/Eloquent/Serializer/SerializerClassMetadataFactory.php.bak +++ /dev/null @@ -1,40 +0,0 @@ - - * - * 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\Laravel\Eloquent\Serializer; - -use Symfony\Component\Serializer\Mapping\ClassMetadataInterface; -use Symfony\Component\Serializer\Mapping\Factory\ClassMetadataFactoryInterface; - -class SerializerClassMetadataFactory implements ClassMetadataFactoryInterface -{ - public function __construct(private readonly ClassMetadataFactoryInterface $decorated) - { - } - - /** - * {@inheritdoc} - */ - public function getMetadataFor($value): ClassMetadataInterface - { - return $this->decorated->getMetadataFor($value); - } - - /** - * {@inheritdoc} - */ - public function hasMetadataFor(mixed $value): bool - { - return $this->decorated->hasMetadataFor($value); - } -} diff --git a/src/Laravel/Tests/McpTest.php b/src/Laravel/Tests/McpTest.php new file mode 100644 index 00000000000..a25cf181fda --- /dev/null +++ b/src/Laravel/Tests/McpTest.php @@ -0,0 +1,411 @@ + + * + * 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\Laravel\Tests; + +use Illuminate\Foundation\Testing\RefreshDatabase; +use Illuminate\Testing\TestResponse; +use Orchestra\Testbench\Concerns\WithWorkbench; +use Orchestra\Testbench\TestCase; +use Symfony\AI\McpBundle\McpBundle; +use Symfony\Component\HttpFoundation\Response; + +class McpTest extends TestCase +{ + use RefreshDatabase; + use WithWorkbench; + + private function isPsr17FactoryAvailable(): bool + { + try { + if (!class_exists('Http\Discovery\Psr17FactoryDiscovery')) { + return false; + } + + \Http\Discovery\Psr17FactoryDiscovery::findServerRequestFactory(); + + return true; + } catch (\Throwable) { + return false; + } + } + + /** + * @param array $arguments + * + * @return TestResponse + */ + private function callTool(string $sessionId, string $toolName, array $arguments = []): TestResponse + { + return $this->postJson('/mcp', [ + 'jsonrpc' => '2.0', + 'id' => 2, + 'method' => 'tools/call', + 'params' => [ + 'name' => $toolName, + 'arguments' => $arguments, + ], + ], [ + 'Accept' => 'application/json, text/event-stream', + 'Content-Type' => 'application/json', + 'mcp-session-id' => $sessionId, + ]); + } + + private function initializeMcpSession(): string + { + $response = $this->postJson('/mcp', [ + 'jsonrpc' => '2.0', + 'id' => 1, + 'method' => 'initialize', + 'params' => [ + 'protocolVersion' => '2024-11-05', + 'clientInfo' => [ + 'name' => 'ApiPlatform Test Suite', + 'version' => '1.0', + ], + 'capabilities' => [], + ], + ], [ + 'Accept' => 'application/json, text/event-stream', + 'Content-Type' => 'application/json', + ]); + + $response->assertStatus(200); + + return $response->headers->get('mcp-session-id'); + } + + public function testBasicProvider(): void + { + if (!class_exists(McpBundle::class)) { + $this->markTestSkipped('MCP bundle is not installed'); + } + + if (!$this->isPsr17FactoryAvailable()) { + $this->markTestSkipped('PSR-17 HTTP factory implementation not available (required for MCP)'); + } + + $sessionId = $this->initializeMcpSession(); + $response = $this->callTool($sessionId, 'get_book_info'); + + $response->assertStatus(200); + $result = $response->json(); + $this->assertArrayHasKey('result', $result); + $content = $result['result']['content'][0]['text'] ?? null; + $this->assertNotNull($content); + $this->assertStringContainsString('API Platform Guide', $content); + $this->assertStringContainsString('978-1234567890', $content); + } + + public function testBasicProcessor(): void + { + if (!class_exists(McpBundle::class)) { + $this->markTestSkipped('MCP bundle is not installed'); + } + + if (!$this->isPsr17FactoryAvailable()) { + $this->markTestSkipped('PSR-17 HTTP factory implementation not available (required for MCP)'); + } + + $sessionId = $this->initializeMcpSession(); + $response = $this->callTool($sessionId, 'update_book_status', [ + 'id' => null, + 'isbn' => '123', + 'title' => 'Test Book', + 'status' => 'pending', + ]); + + $result = $response->json(); + if (isset($result['error'])) { + $this->fail('MCP Error: '.json_encode($result['error'])); + } + $response->assertStatus(200); + $this->assertArrayHasKey('result', $result); + } + + public function testCustomResultWithoutMetadata(): void + { + if (!class_exists(McpBundle::class)) { + $this->markTestSkipped('MCP bundle is not installed'); + } + + if (!$this->isPsr17FactoryAvailable()) { + $this->markTestSkipped('PSR-17 HTTP factory implementation not available (required for MCP)'); + } + + $sessionId = $this->initializeMcpSession(); + $response = $this->callTool($sessionId, 'custom_result', [ + 'text' => 'Test content', + 'includeMetadata' => false, + 'name' => null, + 'email' => null, + 'age' => null, + ]); + + $result = $response->json(); + if (isset($result['error'])) { + $this->fail('MCP Error: '.json_encode($result['error'])); + } + $response->assertStatus(200); + $this->assertArrayHasKey('result', $result); + $content = $result['result']['content'][0]['text'] ?? null; + $this->assertEquals('Custom result: Test content', $content); + $this->assertNull($result['result']['_meta'] ?? null); + } + + public function testCustomResultWithMetadata(): void + { + if (!class_exists(McpBundle::class)) { + $this->markTestSkipped('MCP bundle is not installed'); + } + + if (!$this->isPsr17FactoryAvailable()) { + $this->markTestSkipped('PSR-17 HTTP factory implementation not available (required for MCP)'); + } + + $sessionId = $this->initializeMcpSession(); + $response = $this->callTool($sessionId, 'custom_result', [ + 'text' => 'Test with metadata', + 'includeMetadata' => true, + 'name' => null, + 'email' => null, + 'age' => null, + ]); + + $response->assertStatus(200); + $result = $response->json(); + $this->assertArrayHasKey('result', $result); + $content = $result['result']['content'][0]['text'] ?? null; + $this->assertEquals('Custom result: Test with metadata', $content); + $hasMeta = isset($result['result']['_meta']) || isset($result['result']['meta']) || isset($result['result']['structuredContent']); + $this->assertTrue($hasMeta, 'No metadata found in: '.json_encode(array_keys($result['result']))); + } + + public function testValidationFailure(): void + { + if (!class_exists(McpBundle::class)) { + $this->markTestSkipped('MCP bundle is not installed'); + } + + if (!$this->isPsr17FactoryAvailable()) { + $this->markTestSkipped('PSR-17 HTTP factory implementation not available (required for MCP)'); + } + + $sessionId = $this->initializeMcpSession(); + $response = $this->callTool($sessionId, 'validate_input', [ + 'name' => 'ab', + 'email' => 'invalid-email', + 'age' => -5, + 'text' => null, + 'includeMetadata' => null, + ]); + + $result = $response->json(); + if (422 === $response->getStatusCode()) { + $this->assertArrayHasKey('error', $result); + } else { + $response->assertStatus(200); + } + } + + public function testValidationSuccess(): void + { + if (!class_exists(McpBundle::class)) { + $this->markTestSkipped('MCP bundle is not installed'); + } + + if (!$this->isPsr17FactoryAvailable()) { + $this->markTestSkipped('PSR-17 HTTP factory implementation not available (required for MCP)'); + } + + $sessionId = $this->initializeMcpSession(); + $response = $this->callTool($sessionId, 'validate_input', [ + 'name' => 'John Doe', + 'email' => 'john@example.com', + 'age' => 30, + 'text' => null, + 'includeMetadata' => null, + ]); + + $response->assertStatus(200); + $result = $response->json(); + $this->assertArrayHasKey('result', $result); + $content = $result['result']['content'][0]['text'] ?? null; + $this->assertNotNull($content); + $this->assertStringContainsString('Valid: John Doe', $content); + } + + public function testMarkdownWithoutCodeBlock(): void + { + if (!class_exists(McpBundle::class)) { + $this->markTestSkipped('MCP bundle is not installed'); + } + + if (!$this->isPsr17FactoryAvailable()) { + $this->markTestSkipped('PSR-17 HTTP factory implementation not available (required for MCP)'); + } + + $sessionId = $this->initializeMcpSession(); + $response = $this->callTool($sessionId, 'generate_markdown', [ + 'title' => 'API Platform Guide', + 'content' => 'This is a comprehensive guide to using API Platform.', + 'includeCodeBlock' => false, + ]); + + $response->assertStatus(200); + $result = $response->json(); + $this->assertArrayHasKey('result', $result); + $content = $result['result']['content'][0]['text'] ?? null; + $this->assertNotNull($content, 'No text content in result'); + $this->assertStringContainsString('# API Platform Guide', $content); + $this->assertStringContainsString('This is a comprehensive guide to using API Platform.', $content); + $this->assertStringNotContainsString('```', $content); + $this->assertNull($result['result']['_meta'] ?? null); + $this->assertArrayNotHasKey('structuredContent', $result['result']); + } + + public function testMarkdownWithCodeBlock(): void + { + if (!class_exists(McpBundle::class)) { + $this->markTestSkipped('MCP bundle is not installed'); + } + + if (!$this->isPsr17FactoryAvailable()) { + $this->markTestSkipped('PSR-17 HTTP factory implementation not available (required for MCP)'); + } + + $sessionId = $this->initializeMcpSession(); + $response = $this->callTool($sessionId, 'generate_markdown', [ + 'title' => 'Code Example', + 'content' => 'Here is how to use the feature:', + 'includeCodeBlock' => true, + ]); + + $response->assertStatus(200); + $result = $response->json(); + $this->assertArrayHasKey('result', $result); + $content = $result['result']['content'][0]['text'] ?? null; + $this->assertNotNull($content); + $this->assertStringContainsString('# Code Example', $content); + $this->assertStringContainsString('Here is how to use the feature:', $content); + $this->assertStringContainsString('```php', $content); + $this->assertStringContainsString("echo 'Hello, World!';", $content); + $this->assertStringContainsString('```', $content); + $this->assertNull($result['result']['_meta'] ?? null); + $this->assertArrayNotHasKey('structuredContent', $result['result']); + } + + public function testToolsList(): void + { + if (!class_exists(McpBundle::class)) { + $this->markTestSkipped('MCP bundle is not installed'); + } + + if (!$this->isPsr17FactoryAvailable()) { + $this->markTestSkipped('PSR-17 HTTP factory implementation not available (required for MCP)'); + } + + $sessionId = $this->initializeMcpSession(); + $response = $this->postJson('/mcp', [ + 'jsonrpc' => '2.0', + 'id' => 2, + 'method' => 'tools/list', + ], [ + 'Accept' => 'application/json, text/event-stream', + 'Content-Type' => 'application/json', + 'mcp-session-id' => $sessionId, + ]); + + $data = $response->json(); + $this->assertArrayHasKey('result', $data); + $this->assertArrayHasKey('tools', $data['result']); + + $tools = $data['result']['tools']; + $toolNames = array_column($tools, 'name'); + + $this->assertContains('get_book_info', $toolNames); + $this->assertContains('update_book_status', $toolNames); + $this->assertContains('custom_result', $toolNames); + $this->assertContains('validate_input', $toolNames); + $this->assertContains('generate_markdown', $toolNames); + $this->assertContains('process_message', $toolNames); + + foreach ($tools as $tool) { + $this->assertArrayHasKey('name', $tool); + $this->assertArrayHasKey('inputSchema', $tool); + $this->assertEquals('object', $tool['inputSchema']['type']); + } + + $response->assertStatus(200); + } + + public function testMcpToolAttribute(): void + { + if (!class_exists(McpBundle::class)) { + $this->markTestSkipped('MCP bundle is not installed'); + } + + if (!$this->isPsr17FactoryAvailable()) { + $this->markTestSkipped('PSR-17 HTTP factory implementation not available (required for MCP)'); + } + + $sessionId = $this->initializeMcpSession(); + $response = $this->postJson('/mcp', [ + 'jsonrpc' => '2.0', + 'id' => 2, + 'method' => 'tools/list', + ], [ + 'Accept' => 'application/json, text/event-stream', + 'Content-Type' => 'application/json', + 'mcp-session-id' => $sessionId, + ]); + + $data = $response->json(); + $tools = $data['result']['tools']; + $processMessageTool = null; + foreach ($tools as $tool) { + if ('process_message' === $tool['name']) { + $processMessageTool = $tool; + break; + } + } + + $this->assertNotNull($processMessageTool); + $this->assertEquals('process_message', $processMessageTool['name']); + $this->assertEquals('Process a message with priority', $processMessageTool['description'] ?? null); + $this->assertArrayHasKey('inputSchema', $processMessageTool); + $this->assertEquals('object', $processMessageTool['inputSchema']['type']); + + $response = $this->postJson('/mcp', [ + 'jsonrpc' => '2.0', + 'id' => 3, + 'method' => 'tools/call', + 'params' => [ + 'name' => 'process_message', + 'arguments' => [ + 'message' => 'Hello World', + 'priority' => 5, + ], + ], + ], [ + 'Accept' => 'application/json, text/event-stream', + 'Content-Type' => 'application/json', + 'mcp-session-id' => $sessionId, + ]); + + $response->assertStatus(200); + $result = $response->json(); + $this->assertArrayHasKey('result', $result); + } +} diff --git a/src/Laravel/composer.json b/src/Laravel/composer.json index 92d1c1d880c..66306da5086 100644 --- a/src/Laravel/composer.json +++ b/src/Laravel/composer.json @@ -29,12 +29,13 @@ "require": { "php": ">=8.2", "api-platform/documentation": "^4.2", + "api-platform/hal": "^4.2.0-beta.1", "api-platform/hydra": "^4.2", "api-platform/json-api": "^4.2", - "api-platform/hal": "^4.2.0-beta.1", "api-platform/json-schema": "^4.2", "api-platform/jsonld": "^4.2", "api-platform/metadata": "^4.2", + "api-platform/mcp": "dev-main", "api-platform/openapi": "^4.2", "api-platform/serializer": "^4.2.4", "api-platform/state": "^4.2.4", @@ -58,11 +59,14 @@ "doctrine/dbal": "^4.0", "larastan/larastan": "^2.0 || ^3.0", "laravel/sanctum": "^4.0", + "mcp/sdk": "^0.3.0", "orchestra/testbench": "^10.1", "phpdocumentor/type-resolver": "^1.7", "phpstan/phpdoc-parser": "^1.29 || ^2.0", "phpunit/phpunit": "^12.2", - "symfony/http-client": "^7.4 || ^8.0" + "symfony/http-client": "^7.4 || ^8.0", + "symfony/mcp-bundle": "dev-main", + "symfony/object-mapper": "8.1.x-dev" }, "autoload": { "psr-4": { @@ -74,7 +78,10 @@ ] }, "config": { - "sort-packages": true + "sort-packages": true, + "allow-plugins": { + "php-http/discovery": true + } }, "suggest": { "api-platform/graphql": "Enable GraphQl support.", @@ -127,5 +134,6 @@ "lint": [ "@php vendor/bin/phpstan analyse --verbose --ansi" ] - } + }, + "minimum-stability": "dev" } diff --git a/src/Laravel/config/api-platform.php b/src/Laravel/config/api-platform.php index ecc875fb808..6a938e47b82 100644 --- a/src/Laravel/config/api-platform.php +++ b/src/Laravel/config/api-platform.php @@ -30,10 +30,12 @@ 'resources' => [ app_path('Models'), + app_path('ApiResource'), ], 'formats' => [ 'jsonld' => ['application/ld+json'], + 'json' => ['application/json'], // 'jsonapi' => ['application/vnd.api+json'], // 'csv' => ['text/csv'], ], @@ -132,7 +134,7 @@ // 'openapi' => [ // 'tags' => [], - // ], + // ], 'url_generation_strategy' => UrlGeneratorInterface::ABS_PATH, @@ -144,6 +146,11 @@ // we recommend using "file" or "acpu" 'cache' => 'file', + // MCP (Model Context Protocol) configuration + 'mcp' => [ + 'enabled' => true, + ], + // install `api-platform/http-cache` // 'http_cache' => [ // 'etag' => false, diff --git a/src/Laravel/routes/api.php b/src/Laravel/routes/api.php index 2b060075b1f..eb569ad675b 100644 --- a/src/Laravel/routes/api.php +++ b/src/Laravel/routes/api.php @@ -24,6 +24,7 @@ use ApiPlatform\Metadata\Resource\Factory\ResourceNameCollectionFactoryInterface; use ApiPlatform\OpenApi\Attributes\Webhook; use Illuminate\Support\Facades\Route; +use Symfony\AI\McpBundle\Controller\McpController; $globalMiddlewares = config()->get('api-platform.routes.middleware', []); $domain = config()->get('api-platform.routes.domain', ''); @@ -111,3 +112,20 @@ } }); }); + +// MCP endpoint (outside the API prefix) +if (class_exists(McpController::class)) { + Route::match(['GET', 'POST', 'DELETE', 'OPTIONS'], '/mcp', static function (Illuminate\Http\Request $request) { + $requestStack = app(Symfony\Component\HttpFoundation\RequestStack::class); + $mcpController = app(McpController::class); + + // Push Laravel request onto Symfony RequestStack + $requestStack->push($request); + + try { + return $mcpController->handle($request); + } finally { + $requestStack->pop(); + } + })->name('api_mcp'); +} diff --git a/src/Laravel/workbench/app/ApiResource/McpToolAttribute.php b/src/Laravel/workbench/app/ApiResource/McpToolAttribute.php new file mode 100644 index 00000000000..f2c04a3c8a0 --- /dev/null +++ b/src/Laravel/workbench/app/ApiResource/McpToolAttribute.php @@ -0,0 +1,58 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Workbench\App\ApiResource; + +use ApiPlatform\Metadata\McpTool; + +#[McpTool( + name: 'process_message', + description: 'Process a message with priority', + processor: [McpToolAttribute::class, 'process'] +)] +class McpToolAttribute +{ + public function __construct( + private string $message, + private int $priority = 1, + ) { + } + + public function getMessage(): string + { + return $this->message; + } + + public function setMessage(string $message): void + { + $this->message = $message; + } + + public function getPriority(): int + { + return $this->priority; + } + + public function setPriority(int $priority): void + { + $this->priority = $priority; + } + + public static function process($data): mixed + { + $data->setMessage('Processed: '.$data->getMessage()); + $data->setPriority($data->getPriority() + 10); + + return $data; + } +} diff --git a/src/Laravel/workbench/app/ApiResource/McpTools.php b/src/Laravel/workbench/app/ApiResource/McpTools.php new file mode 100644 index 00000000000..745000ef6e7 --- /dev/null +++ b/src/Laravel/workbench/app/ApiResource/McpTools.php @@ -0,0 +1,116 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Workbench\App\ApiResource; + +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\McpTool; +use Mcp\Schema\Content\TextContent; +use Mcp\Schema\Result\CallToolResult; + +#[ApiResource( + shortName: 'McpTools', + operations: [], + mcp: [ + 'custom_result' => new McpTool( + processor: [self::class, 'processCustomResult'] + ), + 'validate_input' => new McpTool( + processor: [self::class, 'processValidation'], + rules: [ + 'name' => 'required|min:3|max:50', + 'email' => 'required|email', + 'age' => 'required|integer|min:1', + ] + ), + ] +)] +class McpTools +{ + public function __construct( + private ?string $text = null, + private ?bool $includeMetadata = null, + private ?string $name = null, + private ?string $email = null, + private ?int $age = null, + ) { + } + + public function getText(): ?string + { + return $this->text; + } + + public function setText(?string $text): void + { + $this->text = $text; + } + + public function isIncludeMetadata(): ?bool + { + return $this->includeMetadata; + } + + public function setIncludeMetadata(?bool $includeMetadata): void + { + $this->includeMetadata = $includeMetadata; + } + + public function getName(): ?string + { + return $this->name; + } + + public function setName(?string $name): void + { + $this->name = $name; + } + + public function getEmail(): ?string + { + return $this->email; + } + + public function setEmail(?string $email): void + { + $this->email = $email; + } + + public function getAge(): ?int + { + return $this->age; + } + + public function setAge(?int $age): void + { + $this->age = $age; + } + + public static function processCustomResult($data): CallToolResult + { + $metadata = $data->isIncludeMetadata() ? ['processed' => true, 'timestamp' => time()] : null; + + return new CallToolResult( + [new TextContent('Custom result: '.$data->getText())], + false, + $metadata + ); + } + + public static function processValidation($data): mixed + { + $data->setName('Valid: '.$data->getName()); + + return $data; + } +} diff --git a/src/Laravel/workbench/app/ApiResource/McpWithMarkdown.php b/src/Laravel/workbench/app/ApiResource/McpWithMarkdown.php new file mode 100644 index 00000000000..e68cbe20c62 --- /dev/null +++ b/src/Laravel/workbench/app/ApiResource/McpWithMarkdown.php @@ -0,0 +1,86 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Workbench\App\ApiResource; + +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\McpTool; +use Mcp\Schema\Content\TextContent; +use Mcp\Schema\Result\CallToolResult; + +#[ApiResource( + shortName: 'McpWithMarkdown', + operations: [], + mcp: [ + 'generate_markdown' => new McpTool( + structuredContent: false, + processor: [self::class, 'processMarkdown'] + ), + ] +)] +class McpWithMarkdown +{ + public function __construct( + private string $title, + private string $content, + private bool $includeCodeBlock = false, + ) { + } + + public function getTitle(): string + { + return $this->title; + } + + public function setTitle(string $title): void + { + $this->title = $title; + } + + public function getContent(): string + { + return $this->content; + } + + public function setContent(string $content): void + { + $this->content = $content; + } + + public function isIncludeCodeBlock(): bool + { + return $this->includeCodeBlock; + } + + public function setIncludeCodeBlock(bool $includeCodeBlock): void + { + $this->includeCodeBlock = $includeCodeBlock; + } + + public static function processMarkdown($data): CallToolResult + { + $markdown = "# {$data->getTitle()}\n\n"; + $markdown .= $data->getContent(); + + if ($data->isIncludeCodeBlock()) { + $markdown .= "\n\n```php\n"; + $markdown .= "echo 'Hello, World!';\n"; + $markdown .= '```'; + } + + return new CallToolResult( + [new TextContent($markdown)], + false + ); + } +} diff --git a/src/Laravel/workbench/app/Models/McpBook.php b/src/Laravel/workbench/app/Models/McpBook.php new file mode 100644 index 00000000000..7198e2f2338 --- /dev/null +++ b/src/Laravel/workbench/app/Models/McpBook.php @@ -0,0 +1,89 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Workbench\App\Models; + +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\McpTool; +use Illuminate\Database\Eloquent\Model; + +#[ApiResource( + shortName: 'McpBook', + operations: [], + mcp: [ + 'get_book_info' => new McpTool( + provider: [self::class, 'provide'] + ), + 'update_book_status' => new McpTool( + processor: [self::class, 'process'] + ), + ] +)] +class McpBook extends Model +{ + protected $fillable = ['title', 'isbn', 'status']; + + public ?string $title = null; + public ?string $isbn = null; + public ?string $status = null; + + public function getTitle(): ?string + { + return $this->title; + } + + public function setTitle(?string $title): void + { + $this->title = $title; + $this->setAttribute('title', $title); + } + + public function getIsbn(): ?string + { + return $this->isbn; + } + + public function setIsbn(?string $isbn): void + { + $this->isbn = $isbn; + $this->setAttribute('isbn', $isbn); + } + + public function getStatus(): ?string + { + return $this->status; + } + + public function setStatus(?string $status): void + { + $this->status = $status; + $this->setAttribute('status', $status); + } + + public static function provide(): self + { + $book = new self(); + $book->setTitle('API Platform Guide'); + $book->setIsbn('978-1234567890'); + $book->setStatus('available'); + + return $book; + } + + public static function process($data): mixed + { + $data->setStatus('updated'); + + return $data; + } +} diff --git a/src/Laravel/workbench/database/migrations/2026_01_27_000000_create_mcp_books_table.php b/src/Laravel/workbench/database/migrations/2026_01_27_000000_create_mcp_books_table.php new file mode 100644 index 00000000000..2734c085474 --- /dev/null +++ b/src/Laravel/workbench/database/migrations/2026_01_27_000000_create_mcp_books_table.php @@ -0,0 +1,34 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +use Illuminate\Database\Migrations\Migration; +use Illuminate\Database\Schema\Blueprint; +use Illuminate\Support\Facades\Schema; + +return new class extends Migration { + public function up(): void + { + Schema::create('mcp_books', static function (Blueprint $table): void { + $table->id(); + $table->string('title'); + $table->string('isbn'); + $table->string('status')->nullable(); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('mcp_books'); + } +}; diff --git a/src/Mcp/Server/Handler.php b/src/Mcp/Server/Handler.php index db28907caa4..55d67b9c224 100644 --- a/src/Mcp/Server/Handler.php +++ b/src/Mcp/Server/Handler.php @@ -13,6 +13,7 @@ namespace ApiPlatform\Mcp\Server; +use ApiPlatform\Mcp\State\ToolProvider; use ApiPlatform\Metadata\HttpOperation; use ApiPlatform\Metadata\Operation\Factory\OperationMetadataFactoryInterface; use ApiPlatform\State\ProcessorInterface; @@ -99,7 +100,7 @@ public function handle(Request $request, SessionInterface $session): Response|Er } if (null === $operation->getProvider()) { - $operation = $operation->withProvider('api_platform.mcp.state.tool_provider'); + $operation = $operation->withProvider(ToolProvider::class); } if (null === $operation->canDeserialize()) { diff --git a/src/Symfony/Bundle/Resources/config/mcp/mcp.php b/src/Symfony/Bundle/Resources/config/mcp/mcp.php index 9440aa88cf6..8ffdffaea8c 100644 --- a/src/Symfony/Bundle/Resources/config/mcp/mcp.php +++ b/src/Symfony/Bundle/Resources/config/mcp/mcp.php @@ -35,12 +35,14 @@ service('api_platform.mcp.iri_converter.inner'), ]); - $services->set('api_platform.mcp.state.tool_provider', ToolProvider::class) + $services->set(ToolProvider::class, ToolProvider::class) ->args([ service('object_mapper'), ]) ->tag('api_platform.state_provider'); + $services->alias('api_platform.mcp.state.tool_provider', ToolProvider::class); + $services->set('api_platform.mcp.metadata.operation.mcp_factory', OperationMetadataFactory::class) ->args([ service('api_platform.metadata.resource.name_collection_factory'),