From 1382d4c01e65d7050eca7ea3ec24fc241f9b8a0d Mon Sep 17 00:00:00 2001 From: camilleislasse Date: Thu, 18 Sep 2025 19:18:09 +0200 Subject: [PATCH] Migrate MCP Bundle from symfony/mcp-sdk to official mcp/sdk - Replace symfony/mcp-sdk dependency with official mcp/sdk - Refactor Server creation to use Server::make() builder pattern - Convert tool definitions from interfaces to #[McpTool] attributes - Implement automatic tool discovery in src/ directory - Update STDIO transport command to mcp:server - Simplify service configuration using native SDK patterns - Remove redundant ServerFactory and routes.php files - Add custom LogicException for bundle-specific errors - Update all documentation (README, CHANGELOG, index.rst) - Fix composer.json description - Add .idea/ to .gitignore Resolves #526 Fabbot + php-cs-fixer DOCtor-RST Fix RouteLoader registration to always create when transports enabled Remove .idea - Add autoconfiguration for #[McpTool] attribute with mcp.tool tag - Create McpToolPass compiler pass using ServiceLocatorTagPass::register() - Inject Service Locator into ServerBuilder via setContainer() Fix PR review comments for MCP Bundle migration - Remove prefer-stable from root composer.json - Move CHANGELOG content to 0.1 section and remove BC BREAK labels - Use ServerBuilder::class and Server::class in services configuration - Fix "client vs server" comment in documentation - Use ServerBuilder::class in test instead of string Remove symfony/mcp-sdk Remove symfony/mcp-sdk ref in claude reamdde et phpstan Rewrite CHANGELOG.md Use [] === $taggedServices instead of empty($taggedServices) Use use Symfony\Component\Routing\Exception\LogicException; instead of custom Exception Add MCP capabilities support to Symfony bundle This commit implements Model Context Protocol (MCP) support by adding: - Prompts: System instructions for AI context using #[McpPrompt] - Resources: Static data access using #[McpResource] - Resource Templates: Dynamic resources with parameters using #[McpResourceTemplate] Implementation includes: - Auto-configuration for MCP attributes with proper tagging - Compiler passes extending AbstractMcpPass for service registration - Documentation updates with examples and usage patterns - Demo examples showcasing each capability type Technical details: - Refactored auto-configuration into registerMcpAttributes() method - Created AbstractMcpPass to eliminate code duplication - Added proper error handling for timezone validation - Resource Templates ready but await MCP SDK handler implementation Apply PHP CS Fixer to demo MCP examples - Add Symfony license headers to demo files - Fix code style and formatting - Add trailing commas where appropriate Add pagination_limit and instructions configuration options - Add pagination_limit option to control MCP list responses (default: 50) - Add instructions option for server description to help LLMs - Update services.php to pass both options to ServerBuilder - Add comprehensive tests for new configuration options - Update documentation with examples and usage - Update demo configuration with practical examples Both options map directly to ServerBuilder methods: - setPaginationLimit(int) - setInstructions(string) Add dedicated MCP logger with configurable Monolog integration - Create monolog.logger.mcp service with dedicated channel - Update ServerBuilder to use MCP-specific logger instead of generic logger - Add comprehensive logging documentation with configuration examples - Include examples for different environments (dev/prod) and handlers (file/Slack) - Add test coverage for MCP logger service creation and configuration The MCP logger uses Monolog's logger prototype pattern and can be customized by users through standard Monolog configuration in their applications. Add EventDispatcher support and event system documentation - Configure Symfony EventDispatcher for MCP SDK event handling - Add comprehensive event system documentation with examples - Add test coverage for EventDispatcher configuration - Fix PHPStan type assertion for ChildDefinition Migrate MCP Bundle from SSE to official StreamableHttpTransport - Replace custom SSE implementation with MCP SDK's StreamableHttpTransport - Change sse transport to http in configuration and code - Simplify DependencyInjection compiler passes - Add HTTP session management (file/memory store options) - Update documentation and tests for HTTP transport --- .phpstan/ForbidNativeExceptionRule.php | 1 - AGENTS.md | 3 +- CLAUDE.md | 5 +- README.md | 3 +- demo/composer.json | 1 + demo/config/packages/mcp.yaml | 10 + demo/config/routes.yaml | 5 + demo/src/MCP/Prompts/CurrentTimePrompt.php | 28 +++ .../CurrentTimeResourceTemplate.php | 33 +++ .../src/MCP/Resources/CurrentTimeResource.php | 27 +++ demo/src/MCP/Tools/CurrentTimeTool.php | 62 +----- src/mcp-bundle/CHANGELOG.md | 34 +-- src/mcp-bundle/README.md | 6 +- src/mcp-bundle/composer.json | 13 +- src/mcp-bundle/config/options.php | 57 ++--- src/mcp-bundle/config/routes.php | 24 --- src/mcp-bundle/config/services.php | 75 ++----- src/mcp-bundle/doc/index.rst | 200 ++++++++++++++++-- src/mcp-bundle/src/Command/McpCommand.php | 10 +- .../src/Controller/McpController.php | 48 +++-- .../src/DependencyInjection/McpPass.php | 43 ++++ src/mcp-bundle/src/McpBundle.php | 106 ++++++++-- src/mcp-bundle/src/Routing/RouteLoader.php | 29 ++- .../DependencyInjection/McpBundleTest.php | 189 +++++++++++++---- .../tests/DependencyInjection/McpPassTest.php | 119 +++++++++++ src/mcp-sdk/.gitattributes | 7 - src/mcp-sdk/.github/PULL_REQUEST_TEMPLATE.md | 8 - .../.github/workflows/close-pull-request.yml | 20 -- src/mcp-sdk/.gitignore | 4 - src/mcp-sdk/CHANGELOG.md | 24 --- src/mcp-sdk/LICENSE | 19 -- src/mcp-sdk/README.md | 28 --- src/mcp-sdk/composer.json | 44 ---- src/mcp-sdk/doc/index.rst | 158 -------------- src/mcp-sdk/examples/cli/README.md | 29 --- src/mcp-sdk/examples/cli/composer.json | 23 -- .../examples/cli/example-requests.json | 12 -- src/mcp-sdk/examples/cli/index.php | 39 ---- src/mcp-sdk/examples/cli/src/Builder.php | 69 ------ .../examples/cli/src/ExamplePrompt.php | 55 ----- .../examples/cli/src/ExampleResource.php | 53 ----- src/mcp-sdk/examples/cli/src/ExampleTool.php | 70 ------ src/mcp-sdk/phpstan.dist.neon | 14 -- src/mcp-sdk/phpunit.xml.dist | 22 -- .../Capability/Prompt/CollectionInterface.php | 26 --- .../Capability/Prompt/IdentifierInterface.php | 20 -- .../Capability/Prompt/MetadataInterface.php | 26 --- .../src/Capability/Prompt/PromptGet.php | 25 --- .../src/Capability/Prompt/PromptGetResult.php | 24 --- .../Prompt/PromptGetResultMessages.php | 27 --- .../Prompt/PromptGetterInterface.php | 24 --- src/mcp-sdk/src/Capability/PromptChain.php | 75 ------- .../Resource/CollectionInterface.php | 29 --- .../Resource/IdentifierInterface.php | 20 -- .../Capability/Resource/MetadataInterface.php | 29 --- .../src/Capability/Resource/ResourceRead.php | 21 -- .../Resource/ResourceReadResult.php | 27 --- .../Resource/ResourceReaderInterface.php | 27 --- src/mcp-sdk/src/Capability/ResourceChain.php | 75 ------- .../Capability/Tool/CollectionInterface.php | 26 --- .../Capability/Tool/IdentifierInterface.php | 20 -- .../src/Capability/Tool/MetadataInterface.php | 74 ------- .../Tool/ToolAnnotationsInterface.php | 62 ------ src/mcp-sdk/src/Capability/Tool/ToolCall.php | 25 --- .../src/Capability/Tool/ToolCallResult.php | 27 --- .../Tool/ToolCollectionInterface.php | 20 -- .../Capability/Tool/ToolExecutorInterface.php | 24 --- src/mcp-sdk/src/Capability/ToolChain.php | 77 ------- .../src/Exception/ExceptionInterface.php | 16 -- .../Exception/HandlerNotFoundException.php | 16 -- .../Exception/InvalidArgumentException.php | 19 -- .../src/Exception/InvalidCursorException.php | 21 -- .../InvalidInputMessageException.php | 16 -- .../Exception/NotFoundExceptionInterface.php | 16 -- .../src/Exception/PromptGetException.php | 24 --- .../src/Exception/PromptNotFoundException.php | 23 -- .../Exception/ResourceNotFoundException.php | 23 -- .../src/Exception/ResourceReadException.php | 24 --- .../src/Exception/ToolExecutionException.php | 24 --- .../src/Exception/ToolNotFoundException.php | 23 -- src/mcp-sdk/src/Message/Error.php | 73 ------- src/mcp-sdk/src/Message/Factory.php | 44 ---- src/mcp-sdk/src/Message/Notification.php | 52 ----- src/mcp-sdk/src/Message/Request.php | 55 ----- src/mcp-sdk/src/Message/Response.php | 36 ---- src/mcp-sdk/src/Server.php | 61 ------ src/mcp-sdk/src/Server/JsonRpcHandler.php | 160 -------------- .../BaseNotificationHandler.php | 28 --- .../InitializedHandler.php | 29 --- .../Server/NotificationHandlerInterface.php | 28 --- .../RequestHandler/BaseRequestHandler.php | 28 --- .../RequestHandler/InitializeHandler.php | 45 ---- .../src/Server/RequestHandler/PingHandler.php | 31 --- .../RequestHandler/PromptGetHandler.php | 83 -------- .../RequestHandler/PromptListHandler.php | 85 -------- .../RequestHandler/ResourceListHandler.php | 79 ------- .../RequestHandler/ResourceReadHandler.php | 59 ------ .../Server/RequestHandler/ToolCallHandler.php | 75 ------- .../Server/RequestHandler/ToolListHandler.php | 78 ------- .../src/Server/RequestHandlerInterface.php | 30 --- .../Transport/Sse/Store/CachePoolStore.php | 62 ------ .../Server/Transport/Sse/StoreInterface.php | 26 --- .../Server/Transport/Sse/StreamTransport.php | 62 ------ .../Stdio/SymfonyConsoleTransport.php | 67 ------ src/mcp-sdk/src/Server/TransportInterface.php | 28 --- .../tests/Fixtures/InMemoryTransport.php | 50 ----- src/mcp-sdk/tests/Message/ErrorTest.php | 48 ----- src/mcp-sdk/tests/Message/FactoryTest.php | 90 -------- src/mcp-sdk/tests/Message/ResponseTest.php | 42 ---- .../tests/Server/JsonRpcHandlerTest.php | 86 -------- .../RequestHandler/PromptListHandlerTest.php | 76 ------- .../ResourceListHandlerTest.php | 113 ---------- .../RequestHandler/ToolListHandlerTest.php | 119 ----------- src/mcp-sdk/tests/ServerTest.php | 46 ---- 114 files changed, 822 insertions(+), 4156 deletions(-) create mode 100644 demo/src/MCP/Prompts/CurrentTimePrompt.php create mode 100644 demo/src/MCP/ResourceTemplates/CurrentTimeResourceTemplate.php create mode 100644 demo/src/MCP/Resources/CurrentTimeResource.php delete mode 100644 src/mcp-bundle/config/routes.php create mode 100644 src/mcp-bundle/src/DependencyInjection/McpPass.php create mode 100644 src/mcp-bundle/tests/DependencyInjection/McpPassTest.php delete mode 100644 src/mcp-sdk/.gitattributes delete mode 100644 src/mcp-sdk/.github/PULL_REQUEST_TEMPLATE.md delete mode 100644 src/mcp-sdk/.github/workflows/close-pull-request.yml delete mode 100644 src/mcp-sdk/.gitignore delete mode 100644 src/mcp-sdk/CHANGELOG.md delete mode 100644 src/mcp-sdk/LICENSE delete mode 100644 src/mcp-sdk/README.md delete mode 100644 src/mcp-sdk/composer.json delete mode 100644 src/mcp-sdk/doc/index.rst delete mode 100644 src/mcp-sdk/examples/cli/README.md delete mode 100644 src/mcp-sdk/examples/cli/composer.json delete mode 100644 src/mcp-sdk/examples/cli/example-requests.json delete mode 100644 src/mcp-sdk/examples/cli/index.php delete mode 100644 src/mcp-sdk/examples/cli/src/Builder.php delete mode 100644 src/mcp-sdk/examples/cli/src/ExamplePrompt.php delete mode 100644 src/mcp-sdk/examples/cli/src/ExampleResource.php delete mode 100644 src/mcp-sdk/examples/cli/src/ExampleTool.php delete mode 100644 src/mcp-sdk/phpstan.dist.neon delete mode 100644 src/mcp-sdk/phpunit.xml.dist delete mode 100644 src/mcp-sdk/src/Capability/Prompt/CollectionInterface.php delete mode 100644 src/mcp-sdk/src/Capability/Prompt/IdentifierInterface.php delete mode 100644 src/mcp-sdk/src/Capability/Prompt/MetadataInterface.php delete mode 100644 src/mcp-sdk/src/Capability/Prompt/PromptGet.php delete mode 100644 src/mcp-sdk/src/Capability/Prompt/PromptGetResult.php delete mode 100644 src/mcp-sdk/src/Capability/Prompt/PromptGetResultMessages.php delete mode 100644 src/mcp-sdk/src/Capability/Prompt/PromptGetterInterface.php delete mode 100644 src/mcp-sdk/src/Capability/PromptChain.php delete mode 100644 src/mcp-sdk/src/Capability/Resource/CollectionInterface.php delete mode 100644 src/mcp-sdk/src/Capability/Resource/IdentifierInterface.php delete mode 100644 src/mcp-sdk/src/Capability/Resource/MetadataInterface.php delete mode 100644 src/mcp-sdk/src/Capability/Resource/ResourceRead.php delete mode 100644 src/mcp-sdk/src/Capability/Resource/ResourceReadResult.php delete mode 100644 src/mcp-sdk/src/Capability/Resource/ResourceReaderInterface.php delete mode 100644 src/mcp-sdk/src/Capability/ResourceChain.php delete mode 100644 src/mcp-sdk/src/Capability/Tool/CollectionInterface.php delete mode 100644 src/mcp-sdk/src/Capability/Tool/IdentifierInterface.php delete mode 100644 src/mcp-sdk/src/Capability/Tool/MetadataInterface.php delete mode 100644 src/mcp-sdk/src/Capability/Tool/ToolAnnotationsInterface.php delete mode 100644 src/mcp-sdk/src/Capability/Tool/ToolCall.php delete mode 100644 src/mcp-sdk/src/Capability/Tool/ToolCallResult.php delete mode 100644 src/mcp-sdk/src/Capability/Tool/ToolCollectionInterface.php delete mode 100644 src/mcp-sdk/src/Capability/Tool/ToolExecutorInterface.php delete mode 100644 src/mcp-sdk/src/Capability/ToolChain.php delete mode 100644 src/mcp-sdk/src/Exception/ExceptionInterface.php delete mode 100644 src/mcp-sdk/src/Exception/HandlerNotFoundException.php delete mode 100644 src/mcp-sdk/src/Exception/InvalidArgumentException.php delete mode 100644 src/mcp-sdk/src/Exception/InvalidCursorException.php delete mode 100644 src/mcp-sdk/src/Exception/InvalidInputMessageException.php delete mode 100644 src/mcp-sdk/src/Exception/NotFoundExceptionInterface.php delete mode 100644 src/mcp-sdk/src/Exception/PromptGetException.php delete mode 100644 src/mcp-sdk/src/Exception/PromptNotFoundException.php delete mode 100644 src/mcp-sdk/src/Exception/ResourceNotFoundException.php delete mode 100644 src/mcp-sdk/src/Exception/ResourceReadException.php delete mode 100644 src/mcp-sdk/src/Exception/ToolExecutionException.php delete mode 100644 src/mcp-sdk/src/Exception/ToolNotFoundException.php delete mode 100644 src/mcp-sdk/src/Message/Error.php delete mode 100644 src/mcp-sdk/src/Message/Factory.php delete mode 100644 src/mcp-sdk/src/Message/Notification.php delete mode 100644 src/mcp-sdk/src/Message/Request.php delete mode 100644 src/mcp-sdk/src/Message/Response.php delete mode 100644 src/mcp-sdk/src/Server.php delete mode 100644 src/mcp-sdk/src/Server/JsonRpcHandler.php delete mode 100644 src/mcp-sdk/src/Server/NotificationHandler/BaseNotificationHandler.php delete mode 100644 src/mcp-sdk/src/Server/NotificationHandler/InitializedHandler.php delete mode 100644 src/mcp-sdk/src/Server/NotificationHandlerInterface.php delete mode 100644 src/mcp-sdk/src/Server/RequestHandler/BaseRequestHandler.php delete mode 100644 src/mcp-sdk/src/Server/RequestHandler/InitializeHandler.php delete mode 100644 src/mcp-sdk/src/Server/RequestHandler/PingHandler.php delete mode 100644 src/mcp-sdk/src/Server/RequestHandler/PromptGetHandler.php delete mode 100644 src/mcp-sdk/src/Server/RequestHandler/PromptListHandler.php delete mode 100644 src/mcp-sdk/src/Server/RequestHandler/ResourceListHandler.php delete mode 100644 src/mcp-sdk/src/Server/RequestHandler/ResourceReadHandler.php delete mode 100644 src/mcp-sdk/src/Server/RequestHandler/ToolCallHandler.php delete mode 100644 src/mcp-sdk/src/Server/RequestHandler/ToolListHandler.php delete mode 100644 src/mcp-sdk/src/Server/RequestHandlerInterface.php delete mode 100644 src/mcp-sdk/src/Server/Transport/Sse/Store/CachePoolStore.php delete mode 100644 src/mcp-sdk/src/Server/Transport/Sse/StoreInterface.php delete mode 100644 src/mcp-sdk/src/Server/Transport/Sse/StreamTransport.php delete mode 100644 src/mcp-sdk/src/Server/Transport/Stdio/SymfonyConsoleTransport.php delete mode 100644 src/mcp-sdk/src/Server/TransportInterface.php delete mode 100644 src/mcp-sdk/tests/Fixtures/InMemoryTransport.php delete mode 100644 src/mcp-sdk/tests/Message/ErrorTest.php delete mode 100644 src/mcp-sdk/tests/Message/FactoryTest.php delete mode 100644 src/mcp-sdk/tests/Message/ResponseTest.php delete mode 100644 src/mcp-sdk/tests/Server/JsonRpcHandlerTest.php delete mode 100644 src/mcp-sdk/tests/Server/RequestHandler/PromptListHandlerTest.php delete mode 100644 src/mcp-sdk/tests/Server/RequestHandler/ResourceListHandlerTest.php delete mode 100644 src/mcp-sdk/tests/Server/RequestHandler/ToolListHandlerTest.php delete mode 100644 src/mcp-sdk/tests/ServerTest.php diff --git a/.phpstan/ForbidNativeExceptionRule.php b/.phpstan/ForbidNativeExceptionRule.php index 9cd6c2e1a..278a611c8 100644 --- a/.phpstan/ForbidNativeExceptionRule.php +++ b/.phpstan/ForbidNativeExceptionRule.php @@ -51,7 +51,6 @@ final class ForbidNativeExceptionRule implements Rule 'Symfony\\AI\\Agent' => 'Symfony\\AI\\Agent\\Exception\\', 'Symfony\\AI\\Platform' => 'Symfony\\AI\\Platform\\Exception\\', 'Symfony\\AI\\Store' => 'Symfony\\AI\\Store\\Exception\\', - 'Symfony\\AI\\McpSdk' => 'Symfony\\AI\\McpSdk\\Exception\\', 'Symfony\\AI\\AiBundle' => 'Symfony\\AI\\AiBundle\\Exception\\', 'Symfony\\AI\\McpBundle' => 'Symfony\\AI\\McpBundle\\Exception\\', ]; diff --git a/AGENTS.md b/AGENTS.md index 50c8c7400..dfc4e29f9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -12,11 +12,10 @@ Symfony AI monorepo with independent packages for AI integration in PHP applicat - **Platform** (`src/platform/`): Unified AI platform interface (OpenAI, Anthropic, Azure, Gemini, VertexAI) - **Agent** (`src/agent/`): AI agent framework for user interaction and task execution - **Store** (`src/store/`): Data storage abstraction with vector database support -- **MCP SDK** (`src/mcp-sdk/`): Model Context Protocol SDK for agent-tool communication ### Integration Bundles - **AI Bundle** (`src/ai-bundle/`): Symfony integration for Platform, Store, and Agent -- **MCP Bundle** (`src/mcp-bundle/`): Symfony integration for MCP SDK +- **MCP Bundle** (`src/mcp-bundle/`): Symfony integration for official MCP SDK ### Supporting Directories - **Examples** (`examples/`): Standalone usage examples diff --git a/CLAUDE.md b/CLAUDE.md index 8689349db..b8468cbfe 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -12,11 +12,10 @@ This is the Symfony AI monorepo containing multiple components and bundles that - **Platform** (`src/platform/`): Unified interface to AI platforms (OpenAI, Anthropic, Azure, Gemini, VertexAI, etc.) - **Agent** (`src/agent/`): Framework for building AI agents that interact with users and perform tasks - **Store** (`src/store/`): Data storage abstraction with indexing and retrieval for vector databases -- **MCP SDK** (`src/mcp-sdk/`): SDK for Model Context Protocol enabling agent-tool communication ### Integration Bundles - **AI Bundle** (`src/ai-bundle/`): Symfony integration for Platform, Store, and Agent components -- **MCP Bundle** (`src/mcp-bundle/`): Symfony integration for MCP SDK +- **MCP Bundle** (`src/mcp-bundle/`): Symfony integration for official MCP SDK ### Supporting Directories - **Examples** (`examples/`): Standalone examples demonstrating component usage across different AI platforms @@ -92,7 +91,7 @@ symfony server:start Components are designed to work independently but have these relationships: - Agent depends on Platform for AI communication - AI Bundle integrates Platform, Agent, and Store -- MCP Bundle provides MCP SDK integration +- MCP Bundle provides official MCP SDK integration - Store is standalone but often used with Agent for RAG applications ## Testing Architecture diff --git a/README.md b/README.md index ac65db86d..f1244d7d8 100644 --- a/README.md +++ b/README.md @@ -16,10 +16,9 @@ Symfony AI consists of several lower and higher level **components** and the res * **[Platform](src/platform/README.md)**: A unified interface to various AI platforms like OpenAI, Anthropic, Azure, Gemini, VertexAI, and more. * **[Agent](src/agent/README.md)**: Framework for building AI agents that can interact with users and perform tasks. * **[Store](src/store/README.md)**: Data storage abstraction with indexing and retrieval for AI applications. - * **[MCP SDK](src/mcp-sdk/README.md)**: SDK for [Model Context Protocol](https://modelcontextprotocol.io) enabling communication between AI agents and tools. * **Bundles** * **[AI Bundle](src/ai-bundle/README.md)**: Symfony integration for AI Platform, Store and Agent components. - * **[MCP Bundle](src/mcp-bundle/README.md)**: Symfony integration for MCP SDK, allowing them to act as MCP servers or clients. + * **[MCP Bundle](src/mcp-bundle/README.md)**: Symfony integration for official MCP SDK, allowing them to act as MCP servers or clients. ## Examples & Demo diff --git a/demo/composer.json b/demo/composer.json index a69f6f179..049679af2 100644 --- a/demo/composer.json +++ b/demo/composer.json @@ -23,6 +23,7 @@ "symfony/flex": "^2.5", "symfony/framework-bundle": "~7.3.0", "symfony/http-client": "~7.3.0", + "mcp/sdk": "@dev", "symfony/mcp-bundle": "@dev", "symfony/monolog-bundle": "^3.10", "symfony/runtime": "~7.3.0", diff --git a/demo/config/packages/mcp.yaml b/demo/config/packages/mcp.yaml index 0f54beb96..027b7e619 100644 --- a/demo/config/packages/mcp.yaml +++ b/demo/config/packages/mcp.yaml @@ -1,5 +1,15 @@ mcp: app: demo-app version: 0.0.1 + pagination_limit: 10 + instructions: | + This demo MCP server provides time management capabilities for developers. + + Use this server when you need to work with timestamps, time zones, or time-based calculations. + client_transports: stdio: true + http: true + http: + session: + store: file diff --git a/demo/config/routes.yaml b/demo/config/routes.yaml index d27bd8e64..9aa9d5543 100644 --- a/demo/config/routes.yaml +++ b/demo/config/routes.yaml @@ -49,3 +49,8 @@ stream: stream_assistant_reply: path: '/stream/assistant-reply' controller: 'App\Stream\TwigComponent::streamContent' + +# Load MCP routes conditionally based on configuration +_mcp: + resource: . + type: mcp diff --git a/demo/src/MCP/Prompts/CurrentTimePrompt.php b/demo/src/MCP/Prompts/CurrentTimePrompt.php new file mode 100644 index 000000000..ea66b053b --- /dev/null +++ b/demo/src/MCP/Prompts/CurrentTimePrompt.php @@ -0,0 +1,28 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace App\MCP\Prompts; + +use Mcp\Capability\Attribute\McpPrompt; + +class CurrentTimePrompt +{ + #[McpPrompt(name: 'time-analysis')] + public function getTimeAnalysisPrompt(): array + { + return [ + [ + 'role' => 'user', + 'content' => 'You are a time management expert. Analyze what time of day it is and suggest appropriate activities for this time.', + ], + ]; + } +} diff --git a/demo/src/MCP/ResourceTemplates/CurrentTimeResourceTemplate.php b/demo/src/MCP/ResourceTemplates/CurrentTimeResourceTemplate.php new file mode 100644 index 000000000..af772acf7 --- /dev/null +++ b/demo/src/MCP/ResourceTemplates/CurrentTimeResourceTemplate.php @@ -0,0 +1,33 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace App\MCP\ResourceTemplates; + +use Mcp\Capability\Attribute\McpResourceTemplate; + +class CurrentTimeResourceTemplate +{ + #[McpResourceTemplate(uriTemplate: 'time://{timezone}', name: 'time-by-timezone')] + public function getTimeByTimezone(string $timezone): array + { + try { + $time = (new \DateTime('now', new \DateTimeZone($timezone)))->format('Y-m-d H:i:s T'); + } catch (\Exception $e) { + $time = 'Invalid timezone: '.$timezone; + } + + return [ + 'uri' => "time://$timezone", + 'mimeType' => 'text/plain', + 'text' => $time, + ]; + } +} diff --git a/demo/src/MCP/Resources/CurrentTimeResource.php b/demo/src/MCP/Resources/CurrentTimeResource.php new file mode 100644 index 000000000..11cd21e99 --- /dev/null +++ b/demo/src/MCP/Resources/CurrentTimeResource.php @@ -0,0 +1,27 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace App\MCP\Resources; + +use Mcp\Capability\Attribute\McpResource; + +class CurrentTimeResource +{ + #[McpResource(uri: 'time://current', name: 'current-time-resource')] + public function getCurrentTimeResource(): array + { + return [ + 'uri' => 'time://current', + 'mimeType' => 'text/plain', + 'text' => (new \DateTime('now'))->format('Y-m-d H:i:s T'), + ]; + } +} diff --git a/demo/src/MCP/Tools/CurrentTimeTool.php b/demo/src/MCP/Tools/CurrentTimeTool.php index 9f4a49b18..053ba79c1 100644 --- a/demo/src/MCP/Tools/CurrentTimeTool.php +++ b/demo/src/MCP/Tools/CurrentTimeTool.php @@ -11,63 +11,21 @@ namespace App\MCP\Tools; -use Symfony\AI\McpSdk\Capability\Tool\MetadataInterface; -use Symfony\AI\McpSdk\Capability\Tool\ToolAnnotationsInterface; -use Symfony\AI\McpSdk\Capability\Tool\ToolCall; -use Symfony\AI\McpSdk\Capability\Tool\ToolCallResult; -use Symfony\AI\McpSdk\Capability\Tool\ToolExecutorInterface; +use Mcp\Capability\Attribute\McpTool; /** * @author Tom Hart */ -class CurrentTimeTool implements MetadataInterface, ToolExecutorInterface +class CurrentTimeTool { - public function call(ToolCall $input): ToolCallResult + /** + * Returns the current time in UTC. + * + * @param string $format The format of the time, e.g. "Y-m-d H:i:s" + */ + #[McpTool(name: 'current-time')] + public function getCurrentTime(string $format = 'Y-m-d H:i:s'): string { - $format = $input->arguments['format'] ?? 'Y-m-d H:i:s'; - - return new ToolCallResult( - (new \DateTime('now', new \DateTimeZone('UTC')))->format($format) - ); - } - - public function getName(): string - { - return 'current-time'; - } - - public function getDescription(): string - { - return 'Returns the current time in UTC'; - } - - public function getInputSchema(): array - { - return [ - 'type' => 'object', - 'properties' => [ - 'format' => [ - 'type' => 'string', - 'description' => 'The format of the time, e.g. "Y-m-d H:i:s"', - 'default' => 'Y-m-d H:i:s', - ], - ], - 'required' => ['format'], - ]; - } - - public function getOutputSchema(): ?array - { - return null; - } - - public function getTitle(): ?string - { - return null; - } - - public function getAnnotations(): ?ToolAnnotationsInterface - { - return null; + return (new \DateTime('now', new \DateTimeZone('UTC')))->format($format); } } diff --git a/src/mcp-bundle/CHANGELOG.md b/src/mcp-bundle/CHANGELOG.md index 7397baea9..4c3b2cd72 100644 --- a/src/mcp-bundle/CHANGELOG.md +++ b/src/mcp-bundle/CHANGELOG.md @@ -4,19 +4,23 @@ CHANGELOG 0.1 --- - * Add Symfony bundle bridging MCP-SDK with Symfony applications - * Add server mode exposing Symfony tools to MCP clients: - - STDIO transport via `php bin/console mcp` command - - SSE (Server-Sent Events) transport via HTTP endpoints - - Automatic tool discovery and registration - - Integration with AI-Bundle tools - * Add routing configuration for SSE endpoints: - - `/_mcp/sse` for SSE connections - - `/_mcp/messages/{id}` for message retrieval - * Add `McpController` for handling SSE connections + * Add Symfony bundle providing Model Context Protocol integration using official `mcp/sdk` + * Add server mode exposing MCP capabilities to clients: + - STDIO transport via `php bin/console mcp:server` command + - HTTP transport via StreamableHttpTransport using configurable endpoints + - Automatic capability discovery and registration + - EventDispatcher integration for capability change notifications + * Add configurable HTTP transport features: + - Configurable endpoint path (default: `/_mcp`) + - File and memory session store options + - TTL configuration for session management + - CORS headers for cross-origin requests + * Add `McpController` for handling HTTP transport connections * Add `McpCommand` providing STDIO interface - * Add bundle configuration for enabling/disabling transports - * Add cache-based SSE message storage - * Add service configuration for MCP server setup - * Classes extending `\Symfony\AI\McpSdk\Capability\Tool\IdentifierInterface` automatically - get the `mcp.tool` tag for MCP tool discovery + * Add bundle configuration for transport selection and HTTP options + * Add dedicated MCP logger with configurable Monolog integration + * Add pagination and instructions configuration + * Tools using `#[McpTool]` attribute automatically discovered + * Prompts using `#[McpPrompt]` attribute automatically discovered + * Resources using `#[McpResource]` attribute automatically discovered + * Resource templates using `#[McpResourceTemplate]` attribute automatically discovered diff --git a/src/mcp-bundle/README.md b/src/mcp-bundle/README.md index 8c23a9db8..adce50be4 100644 --- a/src/mcp-bundle/README.md +++ b/src/mcp-bundle/README.md @@ -1,9 +1,9 @@ # MCP Bundle -Symfony integration bundle for [Model Context Protocol](https://modelcontextprotocol.io/) using the Symfony AI -MCP SDK [symfony/mcp-sdk](https://github.com/symfony/mcp-sdk). +Symfony integration bundle for [Model Context Protocol](https://modelcontextprotocol.io/) using the official +MCP SDK [mcp/sdk](https://github.com/modelcontextprotocol/php-sdk). -**Currently only supports tools as server via Server-Sent Events (SSE) and STDIO.** +**Supports MCP capabilities (tools, prompts, resources) as server via HTTP transport and STDIO. Resource templates implementation ready but awaiting MCP SDK support.** **This Bundle is experimental**. [Experimental features](https://symfony.com/doc/current/contributing/code/experimental.html) diff --git a/src/mcp-bundle/composer.json b/src/mcp-bundle/composer.json index 077377d7c..e6bf58623 100644 --- a/src/mcp-bundle/composer.json +++ b/src/mcp-bundle/composer.json @@ -1,6 +1,6 @@ { "name": "symfony/mcp-bundle", - "description": "Symfony integration bundle for Model Context Protocol (via symfony/mcp-sdk)", + "description": "Symfony integration bundle for Model Context Protocol (via official mcp/sdk)", "license": "MIT", "type": "symfony-bundle", "authors": [ @@ -16,8 +16,10 @@ "symfony/framework-bundle": "^7.3|^8.0", "symfony/http-foundation": "^7.3|^8.0", "symfony/http-kernel": "^7.3|^8.0", - "symfony/mcp-sdk": "@dev", - "symfony/routing": "^7.3|^8.0" + "mcp/sdk": "@dev", + "symfony/routing": "^7.3|^8.0", + "symfony/psr-http-message-bridge": "^7.3|^8.0", + "php-http/discovery": "^1.20" }, "require-dev": { "phpstan/phpstan": "^2.1", @@ -37,6 +39,9 @@ } }, "config": { - "sort-packages": true + "sort-packages": true, + "allow-plugins": { + "php-http/discovery": true + } } } diff --git a/src/mcp-bundle/config/options.php b/src/mcp-bundle/config/options.php index 2f06d2888..6dea5511b 100644 --- a/src/mcp-bundle/config/options.php +++ b/src/mcp-bundle/config/options.php @@ -16,49 +16,26 @@ ->children() ->scalarNode('app')->defaultValue('app')->end() ->scalarNode('version')->defaultValue('0.0.1')->end() - ->scalarNode('page_size')->defaultValue(20)->end() - // ->arrayNode('servers') - // ->useAttributeAsKey('name') - // ->arrayPrototype() - // ->children() - // ->enumNode('transport') - // ->values(['stdio', 'sse']) - // ->isRequired() - // ->end() - // ->arrayNode('stdio') - // ->children() - // ->scalarNode('command')->isRequired()->end() - // ->arrayNode('arguments') - // ->scalarPrototype()->end() - // ->defaultValue([]) - // ->end() - // ->end() - // ->end() - // ->arrayNode('sse') - // ->children() - // ->scalarNode('url')->isRequired()->end() - // ->end() - // ->end() - // ->end() - // ->validate() - // ->ifTrue(function ($v) { - // if ('stdio' === $v['transport'] && !isset($v['stdio'])) { - // return true; - // } - // if ('sse' === $v['transport'] && !isset($v['sse'])) { - // return true; - // } - // - // return false; - // }) - // ->thenInvalid('When transport is "%s", you must configure the corresponding section.') - // ->end() - // ->end() - // ->end() + ->integerNode('pagination_limit')->defaultValue(50)->end() + ->scalarNode('instructions')->defaultNull()->end() ->arrayNode('client_transports') ->children() ->booleanNode('stdio')->defaultFalse()->end() - ->booleanNode('sse')->defaultFalse()->end() + ->booleanNode('http')->defaultFalse()->end() + ->end() + ->end() + ->arrayNode('http') + ->addDefaultsIfNotSet() + ->children() + ->scalarNode('path')->defaultValue('/_mcp')->end() + ->arrayNode('session') + ->addDefaultsIfNotSet() + ->children() + ->enumNode('store')->values(['file', 'memory'])->defaultValue('file')->end() + ->scalarNode('directory')->defaultValue('%kernel.cache_dir%/mcp-sessions')->end() + ->integerNode('ttl')->min(1)->defaultValue(3600)->end() + ->end() + ->end() ->end() ->end() ->end() diff --git a/src/mcp-bundle/config/routes.php b/src/mcp-bundle/config/routes.php deleted file mode 100644 index fe499bf90..000000000 --- a/src/mcp-bundle/config/routes.php +++ /dev/null @@ -1,24 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -use Symfony\AI\McpBundle\Controller\McpController; -use Symfony\Component\Routing\Loader\Configurator\RoutingConfigurator; - -return function (RoutingConfigurator $routes): void { - $routes->add('_mcp_sse', '/sse') - ->controller([McpController::class, 'sse']) - ->methods(['GET']) - ; - $routes->add('_mcp_messages', '/messages/{id}') - ->controller([McpController::class, 'messages']) - ->methods(['POST']) - ; -}; diff --git a/src/mcp-bundle/config/services.php b/src/mcp-bundle/config/services.php index 56d2c11e2..fc27af04f 100644 --- a/src/mcp-bundle/config/services.php +++ b/src/mcp-bundle/config/services.php @@ -11,67 +11,28 @@ namespace Symfony\Component\DependencyInjection\Loader\Configurator; -use Symfony\AI\McpSdk\Capability\ToolChain; -use Symfony\AI\McpSdk\Message\Factory; -use Symfony\AI\McpSdk\Server; -use Symfony\AI\McpSdk\Server\JsonRpcHandler; -use Symfony\AI\McpSdk\Server\NotificationHandler\InitializedHandler; -use Symfony\AI\McpSdk\Server\RequestHandler\InitializeHandler; -use Symfony\AI\McpSdk\Server\RequestHandler\PingHandler; -use Symfony\AI\McpSdk\Server\RequestHandler\ToolCallHandler; -use Symfony\AI\McpSdk\Server\RequestHandler\ToolListHandler; -use Symfony\AI\McpSdk\Server\Transport\Sse\Store\CachePoolStore; +use Mcp\Server; +use Mcp\Server\ServerBuilder; return static function (ContainerConfigurator $container): void { $container->services() - ->set('mcp.server.notification_handler.initialized', InitializedHandler::class) - ->args([]) - ->tag('mcp.server.notification_handler') - ->set('mcp.server.request_handler.initialize', InitializeHandler::class) - ->args([ - param('mcp.app'), - param('mcp.version'), - ]) - ->tag('mcp.server.request_handler') - ->set('mcp.server.request_handler.ping', PingHandler::class) - ->args([]) - ->tag('mcp.server.request_handler') - ->set('mcp.server.request_handler.tool_call', ToolCallHandler::class) - ->args([ - service('mcp.tool_executor'), - ]) - ->tag('mcp.server.request_handler') - ->set('mcp.server.request_handler.tool_list', ToolListHandler::class) - ->args([ - service('mcp.tool_collection'), - param('mcp.page_size'), - ]) - ->tag('mcp.server.request_handler') + ->set('monolog.logger.mcp') + ->parent('monolog.logger_prototype') + ->args(['mcp']) + ->tag('monolog.logger', ['channel' => 'mcp']) + + ->set('mcp.server.builder', ServerBuilder::class) + ->factory([Server::class, 'make']) + ->call('setServerInfo', [param('mcp.app'), param('mcp.version')]) + ->call('setPaginationLimit', [param('mcp.pagination_limit')]) + ->call('setInstructions', [param('mcp.instructions')]) + ->call('setLogger', [service('monolog.logger.mcp')]) + ->call('setEventDispatcher', [service('event_dispatcher')]) + ->call('setSession', [service('mcp.session.store')]) + ->call('setDiscovery', [param('kernel.project_dir'), ['src']]) - ->set('mcp.message_factory', Factory::class) - ->args([]) - ->set('mcp.server.json_rpc', JsonRpcHandler::class) - ->args([ - service('mcp.message_factory'), - tagged_iterator('mcp.server.request_handler'), - tagged_iterator('mcp.server.notification_handler'), - service('logger')->ignoreOnInvalid(), - ]) ->set('mcp.server', Server::class) - ->args([ - service('mcp.server.json_rpc'), - service('logger')->ignoreOnInvalid(), - ]) - ->alias(Server::class, 'mcp.server') - ->set('mcp.server.sse.store.cache_pool', CachePoolStore::class) - ->args([ - service('cache.app'), - ]) - ->set('mcp.tool_chain', ToolChain::class) - ->args([ - tagged_iterator('mcp.tool'), - ]) - ->alias('mcp.tool_executor', 'mcp.tool_chain') - ->alias('mcp.tool_collection', 'mcp.tool_chain') + ->factory([service('mcp.server.builder'), 'build']) + ; }; diff --git a/src/mcp-bundle/doc/index.rst b/src/mcp-bundle/doc/index.rst index 2e8ecb085..3f499c754 100644 --- a/src/mcp-bundle/doc/index.rst +++ b/src/mcp-bundle/doc/index.rst @@ -1,9 +1,9 @@ MCP Bundle ========== -Symfony integration bundle for `Model Context Protocol`_ using the Symfony AI MCP SDK `symfony/mcp-sdk`_. +Symfony integration bundle for `Model Context Protocol`_ using the official MCP SDK `mcp/sdk`_. -**Currently only supports tools as server via Server-Sent Events (SSE) and STDIO.** +**Supports MCP capabilities (tools, prompts, resources) as server via HTTP transport and STDIO. Resource templates implementation ready but awaiting MCP SDK support.** Installation ------------ @@ -20,12 +20,99 @@ the ``mcp`` section of your ``config/packages/mcp.yaml`` file. **Act as Server** -.. warning:: +To use your application as an MCP server, exposing tools, prompts, resources, and resource templates to clients like `Claude Desktop`_, you need to configure in the +``client_transports`` section the transports you want to expose to clients. You can use either STDIO or HTTP. + +**Creating MCP Capabilities** + +MCP capabilities are automatically discovered using PHP attributes. + +**Tools** - Actions that can be executed:: + + use Mcp\Capability\Attribute\McpTool; + + class CurrentTimeTool + { + #[McpTool(name: 'current-time')] + public function getCurrentTime(string $format = 'Y-m-d H:i:s'): string + { + return (new \DateTime('now', new \DateTimeZone('UTC')))->format($format); + } + } + +**Prompts** - System instructions for AI context:: + + use Mcp\Capability\Attribute\McpPrompt; + + class TimePrompts + { + #[McpPrompt(name: 'time-analysis')] + public function getTimeAnalysisPrompt(): array + { + return [ + ['role' => 'user', 'content' => 'You are a time management expert.'] + ]; + } + } + +**Resources** - Static data that can be read:: + + use Mcp\Capability\Attribute\McpResource; + + class TimeResource + { + #[McpResource(uri: 'time://current', name: 'current-time')] + public function getCurrentTimeResource(): array + { + return [ + 'uri' => 'time://current', + 'mimeType' => 'text/plain', + 'text' => (new \DateTime('now'))->format('Y-m-d H:i:s') + ]; + } + } + +**Resource Templates** - Dynamic resources with parameters: + +.. note:: + + Resource Templates are not yet functional as the underlying MCP SDK is missing the required handlers. + See `MCP SDK issue #9 `_ for implementation status. + +:: + + use Mcp\Capability\Attribute\McpResourceTemplate; + + class TimeResourceTemplate + { + #[McpResourceTemplate(uriTemplate: 'time://{timezone}', name: 'time-by-timezone')] + public function getTimeByTimezone(string $timezone): array + { + $time = (new \DateTime('now', new \DateTimeZone($timezone)))->format('Y-m-d H:i:s T'); + return [ + 'uri' => "time://$timezone", + 'mimeType' => 'text/plain', + 'text' => $time + ]; + } + } + +All capabilities are automatically discovered in the ``src/`` directory when the server starts. + +**Transport Types** + +The MCP Bundle supports two transport types for server communication: + +- **STDIO Transport** - For command-line clients (e.g., ``symfony console mcp:server``) +- **HTTP Transport** - For web-based clients and MCP Inspector using streamable HTTP connections + +The HTTP transport uses the MCP SDK's ``StreamableHttpTransport`` which supports: - Currently only supports tools. Support for prompts, resources, and other features coming soon. +- JSON-RPC 2.0 over HTTP POST requests +- Session management with configurable storage (file/memory) +- CORS headers for cross-origin requests +- Proper MCP initialization handshake -To use your application as an MCP server, exposing tools to clients like `Claude Desktop`_, you need to configure in the -``client_transports`` section the transports you want to expose to clients. You can use either STDIO or SSE. **Act as Client** @@ -34,7 +121,7 @@ To use your application as an MCP server, exposing tools to clients like `Claude Not implemented yet, but planned for the future. To use your application as an MCP client, integrating other MCP servers, you need to configure the ``servers`` you want -to connect to. You can use either STDIO or Server-Sent Events (SSE) as transport methods. +to connect to. You can use either STDIO or HTTP as transport methods. You can find a list of example Servers in the `MCP Server List`_. @@ -49,22 +136,109 @@ Configuration mcp: app: 'app' # Application name to be exposed to clients version: '1.0.0' # Application version to be exposed to clients + pagination_limit: 50 # Maximum number of items returned per list request (default: 50) + instructions: | # Instructions describing server purpose and usage context (for LLMs) + This server provides time management capabilities for developers. + + Use when working with timestamps, time zones, or time-based calculations. + All timestamps are in UTC unless specified otherwise. + + Example contexts: logging, debugging, time-sensitive operations. client_transports: stdio: true # Enable STDIO via command - sse: true # Enable Server-Sent Event via controller + http: true # Enable HTTP transport via controller + + # HTTP transport configuration (optional) + http: + path: /_mcp # HTTP endpoint path (default: /_mcp) + session: + store: file # Session store type: 'file' or 'memory' (default: file) + directory: '%kernel.cache_dir%/mcp-sessions' # Directory for file store (default: cache_dir/mcp-sessions) + ttl: 3600 # Session TTL in seconds (default: 3600) servers: name: - transport: 'stdio' # Transport method to use, either 'stdio' or 'sse' + transport: 'stdio' # Transport method to use, either 'stdio' or 'http' stdio: - command: 'php /path/bin/console mcp' # Command to execute to start the client + command: 'php /path/bin/console mcp:server' # Command to execute to start the server arguments: [] # Arguments to pass to the command - sse: - url: 'http://localhost:8000/sse' # URL to SSE endpoint of MCP server + http: + url: 'http://localhost:8000/_mcp' # URL to HTTP endpoint of MCP server + +Logging Configuration +--------------------- + +By default, MCP uses a dedicated logger channel that inherits your application's default logging configuration. +To configure MCP-specific logging, add the following to your ``config/packages/monolog.yaml``: + +.. code-block:: yaml + + # config/packages/monolog.yaml + monolog: + channels: ['mcp'] + handlers: + mcp: + type: rotating_file + path: '%kernel.logs_dir%/mcp.log' + level: info + channels: ['mcp'] + max_files: 30 + +You can customize the logging level and destination according to your needs: + +.. code-block:: yaml + + # Example: Different levels per environment + monolog: + handlers: + mcp_dev: + type: stream + path: '%kernel.logs_dir%/mcp.log' + level: debug + channels: ['mcp'] + mcp_prod: + type: slack + level: error + channels: ['mcp'] + webhook_url: '%env(SLACK_WEBHOOK)%' + +Event System +------------ + +The MCP Bundle automatically configures the Symfony EventDispatcher to work with the MCP SDK's event system. +This allows you to listen for changes to your server's capabilities. + +**Available Events** + +The MCP SDK dispatches the following events when capabilities are registered: + +- ``Mcp\Event\ToolListChangedEvent`` - When a tool is registered +- ``Mcp\Event\ResourceListChangedEvent`` - When a resource is registered +- ``Mcp\Event\ResourceTemplateListChangedEvent`` - When a resource template is registered +- ``Mcp\Event\PromptListChangedEvent`` - When a prompt is registered + +**Listening to Events** + +You can create event listeners to respond to capability changes:: + + use Mcp\Event\ToolListChangedEvent; + use Symfony\Component\EventDispatcher\Attribute\AsEventListener; + + #[AsEventListener] + class McpCapabilityListener + { + public function onToolListChanged(ToolListChangedEvent $event): void + { + // Handle tool registration + // For example: invalidate cache, log changes, notify clients + } + } + +The events are simple marker events that notify when lists have changed, but don't contain specific details about what was added or modified. .. _`Model Context Protocol`: https://modelcontextprotocol.io/ -.. _`symfony/mcp-sdk`: https://github.com/symfony/mcp-sdk +.. _`mcp/sdk`: https://github.com/modelcontextprotocol/php-sdk .. _`Claude Desktop`: https://claude.ai/download .. _`MCP Server List`: https://modelcontextprotocol.io/examples .. _`AI Bundle`: https://github.com/symfony/ai-bundle diff --git a/src/mcp-bundle/src/Command/McpCommand.php b/src/mcp-bundle/src/Command/McpCommand.php index d3232fa5f..81134023a 100644 --- a/src/mcp-bundle/src/Command/McpCommand.php +++ b/src/mcp-bundle/src/Command/McpCommand.php @@ -11,8 +11,9 @@ namespace Symfony\AI\McpBundle\Command; -use Symfony\AI\McpSdk\Server; -use Symfony\AI\McpSdk\Server\Transport\Stdio\SymfonyConsoleTransport; +use Mcp\Server; +use Mcp\Server\Transport\StdioTransport; +use Psr\Log\LoggerInterface; use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Input\InputInterface; @@ -23,15 +24,14 @@ class McpCommand extends Command { public function __construct( private readonly Server $server, + private readonly LoggerInterface $logger, ) { parent::__construct(); } protected function execute(InputInterface $input, OutputInterface $output): int { - $this->server->connect( - new SymfonyConsoleTransport($input, $output) - ); + $this->server->connect(new StdioTransport(logger: $this->logger)); return Command::SUCCESS; } diff --git a/src/mcp-bundle/src/Controller/McpController.php b/src/mcp-bundle/src/Controller/McpController.php index 3b036aa47..29f501083 100644 --- a/src/mcp-bundle/src/Controller/McpController.php +++ b/src/mcp-bundle/src/Controller/McpController.php @@ -11,41 +11,43 @@ namespace Symfony\AI\McpBundle\Controller; -use Symfony\AI\McpSdk\Server; -use Symfony\AI\McpSdk\Server\Transport\Sse\Store\CachePoolStore; -use Symfony\AI\McpSdk\Server\Transport\Sse\StreamTransport; +use Mcp\Server; +use Mcp\Server\Transport\StreamableHttpTransport; +use Psr\Http\Message\ResponseFactoryInterface; +use Psr\Http\Message\StreamFactoryInterface; +use Psr\Log\LoggerInterface; +use Psr\Log\NullLogger; +use Symfony\Bridge\PsrHttpMessage\HttpFoundationFactoryInterface; +use Symfony\Bridge\PsrHttpMessage\HttpMessageFactoryInterface; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; -use Symfony\Component\HttpFoundation\StreamedResponse; -use Symfony\Component\Routing\Generator\UrlGeneratorInterface; -use Symfony\Component\Uid\Uuid; final readonly class McpController { public function __construct( private Server $server, - private CachePoolStore $store, - private UrlGeneratorInterface $urlGenerator, + private HttpMessageFactoryInterface $psrHttpFactory, + private HttpFoundationFactoryInterface $httpFoundationFactory, + private ResponseFactoryInterface $responseFactory, + private StreamFactoryInterface $streamFactory, + private ?LoggerInterface $logger = null, ) { } - public function sse(): StreamedResponse + public function handle(Request $request): Response { - $id = Uuid::v4(); - $endpoint = $this->urlGenerator->generate('_mcp_messages', ['id' => $id], UrlGeneratorInterface::ABSOLUTE_URL); - $transport = new StreamTransport($endpoint, $this->store, $id); - - return new StreamedResponse(fn () => $this->server->connect($transport), headers: [ - 'Content-Type' => 'text/event-stream', - 'Cache-Control' => 'no-cache', - 'X-Accel-Buffering' => 'no', - ]); - } + $psrRequest = $this->psrHttpFactory->createRequest($request); - public function messages(Request $request, Uuid $id): Response - { - $this->store->push($id, $request->getContent()); + $transport = new StreamableHttpTransport( + $psrRequest, + $this->responseFactory, + $this->streamFactory, + $this->logger ?? new NullLogger(), + ); + + $this->server->connect($transport); + $psrResponse = $transport->listen(); - return new Response(); + return $this->httpFoundationFactory->createResponse($psrResponse); } } diff --git a/src/mcp-bundle/src/DependencyInjection/McpPass.php b/src/mcp-bundle/src/DependencyInjection/McpPass.php new file mode 100644 index 000000000..1061de6d1 --- /dev/null +++ b/src/mcp-bundle/src/DependencyInjection/McpPass.php @@ -0,0 +1,43 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\McpBundle\DependencyInjection; + +use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface; +use Symfony\Component\DependencyInjection\Compiler\ServiceLocatorTagPass; +use Symfony\Component\DependencyInjection\ContainerBuilder; + +final class McpPass implements CompilerPassInterface +{ + public function process(ContainerBuilder $container): void + { + if (!$container->hasDefinition('mcp.server.builder')) { + return; + } + + $allMcpServices = []; + $mcpTags = ['mcp.tool', 'mcp.prompt', 'mcp.resource', 'mcp.resource_template']; + + foreach ($mcpTags as $tag) { + $taggedServices = $container->findTaggedServiceIds($tag); + $allMcpServices = array_merge($allMcpServices, $taggedServices); + } + + if ([] === $allMcpServices) { + return; + } + + $serviceLocatorRef = ServiceLocatorTagPass::register($container, $allMcpServices); + + $container->getDefinition('mcp.server.builder') + ->addMethodCall('setContainer', [$serviceLocatorRef]); + } +} diff --git a/src/mcp-bundle/src/McpBundle.php b/src/mcp-bundle/src/McpBundle.php index acd2374de..0359b79d5 100644 --- a/src/mcp-bundle/src/McpBundle.php +++ b/src/mcp-bundle/src/McpBundle.php @@ -11,14 +11,23 @@ namespace Symfony\AI\McpBundle; +use Http\Discovery\Psr17Factory; +use Mcp\Capability\Attribute\McpPrompt; +use Mcp\Capability\Attribute\McpResource; +use Mcp\Capability\Attribute\McpResourceTemplate; +use Mcp\Capability\Attribute\McpTool; +use Mcp\Server\Session\FileSessionStore; +use Mcp\Server\Session\InMemorySessionStore; use Symfony\AI\McpBundle\Command\McpCommand; use Symfony\AI\McpBundle\Controller\McpController; +use Symfony\AI\McpBundle\DependencyInjection\McpPass; use Symfony\AI\McpBundle\Routing\RouteLoader; -use Symfony\AI\McpSdk\Capability\Tool\IdentifierInterface; -use Symfony\AI\McpSdk\Server\NotificationHandlerInterface; -use Symfony\AI\McpSdk\Server\RequestHandlerInterface; +use Symfony\Bridge\PsrHttpMessage\Factory\HttpFoundationFactory; +use Symfony\Bridge\PsrHttpMessage\Factory\PsrHttpFactory; use Symfony\Component\Config\Definition\Configurator\DefinitionConfigurator; +use Symfony\Component\DependencyInjection\ChildDefinition; use Symfony\Component\DependencyInjection\ContainerBuilder; +use Symfony\Component\DependencyInjection\ContainerInterface; use Symfony\Component\DependencyInjection\Loader\Configurator\ContainerConfigurator; use Symfony\Component\DependencyInjection\Reference; use Symfony\Component\HttpKernel\Bundle\AbstractBundle; @@ -39,53 +48,108 @@ public function loadExtension(array $config, ContainerConfigurator $container, C $builder->setParameter('mcp.app', $config['app']); $builder->setParameter('mcp.version', $config['version']); - $builder->setParameter('mcp.page_size', $config['page_size']); + $builder->setParameter('mcp.pagination_limit', $config['pagination_limit']); + $builder->setParameter('mcp.instructions', $config['instructions']); + + $this->registerMcpAttributes($builder); if (isset($config['client_transports'])) { - $this->configureClient($config['client_transports'], $builder); + $this->configureClient($config['client_transports'], $config['http'], $builder); } + } - $builder - ->registerForAutoconfiguration(IdentifierInterface::class) - ->addTag('mcp.tool') - ; + public function build(ContainerBuilder $container): void + { + $container->addCompilerPass(new McpPass()); + } + + private function registerMcpAttributes(ContainerBuilder $builder): void + { + $mcpAttributes = [ + McpTool::class => 'mcp.tool', + McpPrompt::class => 'mcp.prompt', + McpResource::class => 'mcp.resource', + McpResourceTemplate::class => 'mcp.resource_template', + ]; + + foreach ($mcpAttributes as $attributeClass => $tag) { + $builder->registerAttributeForAutoconfiguration( + $attributeClass, + static function (ChildDefinition $definition) use ($tag): void { + $definition->addTag($tag); + } + ); + } } /** - * @param array{stdio: bool, sse: bool} $transports + * @param array{stdio: bool, http: bool} $transports + * @param array{path: string, session: array{store: string, directory: string, ttl: int}} $httpConfig */ - private function configureClient(array $transports, ContainerBuilder $container): void + private function configureClient(array $transports, array $httpConfig, ContainerBuilder $container): void { - if (!$transports['stdio'] && !$transports['sse']) { + if (!$transports['stdio'] && !$transports['http']) { return; } - $container->registerForAutoconfiguration(NotificationHandlerInterface::class) - ->addTag('mcp.server.notification_handler'); - $container->registerForAutoconfiguration(RequestHandlerInterface::class) - ->addTag('mcp.server.request_handler'); + // Register PSR factories + $container->register('mcp.psr17_factory', Psr17Factory::class); + + $container->register('mcp.psr_http_factory', PsrHttpFactory::class) + ->setArguments([ + new Reference('mcp.psr17_factory'), + new Reference('mcp.psr17_factory'), + new Reference('mcp.psr17_factory'), + new Reference('mcp.psr17_factory'), + ]); + + $container->register('mcp.http_foundation_factory', HttpFoundationFactory::class); + + // Configure session store based on HTTP config + $this->configureSessionStore($httpConfig['session'], $container); if ($transports['stdio']) { $container->register('mcp.server.command', McpCommand::class) ->setArguments([ new Reference('mcp.server'), + new Reference('logger'), ]) ->addTag('console.command'); } - if ($transports['sse']) { + if ($transports['http']) { $container->register('mcp.server.controller', McpController::class) ->setArguments([ new Reference('mcp.server'), - new Reference('mcp.server.sse.store.cache_pool'), - new Reference('router'), + new Reference('mcp.psr_http_factory'), + new Reference('mcp.http_foundation_factory'), + new Reference('mcp.psr17_factory'), + new Reference('mcp.psr17_factory'), + new Reference('monolog.logger.mcp', ContainerInterface::NULL_ON_INVALID_REFERENCE), ]) ->setPublic(true) ->addTag('controller.service_arguments'); } $container->register('mcp.server.route_loader', RouteLoader::class) - ->setArgument(0, $transports['sse']) - ->addTag('routing.route_loader'); + ->setArguments([ + $transports['http'], + $httpConfig['path'], + ]) + ->addTag('routing.loader'); + } + + /** + * @param array{store: string, directory: string, ttl: int} $sessionConfig + */ + private function configureSessionStore(array $sessionConfig, ContainerBuilder $container): void + { + if ('memory' === $sessionConfig['store']) { + $container->register('mcp.session.store', InMemorySessionStore::class) + ->setArguments([$sessionConfig['ttl']]); + } else { + $container->register('mcp.session.store', FileSessionStore::class) + ->setArguments([$sessionConfig['directory'], $sessionConfig['ttl']]); + } } } diff --git a/src/mcp-bundle/src/Routing/RouteLoader.php b/src/mcp-bundle/src/Routing/RouteLoader.php index 579a6464c..dc3e8f8ee 100644 --- a/src/mcp-bundle/src/Routing/RouteLoader.php +++ b/src/mcp-bundle/src/Routing/RouteLoader.php @@ -11,27 +11,44 @@ namespace Symfony\AI\McpBundle\Routing; +use Symfony\Component\Config\Loader\Loader; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\Routing\Exception\LogicException; use Symfony\Component\Routing\Route; use Symfony\Component\Routing\RouteCollection; -final readonly class RouteLoader +final class RouteLoader extends Loader { + private bool $loaded = false; + public function __construct( - private bool $sseTransportEnabled, + private bool $httpTransportEnabled, + private string $httpPath, ) { + parent::__construct(); } - public function __invoke(): RouteCollection + public function load(mixed $resource, ?string $type = null): RouteCollection { - if (!$this->sseTransportEnabled) { + if ($this->loaded) { + throw new LogicException('Do not add the "mcp" loader twice.'); + } + + $this->loaded = true; + + if (!$this->httpTransportEnabled) { return new RouteCollection(); } $collection = new RouteCollection(); - $collection->add('_mcp_sse', new Route('/_mcp/sse', ['_controller' => ['mcp.server.controller', 'sse']], methods: ['GET'])); - $collection->add('_mcp_messages', new Route('/_mcp/messages/{id}', ['_controller' => ['mcp.server.controller', 'messages']], methods: ['POST'])); + $collection->add('_mcp_endpoint', new Route($this->httpPath, ['_controller' => 'mcp.server.controller::handle'], methods: [Request::METHOD_GET, Request::METHOD_POST, Request::METHOD_DELETE, Request::METHOD_OPTIONS])); return $collection; } + + public function supports(mixed $resource, ?string $type = null): bool + { + return 'mcp' === $type; + } } diff --git a/src/mcp-bundle/tests/DependencyInjection/McpBundleTest.php b/src/mcp-bundle/tests/DependencyInjection/McpBundleTest.php index de7a6c896..d6f162ea5 100644 --- a/src/mcp-bundle/tests/DependencyInjection/McpBundleTest.php +++ b/src/mcp-bundle/tests/DependencyInjection/McpBundleTest.php @@ -14,10 +14,6 @@ use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; use Symfony\AI\McpBundle\McpBundle; -use Symfony\AI\McpSdk\Capability\Tool\IdentifierInterface; -use Symfony\AI\McpSdk\Server\NotificationHandlerInterface; -use Symfony\AI\McpSdk\Server\RequestHandler\ToolListHandler; -use Symfony\AI\McpSdk\Server\RequestHandlerInterface; use Symfony\Component\DependencyInjection\ContainerBuilder; class McpBundleTest extends TestCase @@ -28,6 +24,8 @@ public function testDefaultConfiguration() $this->assertSame('app', $container->getParameter('mcp.app')); $this->assertSame('0.0.1', $container->getParameter('mcp.version')); + $this->assertSame(50, $container->getParameter('mcp.pagination_limit')); + $this->assertNull($container->getParameter('mcp.instructions')); } public function testCustomConfiguration() @@ -36,11 +34,28 @@ public function testCustomConfiguration() 'mcp' => [ 'app' => 'my-mcp-app', 'version' => '1.2.3', + 'pagination_limit' => 25, + 'instructions' => 'This server provides weather and calendar tools', ], ]); $this->assertSame('my-mcp-app', $container->getParameter('mcp.app')); $this->assertSame('1.2.3', $container->getParameter('mcp.version')); + $this->assertSame(25, $container->getParameter('mcp.pagination_limit')); + $this->assertSame('This server provides weather and calendar tools', $container->getParameter('mcp.instructions')); + } + + public function testMcpLoggerServiceIsCreated() + { + $container = $this->buildContainer([]); + + $this->assertTrue($container->hasDefinition('monolog.logger.mcp')); + + $definition = $container->getDefinition('monolog.logger.mcp'); + $this->assertInstanceOf(\Symfony\Component\DependencyInjection\ChildDefinition::class, $definition); + $this->assertSame('monolog.logger_prototype', $definition->getParent()); + $this->assertSame(['mcp'], $definition->getArguments()); + $this->assertTrue($definition->hasTag('monolog.logger')); } #[DataProvider('provideClientTransportsConfiguration')] @@ -66,7 +81,7 @@ public static function provideClientTransportsConfiguration(): iterable yield 'no transports enabled' => [ 'config' => [ 'stdio' => false, - 'sse' => false, + 'http' => false, ], 'expectedServices' => [ 'mcp.server.command' => false, @@ -78,7 +93,7 @@ public static function provideClientTransportsConfiguration(): iterable yield 'stdio transport enabled' => [ 'config' => [ 'stdio' => true, - 'sse' => false, + 'http' => false, ], 'expectedServices' => [ 'mcp.server.command' => true, @@ -87,10 +102,10 @@ public static function provideClientTransportsConfiguration(): iterable ], ]; - yield 'sse transport enabled' => [ + yield 'http transport enabled' => [ 'config' => [ 'stdio' => false, - 'sse' => true, + 'http' => true, ], 'expectedServices' => [ 'mcp.server.command' => false, @@ -102,7 +117,7 @@ public static function provideClientTransportsConfiguration(): iterable yield 'both transports enabled' => [ 'config' => [ 'stdio' => true, - 'sse' => true, + 'http' => true, ], 'expectedServices' => [ 'mcp.server.command' => true, @@ -112,76 +127,173 @@ public static function provideClientTransportsConfiguration(): iterable ]; } - public function testToolAutoconfiguration() + public function testServerServices() { - $container = $this->buildContainer([]); + $container = $this->buildContainer([ + 'mcp' => [ + 'client_transports' => [ + 'stdio' => true, + 'http' => true, + ], + ], + ]); + + // Test that core MCP services are registered + $this->assertTrue($container->hasDefinition('mcp.server')); + $this->assertTrue($container->hasDefinition('mcp.session.store')); - $autoconfiguredInstances = $container->getAutoconfiguredInstanceof(); + // Test that ServerBuilder is properly configured with EventDispatcher + $builderDefinition = $container->getDefinition('mcp.server.builder'); + $methodCalls = $builderDefinition->getMethodCalls(); - $this->assertArrayHasKey(IdentifierInterface::class, $autoconfiguredInstances); - $this->assertArrayHasKey('mcp.tool', $autoconfiguredInstances[IdentifierInterface::class]->getTags()); + $hasEventDispatcherCall = false; + foreach ($methodCalls as $call) { + if ('setEventDispatcher' === $call[0]) { + $hasEventDispatcherCall = true; + break; + } + } + $this->assertTrue($hasEventDispatcherCall, 'ServerBuilder should have setEventDispatcher method call'); } - public function testServerAutoconfigurations() + public function testMcpToolAttributeAutoconfiguration() { $container = $this->buildContainer([ 'mcp' => [ 'client_transports' => [ 'stdio' => true, - 'sse' => true, ], ], ]); - $autoconfiguredInstances = $container->getAutoconfiguredInstanceof(); + // Test that McpTool attribute is autoconfigured with mcp.tool tag + $attributeAutoconfigurators = $container->getAttributeAutoconfigurators(); + $this->assertArrayHasKey('Mcp\Capability\Attribute\McpTool', $attributeAutoconfigurators); + } - $this->assertArrayHasKey(NotificationHandlerInterface::class, $autoconfiguredInstances); - $this->assertArrayHasKey(RequestHandlerInterface::class, $autoconfiguredInstances); + public function testMcpPromptAttributeAutoconfiguration() + { + $container = $this->buildContainer([ + 'mcp' => [ + 'client_transports' => [ + 'stdio' => true, + ], + ], + ]); - $this->assertArrayHasKey('mcp.server.notification_handler', $autoconfiguredInstances[NotificationHandlerInterface::class]->getTags()); - $this->assertArrayHasKey('mcp.server.request_handler', $autoconfiguredInstances[RequestHandlerInterface::class]->getTags()); + // Test that McpPrompt attribute is autoconfigured with mcp.prompt tag + $attributeAutoconfigurators = $container->getAttributeAutoconfigurators(); + $this->assertArrayHasKey('Mcp\Capability\Attribute\McpPrompt', $attributeAutoconfigurators); } - public function testDefaultPageSizeConfiguration() + public function testMcpResourceAttributeAutoconfiguration() { - $container = $this->buildContainer([]); + $container = $this->buildContainer([ + 'mcp' => [ + 'client_transports' => [ + 'stdio' => true, + ], + ], + ]); - $this->assertSame(20, $container->getParameter('mcp.page_size')); + // Test that McpResource attribute is autoconfigured with mcp.resource tag + $attributeAutoconfigurators = $container->getAttributeAutoconfigurators(); + $this->assertArrayHasKey('Mcp\Capability\Attribute\McpResource', $attributeAutoconfigurators); + } - $this->assertTrue($container->hasDefinition('mcp.server.request_handler.tool_list')); + public function testMcpResourceTemplateAttributeAutoconfiguration() + { + $container = $this->buildContainer([ + 'mcp' => [ + 'client_transports' => [ + 'stdio' => true, + ], + ], + ]); - $definition = $container->getDefinition('mcp.server.request_handler.tool_list'); - $this->assertSame(ToolListHandler::class, $definition->getClass()); + // Test that McpResourceTemplate attribute is autoconfigured with mcp.resource_template tag + $attributeAutoconfigurators = $container->getAttributeAutoconfigurators(); + $this->assertArrayHasKey('Mcp\Capability\Attribute\McpResourceTemplate', $attributeAutoconfigurators); } - public function testCustomPageSizeConfiguration() + public function testHttpConfigurationDefaults() { $container = $this->buildContainer([ 'mcp' => [ - 'page_size' => 50, + 'client_transports' => [ + 'http' => true, + ], ], ]); - $this->assertSame(50, $container->getParameter('mcp.page_size')); + // Test HTTP route loader defaults + $this->assertTrue($container->hasDefinition('mcp.server.route_loader')); + $routeLoaderDefinition = $container->getDefinition('mcp.server.route_loader'); + $arguments = $routeLoaderDefinition->getArguments(); + $this->assertTrue($arguments[0]); // HTTP transport enabled + $this->assertSame('/_mcp', $arguments[1]); // Default path + + // Test session store defaults (file store) + $this->assertTrue($container->hasDefinition('mcp.session.store')); + $sessionStoreDefinition = $container->getDefinition('mcp.session.store'); + $this->assertSame('Mcp\Server\Session\FileSessionStore', $sessionStoreDefinition->getClass()); + $sessionArguments = $sessionStoreDefinition->getArguments(); + $this->assertSame('%kernel.cache_dir%/mcp-sessions', $sessionArguments[0]); // Default directory + $this->assertSame(3600, $sessionArguments[1]); // Default TTL } - public function testMissingHandlerServices() + public function testHttpConfigurationCustom() { $container = $this->buildContainer([ 'mcp' => [ 'client_transports' => [ - 'stdio' => true, - 'sse' => false, + 'http' => true, + ], + 'http' => [ + 'path' => '/custom-mcp', + 'session' => [ + 'store' => 'memory', + 'directory' => '/custom/sessions', + 'ttl' => 7200, + ], ], ], ]); - // Currently, only ToolListHandler is registered - $this->assertTrue($container->hasDefinition('mcp.server.request_handler.tool_list')); + // Test custom HTTP path + $routeLoaderDefinition = $container->getDefinition('mcp.server.route_loader'); + $arguments = $routeLoaderDefinition->getArguments(); + $this->assertSame('/custom-mcp', $arguments[1]); + + // Test custom session store (memory) + $sessionStoreDefinition = $container->getDefinition('mcp.session.store'); + $this->assertSame('Mcp\Server\Session\InMemorySessionStore', $sessionStoreDefinition->getClass()); + $sessionArguments = $sessionStoreDefinition->getArguments(); + $this->assertSame(7200, $sessionArguments[0]); // Custom TTL for memory store + } + + public function testSessionStoreFileConfiguration() + { + $container = $this->buildContainer([ + 'mcp' => [ + 'client_transports' => [ + 'http' => true, + ], + 'http' => [ + 'session' => [ + 'store' => 'file', + 'directory' => '/var/cache/mcp', + 'ttl' => 1800, + ], + ], + ], + ]); - // These services should be registered but are currently missing - $this->assertFalse($container->hasDefinition('mcp.server.request_handler.resource_list')); - $this->assertFalse($container->hasDefinition('mcp.server.request_handler.prompt_list')); + $sessionStoreDefinition = $container->getDefinition('mcp.session.store'); + $this->assertSame('Mcp\Server\Session\FileSessionStore', $sessionStoreDefinition->getClass()); + $arguments = $sessionStoreDefinition->getArguments(); + $this->assertSame('/var/cache/mcp', $arguments[0]); // Custom directory + $this->assertSame(1800, $arguments[1]); // Custom TTL } private function buildContainer(array $configuration): ContainerBuilder @@ -190,6 +302,7 @@ private function buildContainer(array $configuration): ContainerBuilder $container->setParameter('kernel.debug', true); $container->setParameter('kernel.environment', 'test'); $container->setParameter('kernel.build_dir', 'public'); + $container->setParameter('kernel.project_dir', '/path/to/project'); $extension = (new McpBundle())->getContainerExtension(); $extension->load($configuration, $container); diff --git a/src/mcp-bundle/tests/DependencyInjection/McpPassTest.php b/src/mcp-bundle/tests/DependencyInjection/McpPassTest.php new file mode 100644 index 000000000..0982e8054 --- /dev/null +++ b/src/mcp-bundle/tests/DependencyInjection/McpPassTest.php @@ -0,0 +1,119 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\McpBundle\Tests\DependencyInjection; + +use PHPUnit\Framework\TestCase; +use Symfony\AI\McpBundle\DependencyInjection\McpPass; +use Symfony\Component\DependencyInjection\ContainerBuilder; +use Symfony\Component\DependencyInjection\Definition; + +/** + * @covers \Symfony\AI\McpBundle\DependencyInjection\McpPass + */ +final class McpPassTest extends TestCase +{ + public function testCreatesServiceLocatorForAllMcpServices() + { + $container = new ContainerBuilder(); + + $container->setDefinition('mcp.server.builder', new Definition()); + + // Add services with different MCP tags + $container->setDefinition('tool_service', (new Definition())->addTag('mcp.tool')); + $container->setDefinition('prompt_service', (new Definition())->addTag('mcp.prompt')); + $container->setDefinition('resource_service', (new Definition())->addTag('mcp.resource')); + $container->setDefinition('template_service', (new Definition())->addTag('mcp.resource_template')); + + $pass = new McpPass(); + $pass->process($container); + + $builderDefinition = $container->getDefinition('mcp.server.builder'); + $methodCalls = $builderDefinition->getMethodCalls(); + + $this->assertCount(1, $methodCalls); + $this->assertSame('setContainer', $methodCalls[0][0]); + + // Verify service locator contains all MCP services + $serviceLocatorId = (string) $methodCalls[0][1][0]; + $this->assertTrue($container->hasDefinition($serviceLocatorId)); + + $serviceLocatorDef = $container->getDefinition($serviceLocatorId); + $services = $serviceLocatorDef->getArgument(0); + + $this->assertArrayHasKey('tool_service', $services); + $this->assertArrayHasKey('prompt_service', $services); + $this->assertArrayHasKey('resource_service', $services); + $this->assertArrayHasKey('template_service', $services); + } + + public function testDoesNothingWhenNoMcpServicesTagged() + { + $container = new ContainerBuilder(); + + $container->setDefinition('mcp.server.builder', new Definition()); + + $pass = new McpPass(); + $pass->process($container); + + $builderDefinition = $container->getDefinition('mcp.server.builder'); + $methodCalls = $builderDefinition->getMethodCalls(); + + $this->assertEmpty($methodCalls); + } + + public function testDoesNothingWhenNoServerBuilder() + { + $container = new ContainerBuilder(); + + // Add MCP services but no server builder + $container->setDefinition('tool_service', (new Definition())->addTag('mcp.tool')); + + $pass = new McpPass(); + $pass->process($container); + + // Should not create any service locator + $serviceIds = array_keys($container->getDefinitions()); + $serviceLocators = array_filter($serviceIds, fn ($id) => str_contains($id, 'service_locator')); + + $this->assertEmpty($serviceLocators); + } + + public function testHandlesPartialMcpServices() + { + $container = new ContainerBuilder(); + + $container->setDefinition('mcp.server.builder', new Definition()); + + // Only add tools and prompts, no resources + $container->setDefinition('tool_service', (new Definition())->addTag('mcp.tool')); + $container->setDefinition('prompt_service', (new Definition())->addTag('mcp.prompt')); + + $pass = new McpPass(); + $pass->process($container); + + $builderDefinition = $container->getDefinition('mcp.server.builder'); + $methodCalls = $builderDefinition->getMethodCalls(); + + $this->assertCount(1, $methodCalls); + $this->assertSame('setContainer', $methodCalls[0][0]); + + // Verify service locator contains only the tagged services + $serviceLocatorId = (string) $methodCalls[0][1][0]; + $serviceLocatorDef = $container->getDefinition($serviceLocatorId); + $services = $serviceLocatorDef->getArgument(0); + + $this->assertArrayHasKey('tool_service', $services); + $this->assertArrayHasKey('prompt_service', $services); + $this->assertArrayNotHasKey('resource_service', $services); + $this->assertArrayNotHasKey('template_service', $services); + } +} diff --git a/src/mcp-sdk/.gitattributes b/src/mcp-sdk/.gitattributes deleted file mode 100644 index 679c5c60b..000000000 --- a/src/mcp-sdk/.gitattributes +++ /dev/null @@ -1,7 +0,0 @@ -/.git* export-ignore -/examples export-ignore -/tests export-ignore -/.php-cs-fixer.dist.php export-ignore -/phpstan.dist.neon export-ignore -/phpunit.xml.dist export-ignore -AGENTS.md export-ignore diff --git a/src/mcp-sdk/.github/PULL_REQUEST_TEMPLATE.md b/src/mcp-sdk/.github/PULL_REQUEST_TEMPLATE.md deleted file mode 100644 index fcb87228a..000000000 --- a/src/mcp-sdk/.github/PULL_REQUEST_TEMPLATE.md +++ /dev/null @@ -1,8 +0,0 @@ -Please do not submit any Pull Requests here. They will be closed. ---- - -Please submit your PR here instead: -https://github.com/symfony/ai - -This repository is what we call a "subtree split": a read-only subset of that main repository. -We're looking forward to your PR there! diff --git a/src/mcp-sdk/.github/workflows/close-pull-request.yml b/src/mcp-sdk/.github/workflows/close-pull-request.yml deleted file mode 100644 index 207153fd5..000000000 --- a/src/mcp-sdk/.github/workflows/close-pull-request.yml +++ /dev/null @@ -1,20 +0,0 @@ -name: Close Pull Request - -on: - pull_request_target: - types: [opened] - -jobs: - run: - runs-on: ubuntu-latest - steps: - - uses: superbrothers/close-pull-request@v3 - with: - comment: | - Thanks for your Pull Request! We love contributions. - - However, you should instead open your PR on the main repository: - https://github.com/symfony/ai - - This repository is what we call a "subtree split": a read-only subset of that main repository. - We're looking forward to your PR there! diff --git a/src/mcp-sdk/.gitignore b/src/mcp-sdk/.gitignore deleted file mode 100644 index 22dd1a417..000000000 --- a/src/mcp-sdk/.gitignore +++ /dev/null @@ -1,4 +0,0 @@ -.phpunit.cache -.php-cs-fixer.cache -composer.lock -vendor diff --git a/src/mcp-sdk/CHANGELOG.md b/src/mcp-sdk/CHANGELOG.md deleted file mode 100644 index c484c44de..000000000 --- a/src/mcp-sdk/CHANGELOG.md +++ /dev/null @@ -1,24 +0,0 @@ -CHANGELOG -========= - -0.1 ---- - - * Add Model Context Protocol (MCP) implementation for LLM-application communication - * Add JSON-RPC based protocol handling with `JsonRpcHandler` - * Add three core MCP capabilities: - - Resources: File-like data readable by clients (API responses, file contents) - - Tools: Functions callable by LLMs (with user approval) - - Prompts: Pre-written templates for specific tasks - * Add multiple transport implementations: - - Symfony Console Transport for testing and CLI applications - - Stream Transport supporting Server-Sent Events (SSE) and HTTP streaming - - STDIO transport for command-line interfaces - * Add capability chains for organizing features: - - `ToolChain` for tool management - - `ResourceChain` for resource management - - `PromptChain` for prompt template management - * Add Server component managing transport connections - * Add request/notification handlers for MCP operations - * Add standardized interface enabling LLMs to interact with external systems - * Add support for building LLM "plugins" with extra context capabilities \ No newline at end of file diff --git a/src/mcp-sdk/LICENSE b/src/mcp-sdk/LICENSE deleted file mode 100644 index bc38d714e..000000000 --- a/src/mcp-sdk/LICENSE +++ /dev/null @@ -1,19 +0,0 @@ -Copyright (c) 2025-present Fabien Potencier - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is furnished -to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -THE SOFTWARE. diff --git a/src/mcp-sdk/README.md b/src/mcp-sdk/README.md deleted file mode 100644 index b646e208e..000000000 --- a/src/mcp-sdk/README.md +++ /dev/null @@ -1,28 +0,0 @@ -# Model Context Protocol PHP SDK - -Model Context Protocol SDK for Client and Server applications in PHP. - -**This Component is experimental**. -[Experimental features](https://symfony.com/doc/current/contributing/code/experimental.html) -are not covered by Symfony's -[Backward Compatibility Promise](https://symfony.com/doc/current/contributing/code/bc.html). - -## Installation - -```bash -composer require symfony/mcp-sdk -``` - -This is a low level SDK that implements the [Model Context Protocol](https://modelcontextprotocol.io/) -(MCP). The protocol is used by LLM model to build "plugins" and give them extra -context. Example the logged in users' latest order. - -**This repository is a READ-ONLY sub-tree split**. See -https://github.com/symfony/ai to create issues or submit pull requests. - -## Resources - -- [Documentation](doc/index.rst) -- [Report issues](https://github.com/symfony/ai/issues) and - [send Pull Requests](https://github.com/symfony/ai/pulls) - in the [main Symfony AI repository](https://github.com/symfony/ai) diff --git a/src/mcp-sdk/composer.json b/src/mcp-sdk/composer.json deleted file mode 100644 index a21ce8a78..000000000 --- a/src/mcp-sdk/composer.json +++ /dev/null @@ -1,44 +0,0 @@ -{ - "name": "symfony/mcp-sdk", - "description": "Model Context Protocol SDK for Client and Server applications in PHP", - "license": "MIT", - "type": "library", - "authors": [ - { - "name": "Christopher Hertel", - "email": "mail@christopher-hertel.de" - }, - { - "name": "Tobias Nyholm", - "email": "tobias.nyholm@gmail.com" - } - ], - "require": { - "php": "^8.2", - "psr/log": "^1.0 || ^2.0 || ^3.0", - "symfony/uid": "^7.3|^8.0" - }, - "require-dev": { - "phpstan/phpstan": "^2.1", - "phpstan/phpstan-strict-rules": "^2.0", - "phpunit/phpunit": "^11.5", - "psr/cache": "^3.0", - "symfony/console": "^7.3|^8.0" - }, - "suggest": { - "psr/cache": "To use CachePoolStore with SSE Transport", - "symfony/console": "To use SymfonyConsoleTransport for STDIO" - }, - "autoload": { - "psr-4": { - "Symfony\\AI\\McpSdk\\": "src/" - } - }, - "autoload-dev": { - "psr-4": { - "Symfony\\AI\\McpSdk\\Tests\\": "tests/", - "Symfony\\AI\\PHPStan\\": "../../.phpstan/" - } - }, - "minimum-stability": "dev" -} diff --git a/src/mcp-sdk/doc/index.rst b/src/mcp-sdk/doc/index.rst deleted file mode 100644 index 366066013..000000000 --- a/src/mcp-sdk/doc/index.rst +++ /dev/null @@ -1,158 +0,0 @@ -Model Context Protocol SDK -========================== - -Symfony AI MCP SDK is the low level library that enables communication between -a PHP application and an LLM model. - -Installation ------------- - -Install the SDK using Composer: - -.. code-block:: terminal - - $ composer require symfony/mcp-sdk - -Usage ------ - -The `Model Context Protocol`_ is built on top of JSON-RPC. There two types of -messages. A Notification and Request. The Notification is just a status update -that something has happened. There is never a response to a Notification. A Request -is a message that expects a response. There are 3 concepts/capabilities that you -may use. These are:: - -1. **Resources**: File-like data that can be read by clients (like API responses or file contents) -1. **Tools**: Functions that can be called by the LLM (with user approval) -1. **Prompts**: Pre-written templates that help users accomplish specific tasks - -The SDK comes with NotificationHandlers and RequestHandlers which are expected -to be wired up in your application. - -JsonRpcHandler -.............. - -The ``Symfony\AI\McpSdk\Server\JsonRpcHandler`` is the heart of the SDK. It is here -you inject the NotificationHandlers and RequestHandlers. It is recommended to use -the built-in handlers in ``Symfony\AI\McpSdk\Server\NotificationHandlers\*`` and -``Symfony\AI\McpSdk\Server\RequestHandlers\*``. - -The ``Symfony\AI\McpSdk\Server\JsonRpcHandler`` is started and kept running by -the ``Symfony\AI\McpSdk\Server`` - -Transports -.......... - -The SDK supports multiple transports for sending and receiving messages. The -``Symfony\AI\McpSdk\Server`` is using the transport to fetch a message, then -give it to the ``Symfony\AI\McpSdk\Server\JsonRpcHandler`` and finally send the -response/error back to the transport. The SDK comes with a few transports:: - -1. **Symfony Console Transport**: Good for testing and for CLI applications -1. **Stream Transport**: It uses Server Side Events (SSE) and HTTP streaming - -Capabilities -............ - -Any client would like to discover the capabilities of the server. Exactly what -the server supports is defined in the ``Symfony\AI\McpSdk\Server\RequestHandler\InitializeHandler``. -When the client connects, it sees the capabilities and will ask the server to list -the tools/resource/prompts etc. When you want to add a new capability, example a -**Tool** that can tell the current time, you need to provide some metadata to the -``Symfony\AI\McpSdk\Server\RequestHandler\ToolListHandler``:: - - namespace App; - - use Symfony\AI\McpSdk\Capability\Tool\MetadataInterface; - - class CurrentTimeToolMetadata implements MetadataInterface - { - public function getName(): string - { - return 'Current time'; - } - - public function getDescription(): string - { - return 'Returns the current time in UTC'; - } - - public function getInputSchema(): array - { - return [ - 'type' => 'object', - 'properties' => [ - 'format' => [ - 'type' => 'string', - 'description' => 'The format of the time, e.g. "Y-m-d H:i:s"', - 'default' => 'Y-m-d H:i:s', - ], - ], - 'required' => [], - ]; - } - } - -We would also need a class to actually execute the tool:: - - namespace App; - - use Symfony\AI\McpSdk\Capability\Tool\IdentifierInterface; - use Symfony\AI\McpSdk\Capability\Tool\ToolCall; - use Symfony\AI\McpSdk\Capability\Tool\ToolCallResult; - use Symfony\AI\McpSdk\Capability\Tool\ToolExecutorInterface; - - class CurrentTimeToolExecutor implements ToolExecutorInterface, IdentifierInterface - { - public function getName(): string - { - return 'Current time'; - } - - public function call(ToolCall $input): ToolCallResult - { - $format = $input->arguments['format'] ?? 'Y-m-d H:i:s'; - - return new ToolCallResult( - (new \DateTime('now', new \DateTimeZone('UTC')))->format($format) - ); - } - } - -If you have multiple tools, you can put them in a ToolChain:: - - $tools = new ToolChain([ - new CurrentTimeToolMetadata(), - new CurrentTimeToolExecutor(), - ]); - - $jsonRpcHandler = new Symfony\AI\McpSdk\Server\JsonRpcHandler( - new Symfony\AI\McpSdk\Message\Factory(), - [ - new ToolCallHandler($tools), - new ToolListHandler($tools), - // Other RequestHandlers ... - ], - [ - // Other NotificationHandlers ... - ], - new NullLogger() - ); - -With this metadata and executor, the client can now call the tool. - -Extending the SDK ------------------ - -If you want to extend the SDK, you can create your own RequestHandlers and NotificationHandlers. -The provided one are very good defaults for most applications but they are not -a requirement. - -If you do decide to use them, you get the benefit of having a well-defined interfaces -and value objects to work with. They will assure that you follow the `Model Context Protocol`_. -specification. - -You also have the Transport abstraction that allows you to create your own transport -if non of the standard ones fit your needs. - -.. _`Model Context Protocol`: https://modelcontextprotocol.io/ diff --git a/src/mcp-sdk/examples/cli/README.md b/src/mcp-sdk/examples/cli/README.md deleted file mode 100644 index 0512b6bbb..000000000 --- a/src/mcp-sdk/examples/cli/README.md +++ /dev/null @@ -1,29 +0,0 @@ -# Example app with CLI - -This is just for testing and debugging purposes. - - -Install and create symlink with: - -```bash -cd /path/to/your/project/examples/cli -composer update -rm -rf vendor/php-llm/mcp-sdk/src -ln -s /path/to/your/project/src /path/to/your/project/examples/cli/vendor/php-llm/mcp-sdk/src -``` - -Run the CLI with: - -```bash -DEBUG=1 php index.php -``` - -You will see debug outputs to help you understand what is happening. - -In this terminal you can now test add some json strings. See `example-requests.json`. - -Run with Inspector: - -```bash -npx @modelcontextprotocol/inspector php index.php -``` diff --git a/src/mcp-sdk/examples/cli/composer.json b/src/mcp-sdk/examples/cli/composer.json deleted file mode 100644 index 14018b2e6..000000000 --- a/src/mcp-sdk/examples/cli/composer.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "name": "php-llm/mcp-cli-example", - "description": "An example application for CLI", - "license": "MIT", - "type": "project", - "authors": [ - { - "name": "Tobias Nyholm", - "email": "tobias.nyholm@gmail.com" - } - ], - "require": { - "php": ">=8.2", - "symfony/console": "^7.3|^8.0", - "symfony/mcp-sdk": "@dev" - }, - "minimum-stability": "stable", - "autoload": { - "psr-4": { - "App\\": "src/" - } - } -} diff --git a/src/mcp-sdk/examples/cli/example-requests.json b/src/mcp-sdk/examples/cli/example-requests.json deleted file mode 100644 index b2b72f880..000000000 --- a/src/mcp-sdk/examples/cli/example-requests.json +++ /dev/null @@ -1,12 +0,0 @@ -[ - {"jsonrpc": "2.0", "id": 1, "method": "resources/list", "params": []}, - {"jsonrpc": "2.0", "id": 2, "method": "resources/read", "params": {"uri": "file:///project/src/main.rs"}}, - - {"jsonrpc": "2.0", "id": 1, "method": "tools/list"}, - {"jsonrpc": "2.0", "id": 2, "method": "tools/call", "params": {"name": "Current time"}}, - {"jsonrpc": "2.0", "id": 2, "method": "tools/call", "params": {"name": "Current time","arguments": {"format": "Y-m-d"}}}, - - {"jsonrpc": "2.0", "id": 1, "method": "prompts/list"}, - {"jsonrpc": "2.0", "id": 2 ,"method": "prompts/get", "params": {"name": "Greet"}}, - {"jsonrpc": "2.0", "id": 2 ,"method": "prompts/get", "params": {"name": "Greet", "arguments": { "firstName": "Tobias" }}} -] \ No newline at end of file diff --git a/src/mcp-sdk/examples/cli/index.php b/src/mcp-sdk/examples/cli/index.php deleted file mode 100644 index 740b953e9..000000000 --- a/src/mcp-sdk/examples/cli/index.php +++ /dev/null @@ -1,39 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -require __DIR__.'/vendor/autoload.php'; - -use Symfony\Component\Console as SymfonyConsole; -use Symfony\Component\Console\Output\OutputInterface; - -$debug = (bool) ($_SERVER['DEBUG'] ?? false); - -// Setup input, output and logger -$input = new SymfonyConsole\Input\ArgvInput($argv); -$output = new SymfonyConsole\Output\ConsoleOutput($debug ? OutputInterface::VERBOSITY_VERY_VERBOSE : OutputInterface::VERBOSITY_NORMAL); -$logger = new SymfonyConsole\Logger\ConsoleLogger($output); - -// Configure the JsonRpcHandler and build the functionality -$jsonRpcHandler = new Symfony\AI\McpSdk\Server\JsonRpcHandler( - new Symfony\AI\McpSdk\Message\Factory(), - App\Builder::buildRequestHandlers(), - App\Builder::buildNotificationHandlers(), - $logger -); - -// Set up the server -$sever = new Symfony\AI\McpSdk\Server($jsonRpcHandler, $logger); - -// Create the transport layer using Symfony Console -$transport = new Symfony\AI\McpSdk\Server\Transport\Stdio\SymfonyConsoleTransport($input, $output); - -// Start our application -$sever->connect($transport); diff --git a/src/mcp-sdk/examples/cli/src/Builder.php b/src/mcp-sdk/examples/cli/src/Builder.php deleted file mode 100644 index c2fc9cee3..000000000 --- a/src/mcp-sdk/examples/cli/src/Builder.php +++ /dev/null @@ -1,69 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace App; - -use Symfony\AI\McpSdk\Capability\PromptChain; -use Symfony\AI\McpSdk\Capability\ResourceChain; -use Symfony\AI\McpSdk\Capability\ToolChain; -use Symfony\AI\McpSdk\Server\NotificationHandler\InitializedHandler; -use Symfony\AI\McpSdk\Server\NotificationHandlerInterface; -use Symfony\AI\McpSdk\Server\RequestHandler\InitializeHandler; -use Symfony\AI\McpSdk\Server\RequestHandler\PingHandler; -use Symfony\AI\McpSdk\Server\RequestHandler\PromptGetHandler; -use Symfony\AI\McpSdk\Server\RequestHandler\PromptListHandler; -use Symfony\AI\McpSdk\Server\RequestHandler\ResourceListHandler; -use Symfony\AI\McpSdk\Server\RequestHandler\ResourceReadHandler; -use Symfony\AI\McpSdk\Server\RequestHandler\ToolCallHandler; -use Symfony\AI\McpSdk\Server\RequestHandler\ToolListHandler; -use Symfony\AI\McpSdk\Server\RequestHandlerInterface; - -class Builder -{ - /** - * @return list - */ - public static function buildRequestHandlers(): array - { - $promptManager = new PromptChain([ - new ExamplePrompt(), - ]); - - $resourceManager = new ResourceChain([ - new ExampleResource(), - ]); - - $toolManager = new ToolChain([ - new ExampleTool(), - ]); - - return [ - new InitializeHandler(), - new PingHandler(), - new PromptListHandler($promptManager), - new PromptGetHandler($promptManager), - new ResourceListHandler($resourceManager), - new ResourceReadHandler($resourceManager), - new ToolCallHandler($toolManager), - new ToolListHandler($toolManager), - ]; - } - - /** - * @return list - */ - public static function buildNotificationHandlers(): array - { - return [ - new InitializedHandler(), - ]; - } -} diff --git a/src/mcp-sdk/examples/cli/src/ExamplePrompt.php b/src/mcp-sdk/examples/cli/src/ExamplePrompt.php deleted file mode 100644 index 919ff3359..000000000 --- a/src/mcp-sdk/examples/cli/src/ExamplePrompt.php +++ /dev/null @@ -1,55 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace App; - -use Symfony\AI\McpSdk\Capability\Prompt\MetadataInterface; -use Symfony\AI\McpSdk\Capability\Prompt\PromptGet; -use Symfony\AI\McpSdk\Capability\Prompt\PromptGetResult; -use Symfony\AI\McpSdk\Capability\Prompt\PromptGetResultMessages; -use Symfony\AI\McpSdk\Capability\Prompt\PromptGetterInterface; - -class ExamplePrompt implements MetadataInterface, PromptGetterInterface -{ - public function get(PromptGet $input): PromptGetResult - { - $firstName = $input->arguments['first name'] ?? null; - - return new PromptGetResult( - $this->getDescription(), - [new PromptGetResultMessages( - 'user', - \sprintf('Hello %s', $firstName ?? 'World') - )] - ); - } - - public function getName(): string - { - return 'Greet'; - } - - public function getDescription(): ?string - { - return 'Greet a person with a nice message'; - } - - public function getArguments(): array - { - return [ - [ - 'name' => 'first name', - 'description' => 'The name of the person to greet', - 'required' => false, - ], - ]; - } -} diff --git a/src/mcp-sdk/examples/cli/src/ExampleResource.php b/src/mcp-sdk/examples/cli/src/ExampleResource.php deleted file mode 100644 index 724cb9456..000000000 --- a/src/mcp-sdk/examples/cli/src/ExampleResource.php +++ /dev/null @@ -1,53 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace App; - -use Symfony\AI\McpSdk\Capability\Resource\MetadataInterface; -use Symfony\AI\McpSdk\Capability\Resource\ResourceRead; -use Symfony\AI\McpSdk\Capability\Resource\ResourceReaderInterface; -use Symfony\AI\McpSdk\Capability\Resource\ResourceReadResult; - -class ExampleResource implements MetadataInterface, ResourceReaderInterface -{ - public function read(ResourceRead $input): ResourceReadResult - { - return new ResourceReadResult( - 'Content of '.$this->getName(), - $this->getUri(), - ); - } - - public function getUri(): string - { - return 'file:///project/src/main.rs'; - } - - public function getName(): string - { - return 'My resource'; - } - - public function getDescription(): ?string - { - return 'This is just an example'; - } - - public function getMimeType(): ?string - { - return null; - } - - public function getSize(): ?int - { - return null; - } -} diff --git a/src/mcp-sdk/examples/cli/src/ExampleTool.php b/src/mcp-sdk/examples/cli/src/ExampleTool.php deleted file mode 100644 index 70097be1b..000000000 --- a/src/mcp-sdk/examples/cli/src/ExampleTool.php +++ /dev/null @@ -1,70 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace App; - -use Symfony\AI\McpSdk\Capability\Tool\MetadataInterface; -use Symfony\AI\McpSdk\Capability\Tool\ToolAnnotationsInterface; -use Symfony\AI\McpSdk\Capability\Tool\ToolCall; -use Symfony\AI\McpSdk\Capability\Tool\ToolCallResult; -use Symfony\AI\McpSdk\Capability\Tool\ToolExecutorInterface; - -class ExampleTool implements MetadataInterface, ToolExecutorInterface -{ - public function call(ToolCall $input): ToolCallResult - { - $format = $input->arguments['format'] ?? 'Y-m-d H:i:s'; - - return new ToolCallResult( - (new \DateTime('now', new \DateTimeZone('UTC')))->format($format) - ); - } - - public function getName(): string - { - return 'Current time'; - } - - public function getDescription(): string - { - return 'Returns the current time in UTC'; - } - - public function getInputSchema(): array - { - return [ - 'type' => 'object', - 'properties' => [ - 'format' => [ - 'type' => 'string', - 'description' => 'The format of the time, e.g. "Y-m-d H:i:s"', - 'default' => 'Y-m-d H:i:s', - ], - ], - 'required' => [], - ]; - } - - public function getOutputSchema(): ?array - { - return null; - } - - public function getTitle(): ?string - { - return null; - } - - public function getAnnotations(): ?ToolAnnotationsInterface - { - return null; - } -} diff --git a/src/mcp-sdk/phpstan.dist.neon b/src/mcp-sdk/phpstan.dist.neon deleted file mode 100644 index 4ac488675..000000000 --- a/src/mcp-sdk/phpstan.dist.neon +++ /dev/null @@ -1,14 +0,0 @@ -includes: - - ../../.phpstan/extension.neon - -parameters: - level: 6 - paths: - - examples/ - - src/ - - tests/ - excludePaths: - - examples/cli/vendor (?) - ignoreErrors: - - - message: "#^Method .*::test.*\\(\\) has no return type specified\\.$#" diff --git a/src/mcp-sdk/phpunit.xml.dist b/src/mcp-sdk/phpunit.xml.dist deleted file mode 100644 index c442dcc43..000000000 --- a/src/mcp-sdk/phpunit.xml.dist +++ /dev/null @@ -1,22 +0,0 @@ - - - - - - tests - - - - - - src - - - diff --git a/src/mcp-sdk/src/Capability/Prompt/CollectionInterface.php b/src/mcp-sdk/src/Capability/Prompt/CollectionInterface.php deleted file mode 100644 index 371bba882..000000000 --- a/src/mcp-sdk/src/Capability/Prompt/CollectionInterface.php +++ /dev/null @@ -1,26 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace Symfony\AI\McpSdk\Capability\Prompt; - -use Symfony\AI\McpSdk\Exception\InvalidCursorException; - -interface CollectionInterface -{ - /** - * @param int $count the number of metadata items to return - * - * @return iterable - * - * @throws InvalidCursorException if no item with $lastIdentifier was found - */ - public function getMetadata(int $count, ?string $lastIdentifier = null): iterable; -} diff --git a/src/mcp-sdk/src/Capability/Prompt/IdentifierInterface.php b/src/mcp-sdk/src/Capability/Prompt/IdentifierInterface.php deleted file mode 100644 index 56a834195..000000000 --- a/src/mcp-sdk/src/Capability/Prompt/IdentifierInterface.php +++ /dev/null @@ -1,20 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace Symfony\AI\McpSdk\Capability\Prompt; - -/** - * @author Tobias Nyholm - */ -interface IdentifierInterface -{ - public function getName(): string; -} diff --git a/src/mcp-sdk/src/Capability/Prompt/MetadataInterface.php b/src/mcp-sdk/src/Capability/Prompt/MetadataInterface.php deleted file mode 100644 index 1972bf7d8..000000000 --- a/src/mcp-sdk/src/Capability/Prompt/MetadataInterface.php +++ /dev/null @@ -1,26 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace Symfony\AI\McpSdk\Capability\Prompt; - -interface MetadataInterface extends IdentifierInterface -{ - public function getDescription(): ?string; - - /** - * @return list - */ - public function getArguments(): array; -} diff --git a/src/mcp-sdk/src/Capability/Prompt/PromptGet.php b/src/mcp-sdk/src/Capability/Prompt/PromptGet.php deleted file mode 100644 index ba5bd8c1c..000000000 --- a/src/mcp-sdk/src/Capability/Prompt/PromptGet.php +++ /dev/null @@ -1,25 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace Symfony\AI\McpSdk\Capability\Prompt; - -final readonly class PromptGet -{ - /** - * @param array $arguments - */ - public function __construct( - public string $id, - public string $name, - public array $arguments = [], - ) { - } -} diff --git a/src/mcp-sdk/src/Capability/Prompt/PromptGetResult.php b/src/mcp-sdk/src/Capability/Prompt/PromptGetResult.php deleted file mode 100644 index 1c46dd4eb..000000000 --- a/src/mcp-sdk/src/Capability/Prompt/PromptGetResult.php +++ /dev/null @@ -1,24 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace Symfony\AI\McpSdk\Capability\Prompt; - -final readonly class PromptGetResult -{ - /** - * @param list $messages - */ - public function __construct( - public string $description, - public array $messages = [], - ) { - } -} diff --git a/src/mcp-sdk/src/Capability/Prompt/PromptGetResultMessages.php b/src/mcp-sdk/src/Capability/Prompt/PromptGetResultMessages.php deleted file mode 100644 index b763109b5..000000000 --- a/src/mcp-sdk/src/Capability/Prompt/PromptGetResultMessages.php +++ /dev/null @@ -1,27 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace Symfony\AI\McpSdk\Capability\Prompt; - -final readonly class PromptGetResultMessages -{ - public function __construct( - public string $role, - public string $result, - /** - * @var "text"|"image"|"audio"|"resource"|non-empty-string - */ - public string $type = 'text', - public string $mimeType = 'text/plan', - public ?string $uri = null, - ) { - } -} diff --git a/src/mcp-sdk/src/Capability/Prompt/PromptGetterInterface.php b/src/mcp-sdk/src/Capability/Prompt/PromptGetterInterface.php deleted file mode 100644 index 34718e423..000000000 --- a/src/mcp-sdk/src/Capability/Prompt/PromptGetterInterface.php +++ /dev/null @@ -1,24 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace Symfony\AI\McpSdk\Capability\Prompt; - -use Symfony\AI\McpSdk\Exception\PromptGetException; -use Symfony\AI\McpSdk\Exception\PromptNotFoundException; - -interface PromptGetterInterface -{ - /** - * @throws PromptGetException if the prompt execution fails - * @throws PromptNotFoundException if the prompt is not found - */ - public function get(PromptGet $input): PromptGetResult; -} diff --git a/src/mcp-sdk/src/Capability/PromptChain.php b/src/mcp-sdk/src/Capability/PromptChain.php deleted file mode 100644 index 095448cdc..000000000 --- a/src/mcp-sdk/src/Capability/PromptChain.php +++ /dev/null @@ -1,75 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace Symfony\AI\McpSdk\Capability; - -use Symfony\AI\McpSdk\Capability\Prompt\CollectionInterface; -use Symfony\AI\McpSdk\Capability\Prompt\IdentifierInterface; -use Symfony\AI\McpSdk\Capability\Prompt\MetadataInterface; -use Symfony\AI\McpSdk\Capability\Prompt\PromptGet; -use Symfony\AI\McpSdk\Capability\Prompt\PromptGetResult; -use Symfony\AI\McpSdk\Capability\Prompt\PromptGetterInterface; -use Symfony\AI\McpSdk\Exception\InvalidCursorException; -use Symfony\AI\McpSdk\Exception\PromptGetException; -use Symfony\AI\McpSdk\Exception\PromptNotFoundException; - -/** - * A collection of prompts. All prompts need to implement IdentifierInterface. - */ -class PromptChain implements PromptGetterInterface, CollectionInterface -{ - public function __construct( - /** - * @var IdentifierInterface[] - */ - private readonly array $items, - ) { - } - - public function getMetadata(int $count, ?string $lastIdentifier = null): iterable - { - $found = null === $lastIdentifier; - foreach ($this->items as $item) { - if (!$item instanceof MetadataInterface) { - continue; - } - - if (false === $found) { - $found = $item->getName() === $lastIdentifier; - continue; - } - - yield $item; - if (--$count <= 0) { - break; - } - } - - if (!$found) { - throw new InvalidCursorException($lastIdentifier); - } - } - - public function get(PromptGet $input): PromptGetResult - { - foreach ($this->items as $item) { - if ($item instanceof PromptGetterInterface && $input->name === $item->getName()) { - try { - return $item->get($input); - } catch (\Throwable $e) { - throw new PromptGetException($input, $e); - } - } - } - - throw new PromptNotFoundException($input); - } -} diff --git a/src/mcp-sdk/src/Capability/Resource/CollectionInterface.php b/src/mcp-sdk/src/Capability/Resource/CollectionInterface.php deleted file mode 100644 index f566c8ed4..000000000 --- a/src/mcp-sdk/src/Capability/Resource/CollectionInterface.php +++ /dev/null @@ -1,29 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace Symfony\AI\McpSdk\Capability\Resource; - -use Symfony\AI\McpSdk\Exception\InvalidCursorException; - -/** - * @author Tobias Nyholm - */ -interface CollectionInterface -{ - /** - * @param int $count the number of metadata items to return - * - * @return iterable - * - * @throws InvalidCursorException if no item with $lastIdentifier was found - */ - public function getMetadata(int $count, ?string $lastIdentifier = null): iterable; -} diff --git a/src/mcp-sdk/src/Capability/Resource/IdentifierInterface.php b/src/mcp-sdk/src/Capability/Resource/IdentifierInterface.php deleted file mode 100644 index b0bc3851e..000000000 --- a/src/mcp-sdk/src/Capability/Resource/IdentifierInterface.php +++ /dev/null @@ -1,20 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace Symfony\AI\McpSdk\Capability\Resource; - -/** - * @author Tobias Nyholm - */ -interface IdentifierInterface -{ - public function getUri(): string; -} diff --git a/src/mcp-sdk/src/Capability/Resource/MetadataInterface.php b/src/mcp-sdk/src/Capability/Resource/MetadataInterface.php deleted file mode 100644 index b6e9a38f2..000000000 --- a/src/mcp-sdk/src/Capability/Resource/MetadataInterface.php +++ /dev/null @@ -1,29 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace Symfony\AI\McpSdk\Capability\Resource; - -/** - * @author Tobias Nyholm - */ -interface MetadataInterface extends IdentifierInterface -{ - public function getName(): string; - - public function getDescription(): ?string; - - public function getMimeType(): ?string; - - /** - * Size in bytes. - */ - public function getSize(): ?int; -} diff --git a/src/mcp-sdk/src/Capability/Resource/ResourceRead.php b/src/mcp-sdk/src/Capability/Resource/ResourceRead.php deleted file mode 100644 index 0e0e0d4ad..000000000 --- a/src/mcp-sdk/src/Capability/Resource/ResourceRead.php +++ /dev/null @@ -1,21 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace Symfony\AI\McpSdk\Capability\Resource; - -final readonly class ResourceRead -{ - public function __construct( - public string $id, - public string $uri, - ) { - } -} diff --git a/src/mcp-sdk/src/Capability/Resource/ResourceReadResult.php b/src/mcp-sdk/src/Capability/Resource/ResourceReadResult.php deleted file mode 100644 index 2d3aa4535..000000000 --- a/src/mcp-sdk/src/Capability/Resource/ResourceReadResult.php +++ /dev/null @@ -1,27 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace Symfony\AI\McpSdk\Capability\Resource; - -final readonly class ResourceReadResult -{ - public function __construct( - public string $result, - public string $uri, - - /** - * @var "text"|"blob" - */ - public string $type = 'text', - public string $mimeType = 'text/plain', - ) { - } -} diff --git a/src/mcp-sdk/src/Capability/Resource/ResourceReaderInterface.php b/src/mcp-sdk/src/Capability/Resource/ResourceReaderInterface.php deleted file mode 100644 index 4693ad4db..000000000 --- a/src/mcp-sdk/src/Capability/Resource/ResourceReaderInterface.php +++ /dev/null @@ -1,27 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace Symfony\AI\McpSdk\Capability\Resource; - -use Symfony\AI\McpSdk\Exception\ResourceNotFoundException; -use Symfony\AI\McpSdk\Exception\ResourceReadException; - -/** - * @author Tobias Nyholm - */ -interface ResourceReaderInterface -{ - /** - * @throws ResourceReadException if the resource execution fails - * @throws ResourceNotFoundException if the resource is not found - */ - public function read(ResourceRead $input): ResourceReadResult; -} diff --git a/src/mcp-sdk/src/Capability/ResourceChain.php b/src/mcp-sdk/src/Capability/ResourceChain.php deleted file mode 100644 index 209f869a6..000000000 --- a/src/mcp-sdk/src/Capability/ResourceChain.php +++ /dev/null @@ -1,75 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace Symfony\AI\McpSdk\Capability; - -use Symfony\AI\McpSdk\Capability\Resource\CollectionInterface; -use Symfony\AI\McpSdk\Capability\Resource\IdentifierInterface; -use Symfony\AI\McpSdk\Capability\Resource\MetadataInterface; -use Symfony\AI\McpSdk\Capability\Resource\ResourceRead; -use Symfony\AI\McpSdk\Capability\Resource\ResourceReaderInterface; -use Symfony\AI\McpSdk\Capability\Resource\ResourceReadResult; -use Symfony\AI\McpSdk\Exception\InvalidCursorException; -use Symfony\AI\McpSdk\Exception\ResourceNotFoundException; -use Symfony\AI\McpSdk\Exception\ResourceReadException; - -/** - * A collection of resources. All resources need to implement IdentifierInterface. - */ -class ResourceChain implements CollectionInterface, ResourceReaderInterface -{ - public function __construct( - /** - * @var IdentifierInterface[] - */ - private readonly array $items, - ) { - } - - public function getMetadata(int $count, ?string $lastIdentifier = null): iterable - { - $found = null === $lastIdentifier; - foreach ($this->items as $item) { - if (!$item instanceof MetadataInterface) { - continue; - } - - if (false === $found) { - $found = $item->getUri() === $lastIdentifier; - continue; - } - - yield $item; - if (--$count <= 0) { - break; - } - } - - if (!$found) { - throw new InvalidCursorException($lastIdentifier); - } - } - - public function read(ResourceRead $input): ResourceReadResult - { - foreach ($this->items as $item) { - if ($item instanceof ResourceReaderInterface && $input->uri === $item->getUri()) { - try { - return $item->read($input); - } catch (\Throwable $e) { - throw new ResourceReadException($input, $e); - } - } - } - - throw new ResourceNotFoundException($input); - } -} diff --git a/src/mcp-sdk/src/Capability/Tool/CollectionInterface.php b/src/mcp-sdk/src/Capability/Tool/CollectionInterface.php deleted file mode 100644 index 588e77679..000000000 --- a/src/mcp-sdk/src/Capability/Tool/CollectionInterface.php +++ /dev/null @@ -1,26 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace Symfony\AI\McpSdk\Capability\Tool; - -use Symfony\AI\McpSdk\Exception\InvalidCursorException; - -interface CollectionInterface -{ - /** - * @param int|null $count the number of metadata items to return, null returns all items - * - * @return iterable - * - * @throws InvalidCursorException if no item with $lastIdentifier was found - */ - public function getMetadata(?int $count, ?string $lastIdentifier = null): iterable; -} diff --git a/src/mcp-sdk/src/Capability/Tool/IdentifierInterface.php b/src/mcp-sdk/src/Capability/Tool/IdentifierInterface.php deleted file mode 100644 index 4a45edcd1..000000000 --- a/src/mcp-sdk/src/Capability/Tool/IdentifierInterface.php +++ /dev/null @@ -1,20 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace Symfony\AI\McpSdk\Capability\Tool; - -interface IdentifierInterface -{ - /** - * @return string intended for programmatic or logical use, but used as a display name in past specs or fallback (if title isn’t present) - */ - public function getName(): string; -} diff --git a/src/mcp-sdk/src/Capability/Tool/MetadataInterface.php b/src/mcp-sdk/src/Capability/Tool/MetadataInterface.php deleted file mode 100644 index b09dc5948..000000000 --- a/src/mcp-sdk/src/Capability/Tool/MetadataInterface.php +++ /dev/null @@ -1,74 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace Symfony\AI\McpSdk\Capability\Tool; - -/** - * @see {https://modelcontextprotocol.io/specification/2025-06-18/schema#tool} - */ -interface MetadataInterface extends IdentifierInterface -{ - /** - * @return string|null A human-readable description of the tool. - * This can be used by clients to improve the LLM’s understanding of available tools. It can be thought of like a “hint” to the model - * - * @see https://modelcontextprotocol.io/specification/2025-06-18/schema#tool-description - */ - public function getDescription(): ?string; - - /** - * @return array{ - * type?: 'object', - * required?: list, - * properties?: array, - * } - * - * @see https://modelcontextprotocol.io/specification/2025-06-18/schema#tool-inputschema - */ - public function getInputSchema(): array; - - /** - * @return array{ - * type?: 'object', - * required?: list, - * properties?: array, - * }|null - * - * @see https://modelcontextprotocol.io/specification/2025-06-18/schema#tool-outputschema - */ - public function getOutputSchema(): ?array; - - /** - * @return string|null Intended for UI and end-user contexts — optimized to be human-readable and easily understood, even by those unfamiliar with domain-specific terminology. - * - * If not provided, the name should be used for display (except for Tool, where annotations.title should be given precedence over using name, if present). - * - * @see https://modelcontextprotocol.io/specification/2025-06-18/schema#tool-title - */ - public function getTitle(): ?string; - - /** - * @return ToolAnnotationsInterface|null Additional properties describing a Tool to clients. - * - * NOTE: all properties in ToolAnnotations are hints. They are not guaranteed to provide a faithful description of tool behavior (including descriptive properties like title). - * - * Clients should never make tool use decisions based on ToolAnnotations received from untrusted servers. - * - * @see https://modelcontextprotocol.io/specification/2025-06-18/schema#tool-annotations - */ - public function getAnnotations(): ?ToolAnnotationsInterface; -} diff --git a/src/mcp-sdk/src/Capability/Tool/ToolAnnotationsInterface.php b/src/mcp-sdk/src/Capability/Tool/ToolAnnotationsInterface.php deleted file mode 100644 index b1868d28a..000000000 --- a/src/mcp-sdk/src/Capability/Tool/ToolAnnotationsInterface.php +++ /dev/null @@ -1,62 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace Symfony\AI\McpSdk\Capability\Tool; - -interface ToolAnnotationsInterface -{ - /** - * @return bool|null If true, the tool may perform destructive updates to its environment. If false, the tool performs only additive updates. - * - * (This property is meaningful only when readOnlyHint == false) - * - * Default: true - * - * @see https://modelcontextprotocol.io/specification/2025-06-18/schema#toolannotations-destructivehint - */ - public function getDestructiveHint(): ?bool; - - /** - * @return bool|null If true, calling the tool repeatedly with the same arguments will have no additional effect on the its environment. - * - * (This property is meaningful only when readOnlyHint == false) - * - * Default: false - * - * @see https://modelcontextprotocol.io/specification/2025-06-18/schema#toolannotations-idempotenthint - */ - public function getIdempotentHint(): ?bool; - - /** - * @return bool|null If true, this tool may interact with an “open world” of external entities. If false, the tool’s domain of interaction is closed. For example, the world of a web search tool is open, whereas that of a memory tool is not. - * - * Default: true - * - * @see https://modelcontextprotocol.io/specification/2025-06-18/schema#toolannotations-openworldhint - */ - public function getOpenWorldHint(): ?bool; - - /** - * @return bool|null If true, the tool does not modify its environment. - * - * Default: false - * - * @see https://modelcontextprotocol.io/specification/2025-06-18/schema#toolannotations-readonlyhint - */ - public function getReadOnlyHint(): ?bool; - - /** - * @return string|null A human-readable title for the tool - * - * @see https://modelcontextprotocol.io/specification/2025-06-18/schema#toolannotations-title - */ - public function getTitle(): ?string; -} diff --git a/src/mcp-sdk/src/Capability/Tool/ToolCall.php b/src/mcp-sdk/src/Capability/Tool/ToolCall.php deleted file mode 100644 index 9208fc171..000000000 --- a/src/mcp-sdk/src/Capability/Tool/ToolCall.php +++ /dev/null @@ -1,25 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace Symfony\AI\McpSdk\Capability\Tool; - -final readonly class ToolCall -{ - /** - * @param array $arguments - */ - public function __construct( - public string $id, - public string $name, - public array $arguments = [], - ) { - } -} diff --git a/src/mcp-sdk/src/Capability/Tool/ToolCallResult.php b/src/mcp-sdk/src/Capability/Tool/ToolCallResult.php deleted file mode 100644 index 2bdd2d0df..000000000 --- a/src/mcp-sdk/src/Capability/Tool/ToolCallResult.php +++ /dev/null @@ -1,27 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace Symfony\AI\McpSdk\Capability\Tool; - -final readonly class ToolCallResult -{ - public function __construct( - public string $result, - /** - * @var "text"|"image"|"audio"|"resource"|non-empty-string - */ - public string $type = 'text', - public string $mimeType = 'text/plan', - public bool $isError = false, - public ?string $uri = null, - ) { - } -} diff --git a/src/mcp-sdk/src/Capability/Tool/ToolCollectionInterface.php b/src/mcp-sdk/src/Capability/Tool/ToolCollectionInterface.php deleted file mode 100644 index 19b44928d..000000000 --- a/src/mcp-sdk/src/Capability/Tool/ToolCollectionInterface.php +++ /dev/null @@ -1,20 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace Symfony\AI\McpSdk\Capability\Tool; - -interface ToolCollectionInterface -{ - /** - * @return MetadataInterface[] - */ - public function getMetadata(): array; -} diff --git a/src/mcp-sdk/src/Capability/Tool/ToolExecutorInterface.php b/src/mcp-sdk/src/Capability/Tool/ToolExecutorInterface.php deleted file mode 100644 index 5e0306620..000000000 --- a/src/mcp-sdk/src/Capability/Tool/ToolExecutorInterface.php +++ /dev/null @@ -1,24 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace Symfony\AI\McpSdk\Capability\Tool; - -use Symfony\AI\McpSdk\Exception\ToolExecutionException; -use Symfony\AI\McpSdk\Exception\ToolNotFoundException; - -interface ToolExecutorInterface -{ - /** - * @throws ToolExecutionException if the tool execution fails - * @throws ToolNotFoundException if the tool is not found - */ - public function call(ToolCall $input): ToolCallResult; -} diff --git a/src/mcp-sdk/src/Capability/ToolChain.php b/src/mcp-sdk/src/Capability/ToolChain.php deleted file mode 100644 index 0462b6d8d..000000000 --- a/src/mcp-sdk/src/Capability/ToolChain.php +++ /dev/null @@ -1,77 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace Symfony\AI\McpSdk\Capability; - -use Symfony\AI\McpSdk\Capability\Tool\CollectionInterface; -use Symfony\AI\McpSdk\Capability\Tool\IdentifierInterface; -use Symfony\AI\McpSdk\Capability\Tool\MetadataInterface; -use Symfony\AI\McpSdk\Capability\Tool\ToolCall; -use Symfony\AI\McpSdk\Capability\Tool\ToolCallResult; -use Symfony\AI\McpSdk\Capability\Tool\ToolExecutorInterface; -use Symfony\AI\McpSdk\Exception\InvalidCursorException; -use Symfony\AI\McpSdk\Exception\ToolExecutionException; -use Symfony\AI\McpSdk\Exception\ToolNotFoundException; - -/** - * A collection of tools. All tools need to implement IdentifierInterface. - * - * @author Tobias Nyholm - */ -class ToolChain implements ToolExecutorInterface, CollectionInterface -{ - public function __construct( - /** - * @var IdentifierInterface[] $items - */ - private readonly iterable $items, - ) { - } - - public function getMetadata(?int $count, ?string $lastIdentifier = null): iterable - { - $found = null === $lastIdentifier; - foreach ($this->items as $item) { - if (!$item instanceof MetadataInterface) { - continue; - } - - if (false === $found) { - $found = $item->getName() === $lastIdentifier; - continue; - } - - yield $item; - if (null !== $count && 0 >= --$count) { - break; - } - } - - if (!$found) { - throw new InvalidCursorException($lastIdentifier); - } - } - - public function call(ToolCall $input): ToolCallResult - { - foreach ($this->items as $item) { - if ($item instanceof ToolExecutorInterface && $input->name === $item->getName()) { - try { - return $item->call($input); - } catch (\Throwable $e) { - throw new ToolExecutionException($input, $e); - } - } - } - - throw new ToolNotFoundException($input); - } -} diff --git a/src/mcp-sdk/src/Exception/ExceptionInterface.php b/src/mcp-sdk/src/Exception/ExceptionInterface.php deleted file mode 100644 index 191a94e1e..000000000 --- a/src/mcp-sdk/src/Exception/ExceptionInterface.php +++ /dev/null @@ -1,16 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace Symfony\AI\McpSdk\Exception; - -interface ExceptionInterface extends \Throwable -{ -} diff --git a/src/mcp-sdk/src/Exception/HandlerNotFoundException.php b/src/mcp-sdk/src/Exception/HandlerNotFoundException.php deleted file mode 100644 index baa389190..000000000 --- a/src/mcp-sdk/src/Exception/HandlerNotFoundException.php +++ /dev/null @@ -1,16 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace Symfony\AI\McpSdk\Exception; - -class HandlerNotFoundException extends \InvalidArgumentException implements NotFoundExceptionInterface -{ -} diff --git a/src/mcp-sdk/src/Exception/InvalidArgumentException.php b/src/mcp-sdk/src/Exception/InvalidArgumentException.php deleted file mode 100644 index b9e9ad4f0..000000000 --- a/src/mcp-sdk/src/Exception/InvalidArgumentException.php +++ /dev/null @@ -1,19 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace Symfony\AI\McpSdk\Exception; - -/** - * @author Christopher Hertel - */ -class InvalidArgumentException extends \InvalidArgumentException implements ExceptionInterface -{ -} diff --git a/src/mcp-sdk/src/Exception/InvalidCursorException.php b/src/mcp-sdk/src/Exception/InvalidCursorException.php deleted file mode 100644 index 7cea23d09..000000000 --- a/src/mcp-sdk/src/Exception/InvalidCursorException.php +++ /dev/null @@ -1,21 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace Symfony\AI\McpSdk\Exception; - -final class InvalidCursorException extends \InvalidArgumentException implements ExceptionInterface -{ - public function __construct( - public readonly string $cursor, - ) { - parent::__construct(\sprintf('Invalid value for pagination parameter "cursor": "%s"', $cursor)); - } -} diff --git a/src/mcp-sdk/src/Exception/InvalidInputMessageException.php b/src/mcp-sdk/src/Exception/InvalidInputMessageException.php deleted file mode 100644 index 9a923b47e..000000000 --- a/src/mcp-sdk/src/Exception/InvalidInputMessageException.php +++ /dev/null @@ -1,16 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace Symfony\AI\McpSdk\Exception; - -class InvalidInputMessageException extends \InvalidArgumentException implements ExceptionInterface -{ -} diff --git a/src/mcp-sdk/src/Exception/NotFoundExceptionInterface.php b/src/mcp-sdk/src/Exception/NotFoundExceptionInterface.php deleted file mode 100644 index c07c25ed2..000000000 --- a/src/mcp-sdk/src/Exception/NotFoundExceptionInterface.php +++ /dev/null @@ -1,16 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace Symfony\AI\McpSdk\Exception; - -interface NotFoundExceptionInterface extends ExceptionInterface -{ -} diff --git a/src/mcp-sdk/src/Exception/PromptGetException.php b/src/mcp-sdk/src/Exception/PromptGetException.php deleted file mode 100644 index b9901f6c2..000000000 --- a/src/mcp-sdk/src/Exception/PromptGetException.php +++ /dev/null @@ -1,24 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace Symfony\AI\McpSdk\Exception; - -use Symfony\AI\McpSdk\Capability\Prompt\PromptGet; - -final class PromptGetException extends \RuntimeException implements ExceptionInterface -{ - public function __construct( - public readonly PromptGet $promptGet, - ?\Throwable $previous = null, - ) { - parent::__construct(\sprintf('Handling prompt "%s" failed with error: %s', $promptGet->name, $previous->getMessage()), previous: $previous); - } -} diff --git a/src/mcp-sdk/src/Exception/PromptNotFoundException.php b/src/mcp-sdk/src/Exception/PromptNotFoundException.php deleted file mode 100644 index 6c485bf67..000000000 --- a/src/mcp-sdk/src/Exception/PromptNotFoundException.php +++ /dev/null @@ -1,23 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace Symfony\AI\McpSdk\Exception; - -use Symfony\AI\McpSdk\Capability\Prompt\PromptGet; - -final class PromptNotFoundException extends \RuntimeException implements NotFoundExceptionInterface -{ - public function __construct( - public readonly PromptGet $promptGet, - ) { - parent::__construct(\sprintf('Prompt not found for name: "%s"', $promptGet->name)); - } -} diff --git a/src/mcp-sdk/src/Exception/ResourceNotFoundException.php b/src/mcp-sdk/src/Exception/ResourceNotFoundException.php deleted file mode 100644 index ca88ac72f..000000000 --- a/src/mcp-sdk/src/Exception/ResourceNotFoundException.php +++ /dev/null @@ -1,23 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace Symfony\AI\McpSdk\Exception; - -use Symfony\AI\McpSdk\Capability\Resource\ResourceRead; - -final class ResourceNotFoundException extends \RuntimeException implements NotFoundExceptionInterface -{ - public function __construct( - public readonly ResourceRead $readRequest, - ) { - parent::__construct(\sprintf('Resource not found for uri: "%s"', $readRequest->uri)); - } -} diff --git a/src/mcp-sdk/src/Exception/ResourceReadException.php b/src/mcp-sdk/src/Exception/ResourceReadException.php deleted file mode 100644 index e063fc1bf..000000000 --- a/src/mcp-sdk/src/Exception/ResourceReadException.php +++ /dev/null @@ -1,24 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace Symfony\AI\McpSdk\Exception; - -use Symfony\AI\McpSdk\Capability\Resource\ResourceRead; - -final class ResourceReadException extends \RuntimeException implements ExceptionInterface -{ - public function __construct( - public readonly ResourceRead $readRequest, - ?\Throwable $previous = null, - ) { - parent::__construct(\sprintf('Reading resource "%s" failed with error: %s', $readRequest->uri, $previous?->getMessage() ?? ''), previous: $previous); - } -} diff --git a/src/mcp-sdk/src/Exception/ToolExecutionException.php b/src/mcp-sdk/src/Exception/ToolExecutionException.php deleted file mode 100644 index 704c7cef8..000000000 --- a/src/mcp-sdk/src/Exception/ToolExecutionException.php +++ /dev/null @@ -1,24 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace Symfony\AI\McpSdk\Exception; - -use Symfony\AI\McpSdk\Capability\Tool\ToolCall; - -final class ToolExecutionException extends \RuntimeException implements ExceptionInterface -{ - public function __construct( - public readonly ToolCall $toolCall, - ?\Throwable $previous = null, - ) { - parent::__construct(\sprintf('Execution of tool "%s" failed with error: %s', $toolCall->name, $previous?->getMessage() ?? ''), previous: $previous); - } -} diff --git a/src/mcp-sdk/src/Exception/ToolNotFoundException.php b/src/mcp-sdk/src/Exception/ToolNotFoundException.php deleted file mode 100644 index 614015627..000000000 --- a/src/mcp-sdk/src/Exception/ToolNotFoundException.php +++ /dev/null @@ -1,23 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace Symfony\AI\McpSdk\Exception; - -use Symfony\AI\McpSdk\Capability\Tool\ToolCall; - -final class ToolNotFoundException extends \RuntimeException implements NotFoundExceptionInterface -{ - public function __construct( - public readonly ToolCall $toolCall, - ) { - parent::__construct(\sprintf('Tool not found for call: "%s"', $toolCall->name)); - } -} diff --git a/src/mcp-sdk/src/Message/Error.php b/src/mcp-sdk/src/Message/Error.php deleted file mode 100644 index df5ad11d2..000000000 --- a/src/mcp-sdk/src/Message/Error.php +++ /dev/null @@ -1,73 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace Symfony\AI\McpSdk\Message; - -final readonly class Error implements \JsonSerializable -{ - public const INVALID_REQUEST = -32600; - public const METHOD_NOT_FOUND = -32601; - public const INVALID_PARAMS = -32602; - public const INTERNAL_ERROR = -32603; - public const PARSE_ERROR = -32700; - public const RESOURCE_NOT_FOUND = -32002; - - public function __construct( - public string|int $id, - public int $code, - public string $message, - ) { - } - - public static function invalidRequest(string|int $id, string $message = 'Invalid Request'): self - { - return new self($id, self::INVALID_REQUEST, $message); - } - - public static function methodNotFound(string|int $id, string $message = 'Method not found'): self - { - return new self($id, self::METHOD_NOT_FOUND, $message); - } - - public static function invalidParams(string|int $id, string $message = 'Invalid params'): self - { - return new self($id, self::INVALID_PARAMS, $message); - } - - public static function internalError(string|int $id, string $message = 'Internal error'): self - { - return new self($id, self::INTERNAL_ERROR, $message); - } - - public static function parseError(string|int $id, string $message = 'Parse error'): self - { - return new self($id, self::PARSE_ERROR, $message); - } - - /** - * @return array{ - * jsonrpc: string, - * id: string|int, - * error: array{code: int, message: string} - * } - */ - public function jsonSerialize(): array - { - return [ - 'jsonrpc' => '2.0', - 'id' => $this->id, - 'error' => [ - 'code' => $this->code, - 'message' => $this->message, - ], - ]; - } -} diff --git a/src/mcp-sdk/src/Message/Factory.php b/src/mcp-sdk/src/Message/Factory.php deleted file mode 100644 index 6338d5d69..000000000 --- a/src/mcp-sdk/src/Message/Factory.php +++ /dev/null @@ -1,44 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace Symfony\AI\McpSdk\Message; - -use Symfony\AI\McpSdk\Exception\InvalidInputMessageException; - -/** - * @author Christopher Hertel - */ -final class Factory -{ - /** - * @return iterable - * - * @throws \JsonException When the input string is not valid JSON - */ - public function create(string $input): iterable - { - $data = json_decode($input, true, flags: \JSON_THROW_ON_ERROR); - - if ('{' === $input[0]) { - $data = [$data]; - } - - foreach ($data as $message) { - if (!isset($message['method'])) { - yield new InvalidInputMessageException('Invalid JSON-RPC request, missing "method".'); - } elseif (str_starts_with((string) $message['method'], 'notifications/')) { - yield Notification::from($message); - } else { - yield Request::from($message); - } - } - } -} diff --git a/src/mcp-sdk/src/Message/Notification.php b/src/mcp-sdk/src/Message/Notification.php deleted file mode 100644 index 5da77b6a1..000000000 --- a/src/mcp-sdk/src/Message/Notification.php +++ /dev/null @@ -1,52 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace Symfony\AI\McpSdk\Message; - -final readonly class Notification implements \JsonSerializable, \Stringable -{ - /** - * @param array|null $params - */ - public function __construct( - public string $method, - public ?array $params = null, - ) { - } - - public function __toString(): string - { - return \sprintf('%s', $this->method); - } - - /** - * @param array{method: string, params?: array} $data - */ - public static function from(array $data): self - { - return new self( - $data['method'], - $data['params'] ?? null, - ); - } - - /** - * @return array{jsonrpc: string, method: string, params: array|null} - */ - public function jsonSerialize(): array - { - return [ - 'jsonrpc' => '2.0', - 'method' => $this->method, - 'params' => $this->params, - ]; - } -} diff --git a/src/mcp-sdk/src/Message/Request.php b/src/mcp-sdk/src/Message/Request.php deleted file mode 100644 index 8ec35cb99..000000000 --- a/src/mcp-sdk/src/Message/Request.php +++ /dev/null @@ -1,55 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace Symfony\AI\McpSdk\Message; - -final readonly class Request implements \JsonSerializable, \Stringable -{ - /** - * @param array|null $params - */ - public function __construct( - public int|string $id, - public string $method, - public ?array $params = null, - ) { - } - - public function __toString(): string - { - return \sprintf('%s: %s', $this->id, $this->method); - } - - /** - * @param array{id: string|int, method: string, params?: array} $data - */ - public static function from(array $data): self - { - return new self( - $data['id'], - $data['method'], - $data['params'] ?? null, - ); - } - - /** - * @return array{jsonrpc: string, id: string|int, method: string, params: array|null} - */ - public function jsonSerialize(): array - { - return [ - 'jsonrpc' => '2.0', - 'id' => $this->id, - 'method' => $this->method, - 'params' => $this->params, - ]; - } -} diff --git a/src/mcp-sdk/src/Message/Response.php b/src/mcp-sdk/src/Message/Response.php deleted file mode 100644 index 2b26d9d2c..000000000 --- a/src/mcp-sdk/src/Message/Response.php +++ /dev/null @@ -1,36 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace Symfony\AI\McpSdk\Message; - -final readonly class Response implements \JsonSerializable -{ - /** - * @param array $result - */ - public function __construct( - public string|int $id, - public array $result = [], - ) { - } - - /** - * @return array{jsonrpc: string, id: string|int, result: array} - */ - public function jsonSerialize(): array - { - return [ - 'jsonrpc' => '2.0', - 'id' => $this->id, - 'result' => $this->result, - ]; - } -} diff --git a/src/mcp-sdk/src/Server.php b/src/mcp-sdk/src/Server.php deleted file mode 100644 index 1b5629b41..000000000 --- a/src/mcp-sdk/src/Server.php +++ /dev/null @@ -1,61 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace Symfony\AI\McpSdk; - -use Psr\Log\LoggerInterface; -use Psr\Log\NullLogger; -use Symfony\AI\McpSdk\Server\JsonRpcHandler; -use Symfony\AI\McpSdk\Server\TransportInterface; - -final readonly class Server -{ - public function __construct( - private JsonRpcHandler $jsonRpcHandler, - private LoggerInterface $logger = new NullLogger(), - ) { - } - - public function connect(TransportInterface $transport): void - { - $transport->initialize(); - $this->logger->info('Transport initialized'); - - while ($transport->isConnected()) { - foreach ($transport->receive() as $message) { - if (null === $message) { - continue; - } - - try { - foreach ($this->jsonRpcHandler->process($message) as $response) { - if (null === $response) { - continue; - } - - $transport->send($response); - } - } catch (\JsonException $e) { - $this->logger->error('Failed to encode response to JSON', [ - 'message' => $message, - 'exception' => $e, - ]); - continue; - } - } - - usleep(1000); - } - - $transport->close(); - $this->logger->info('Transport closed'); - } -} diff --git a/src/mcp-sdk/src/Server/JsonRpcHandler.php b/src/mcp-sdk/src/Server/JsonRpcHandler.php deleted file mode 100644 index ed925fdf1..000000000 --- a/src/mcp-sdk/src/Server/JsonRpcHandler.php +++ /dev/null @@ -1,160 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace Symfony\AI\McpSdk\Server; - -use Psr\Log\LoggerInterface; -use Psr\Log\NullLogger; -use Symfony\AI\McpSdk\Exception\ExceptionInterface; -use Symfony\AI\McpSdk\Exception\HandlerNotFoundException; -use Symfony\AI\McpSdk\Exception\InvalidInputMessageException; -use Symfony\AI\McpSdk\Exception\NotFoundExceptionInterface; -use Symfony\AI\McpSdk\Message\Error; -use Symfony\AI\McpSdk\Message\Factory; -use Symfony\AI\McpSdk\Message\Notification; -use Symfony\AI\McpSdk\Message\Request; -use Symfony\AI\McpSdk\Message\Response; - -/** - * @final - */ -readonly class JsonRpcHandler -{ - /** - * @var array - */ - private array $requestHandlers; - - /** - * @var array - */ - private array $notificationHandlers; - - /** - * @param iterable $requestHandlers - * @param iterable $notificationHandlers - */ - public function __construct( - private Factory $messageFactory, - iterable $requestHandlers, - iterable $notificationHandlers, - private LoggerInterface $logger = new NullLogger(), - ) { - $this->requestHandlers = $requestHandlers instanceof \Traversable ? iterator_to_array($requestHandlers) : $requestHandlers; - $this->notificationHandlers = $notificationHandlers instanceof \Traversable ? iterator_to_array($notificationHandlers) : $notificationHandlers; - } - - /** - * @return iterable - * - * @throws ExceptionInterface When a handler throws an exception during message processing - * @throws \JsonException When JSON encoding of the response fails - */ - public function process(string $input): iterable - { - $this->logger->info('Received message to process', ['message' => $input]); - - try { - $messages = $this->messageFactory->create($input); - } catch (\JsonException $e) { - $this->logger->warning('Failed to decode json message', ['exception' => $e]); - - yield $this->encodeResponse(Error::parseError($e->getMessage())); - - return; - } - - foreach ($messages as $message) { - if ($message instanceof InvalidInputMessageException) { - $this->logger->warning('Failed to create message', ['exception' => $message]); - yield $this->encodeResponse(Error::invalidRequest(0, $message->getMessage())); - continue; - } - - $this->logger->info('Decoded incoming message', ['message' => $message]); - - try { - yield $message instanceof Notification - ? $this->handleNotification($message) - : $this->encodeResponse($this->handleRequest($message)); - } catch (\DomainException) { - yield null; - } catch (NotFoundExceptionInterface $e) { - $this->logger->warning(\sprintf('Failed to create response: %s', $e->getMessage()), ['exception' => $e]); - - yield $this->encodeResponse(Error::methodNotFound($message->id, $e->getMessage())); - } catch (\InvalidArgumentException $e) { - $this->logger->warning(\sprintf('Invalid argument: %s', $e->getMessage()), ['exception' => $e]); - - yield $this->encodeResponse(Error::invalidParams($message->id, $e->getMessage())); - } catch (\Throwable $e) { - $this->logger->critical(\sprintf('Uncaught exception: %s', $e->getMessage()), ['exception' => $e]); - - yield $this->encodeResponse(Error::internalError($message->id, $e->getMessage())); - } - } - } - - /** - * @throws \JsonException When JSON encoding fails - */ - private function encodeResponse(Response|Error|null $response): ?string - { - if (null === $response) { - $this->logger->warning('Response is null'); - - return null; - } - - $this->logger->info('Encoding response', ['response' => $response]); - - if ($response instanceof Response && [] === $response->result) { - return json_encode($response, \JSON_THROW_ON_ERROR | \JSON_FORCE_OBJECT); - } - - return json_encode($response, \JSON_THROW_ON_ERROR); - } - - /** - * @throws ExceptionInterface When a notification handler throws an exception - */ - private function handleNotification(Notification $notification): null - { - $handled = false; - foreach ($this->notificationHandlers as $handler) { - if ($handler->supports($notification)) { - $handler->handle($notification); - $handled = true; - } - } - - if (!$handled) { - $this->logger->warning(\sprintf('No handler found for "%s".', $notification->method), ['notification' => $notification]); - } - - return null; - } - - /** - * @throws NotFoundExceptionInterface When no handler is found for the request method - * @throws ExceptionInterface When a request handler throws an exception - */ - private function handleRequest(Request $request): Response|Error - { - foreach ($this->requestHandlers as $handler) { - if ($handler->supports($request)) { - return $handler->createResponse($request); - } - } - - throw new HandlerNotFoundException(\sprintf('No handler found for method "%s".', $request->method)); - } -} diff --git a/src/mcp-sdk/src/Server/NotificationHandler/BaseNotificationHandler.php b/src/mcp-sdk/src/Server/NotificationHandler/BaseNotificationHandler.php deleted file mode 100644 index 4f3d94a78..000000000 --- a/src/mcp-sdk/src/Server/NotificationHandler/BaseNotificationHandler.php +++ /dev/null @@ -1,28 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace Symfony\AI\McpSdk\Server\NotificationHandler; - -use Symfony\AI\McpSdk\Message\Notification; -use Symfony\AI\McpSdk\Server\NotificationHandlerInterface; - -/** - * @author Christopher Hertel - */ -abstract class BaseNotificationHandler implements NotificationHandlerInterface -{ - public function supports(Notification $message): bool - { - return $message->method === \sprintf('notifications/%s', $this->supportedNotification()); - } - - abstract protected function supportedNotification(): string; -} diff --git a/src/mcp-sdk/src/Server/NotificationHandler/InitializedHandler.php b/src/mcp-sdk/src/Server/NotificationHandler/InitializedHandler.php deleted file mode 100644 index 436bb9273..000000000 --- a/src/mcp-sdk/src/Server/NotificationHandler/InitializedHandler.php +++ /dev/null @@ -1,29 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace Symfony\AI\McpSdk\Server\NotificationHandler; - -use Symfony\AI\McpSdk\Message\Notification; - -/** - * @author Christopher Hertel - */ -final class InitializedHandler extends BaseNotificationHandler -{ - public function handle(Notification $notification): void - { - } - - protected function supportedNotification(): string - { - return 'initialized'; - } -} diff --git a/src/mcp-sdk/src/Server/NotificationHandlerInterface.php b/src/mcp-sdk/src/Server/NotificationHandlerInterface.php deleted file mode 100644 index 1388edb6f..000000000 --- a/src/mcp-sdk/src/Server/NotificationHandlerInterface.php +++ /dev/null @@ -1,28 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace Symfony\AI\McpSdk\Server; - -use Symfony\AI\McpSdk\Exception\ExceptionInterface; -use Symfony\AI\McpSdk\Message\Notification; - -/** - * @author Christopher Hertel - */ -interface NotificationHandlerInterface -{ - public function supports(Notification $message): bool; - - /** - * @throws ExceptionInterface When the handler encounters an error processing the notification - */ - public function handle(Notification $notification): void; -} diff --git a/src/mcp-sdk/src/Server/RequestHandler/BaseRequestHandler.php b/src/mcp-sdk/src/Server/RequestHandler/BaseRequestHandler.php deleted file mode 100644 index 307224c49..000000000 --- a/src/mcp-sdk/src/Server/RequestHandler/BaseRequestHandler.php +++ /dev/null @@ -1,28 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace Symfony\AI\McpSdk\Server\RequestHandler; - -use Symfony\AI\McpSdk\Message\Request; -use Symfony\AI\McpSdk\Server\RequestHandlerInterface; - -/** - * @author Christopher Hertel - */ -abstract class BaseRequestHandler implements RequestHandlerInterface -{ - public function supports(Request $message): bool - { - return $message->method === $this->supportedMethod(); - } - - abstract protected function supportedMethod(): string; -} diff --git a/src/mcp-sdk/src/Server/RequestHandler/InitializeHandler.php b/src/mcp-sdk/src/Server/RequestHandler/InitializeHandler.php deleted file mode 100644 index e6f81fa69..000000000 --- a/src/mcp-sdk/src/Server/RequestHandler/InitializeHandler.php +++ /dev/null @@ -1,45 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace Symfony\AI\McpSdk\Server\RequestHandler; - -use Symfony\AI\McpSdk\Message\Request; -use Symfony\AI\McpSdk\Message\Response; - -/** - * @author Christopher Hertel - */ -final class InitializeHandler extends BaseRequestHandler -{ - public function __construct( - private readonly string $name = 'app', - private readonly string $version = 'dev', - ) { - } - - public function createResponse(Request $message): Response - { - return new Response($message->id, [ - 'protocolVersion' => '2025-03-26', - 'capabilities' => [ - 'prompts' => ['listChanged' => false], - 'tools' => ['listChanged' => false], - 'resources' => ['listChanged' => false, 'subscribe' => false], - ], - 'serverInfo' => ['name' => $this->name, 'version' => $this->version], - ]); - } - - protected function supportedMethod(): string - { - return 'initialize'; - } -} diff --git a/src/mcp-sdk/src/Server/RequestHandler/PingHandler.php b/src/mcp-sdk/src/Server/RequestHandler/PingHandler.php deleted file mode 100644 index ab8bda66c..000000000 --- a/src/mcp-sdk/src/Server/RequestHandler/PingHandler.php +++ /dev/null @@ -1,31 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace Symfony\AI\McpSdk\Server\RequestHandler; - -use Symfony\AI\McpSdk\Message\Request; -use Symfony\AI\McpSdk\Message\Response; - -/** - * @author Christopher Hertel - */ -final class PingHandler extends BaseRequestHandler -{ - public function createResponse(Request $message): Response - { - return new Response($message->id, []); - } - - protected function supportedMethod(): string - { - return 'ping'; - } -} diff --git a/src/mcp-sdk/src/Server/RequestHandler/PromptGetHandler.php b/src/mcp-sdk/src/Server/RequestHandler/PromptGetHandler.php deleted file mode 100644 index 17c1a4c3f..000000000 --- a/src/mcp-sdk/src/Server/RequestHandler/PromptGetHandler.php +++ /dev/null @@ -1,83 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace Symfony\AI\McpSdk\Server\RequestHandler; - -use Symfony\AI\McpSdk\Capability\Prompt\PromptGet; -use Symfony\AI\McpSdk\Capability\Prompt\PromptGetterInterface; -use Symfony\AI\McpSdk\Exception\ExceptionInterface; -use Symfony\AI\McpSdk\Exception\InvalidArgumentException; -use Symfony\AI\McpSdk\Message\Error; -use Symfony\AI\McpSdk\Message\Request; -use Symfony\AI\McpSdk\Message\Response; - -/** - * @author Tobias Nyholm - */ -final class PromptGetHandler extends BaseRequestHandler -{ - public function __construct( - private readonly PromptGetterInterface $getter, - ) { - } - - public function createResponse(Request $message): Response|Error - { - $name = $message->params['name']; - $arguments = $message->params['arguments'] ?? []; - - try { - $result = $this->getter->get(new PromptGet(uniqid('', true), $name, $arguments)); - } catch (ExceptionInterface) { - return Error::internalError($message->id, 'Error while handling prompt'); - } - - $messages = []; - foreach ($result->messages as $resultMessage) { - $content = match ($resultMessage->type) { - 'text' => [ - 'type' => 'text', - 'text' => $resultMessage->result, - ], - 'image', 'audio' => [ - 'type' => $resultMessage->type, - 'data' => $resultMessage->result, - 'mimeType' => $resultMessage->mimeType, - ], - 'resource' => [ - 'type' => 'resource', - 'resource' => [ - 'uri' => $resultMessage->uri, - 'mimeType' => $resultMessage->mimeType, - 'text' => $resultMessage->result, - ], - ], - // TODO better exception - default => throw new InvalidArgumentException(\sprintf('Unsupported PromptGet result type: %s', $resultMessage->type)), - }; - - $messages[] = [ - 'role' => $resultMessage->role, - 'content' => $content, - ]; - } - - return new Response($message->id, [ - 'description' => $result->description, - 'messages' => $messages, - ]); - } - - protected function supportedMethod(): string - { - return 'prompts/get'; - } -} diff --git a/src/mcp-sdk/src/Server/RequestHandler/PromptListHandler.php b/src/mcp-sdk/src/Server/RequestHandler/PromptListHandler.php deleted file mode 100644 index 1ee7c44d2..000000000 --- a/src/mcp-sdk/src/Server/RequestHandler/PromptListHandler.php +++ /dev/null @@ -1,85 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace Symfony\AI\McpSdk\Server\RequestHandler; - -use Symfony\AI\McpSdk\Capability\Prompt\CollectionInterface; -use Symfony\AI\McpSdk\Message\Request; -use Symfony\AI\McpSdk\Message\Response; - -/** - * @author Tobias Nyholm - */ -final class PromptListHandler extends BaseRequestHandler -{ - public function __construct( - private readonly CollectionInterface $collection, - private readonly int $pageSize = 20, - ) { - } - - public function createResponse(Request $message): Response - { - $nextCursor = null; - $prompts = []; - - $metadataList = $this->collection->getMetadata( - $this->pageSize, - $message->params['cursor'] ?? null - ); - - foreach ($metadataList as $metadata) { - $nextCursor = $metadata->getName(); - $result = [ - 'name' => $metadata->getName(), - ]; - - $description = $metadata->getDescription(); - if (null !== $description) { - $result['description'] = $description; - } - - $arguments = []; - foreach ($metadata->getArguments() as $data) { - $argument = [ - 'name' => $data['name'], - 'required' => $data['required'] ?? false, - ]; - - if (isset($data['description'])) { - $argument['description'] = $data['description']; - } - $arguments[] = $argument; - } - - if ([] !== $arguments) { - $result['arguments'] = $arguments; - } - - $prompts[] = $result; - } - - $result = [ - 'prompts' => $prompts, - ]; - - if (null !== $nextCursor && \count($prompts) === $this->pageSize) { - $result['nextCursor'] = $nextCursor; - } - - return new Response($message->id, $result); - } - - protected function supportedMethod(): string - { - return 'prompts/list'; - } -} diff --git a/src/mcp-sdk/src/Server/RequestHandler/ResourceListHandler.php b/src/mcp-sdk/src/Server/RequestHandler/ResourceListHandler.php deleted file mode 100644 index 2d6e29d67..000000000 --- a/src/mcp-sdk/src/Server/RequestHandler/ResourceListHandler.php +++ /dev/null @@ -1,79 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace Symfony\AI\McpSdk\Server\RequestHandler; - -use Symfony\AI\McpSdk\Capability\Resource\CollectionInterface; -use Symfony\AI\McpSdk\Message\Request; -use Symfony\AI\McpSdk\Message\Response; - -/** - * @author Tobias Nyholm - */ -final class ResourceListHandler extends BaseRequestHandler -{ - public function __construct( - private readonly CollectionInterface $collection, - private readonly int $pageSize = 20, - ) { - } - - public function createResponse(Request $message): Response - { - $nextCursor = null; - $resources = []; - - $metadataList = $this->collection->getMetadata( - $this->pageSize, - $message->params['cursor'] ?? null - ); - - foreach ($metadataList as $metadata) { - $nextCursor = $metadata->getUri(); - $result = [ - 'uri' => $metadata->getUri(), - 'name' => $metadata->getName(), - ]; - - $description = $metadata->getDescription(); - if (null !== $description) { - $result['description'] = $description; - } - - $mimeType = $metadata->getMimeType(); - if (null !== $mimeType) { - $result['mimeType'] = $mimeType; - } - - $size = $metadata->getSize(); - if (null !== $size) { - $result['size'] = $size; - } - - $resources[] = $result; - } - - $result = [ - 'resources' => $resources, - ]; - - if (null !== $nextCursor && \count($resources) === $this->pageSize) { - $result['nextCursor'] = $nextCursor; - } - - return new Response($message->id, $result); - } - - protected function supportedMethod(): string - { - return 'resources/list'; - } -} diff --git a/src/mcp-sdk/src/Server/RequestHandler/ResourceReadHandler.php b/src/mcp-sdk/src/Server/RequestHandler/ResourceReadHandler.php deleted file mode 100644 index f75a5b159..000000000 --- a/src/mcp-sdk/src/Server/RequestHandler/ResourceReadHandler.php +++ /dev/null @@ -1,59 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace Symfony\AI\McpSdk\Server\RequestHandler; - -use Symfony\AI\McpSdk\Capability\Resource\ResourceRead; -use Symfony\AI\McpSdk\Capability\Resource\ResourceReaderInterface; -use Symfony\AI\McpSdk\Exception\ExceptionInterface; -use Symfony\AI\McpSdk\Exception\ResourceNotFoundException; -use Symfony\AI\McpSdk\Message\Error; -use Symfony\AI\McpSdk\Message\Request; -use Symfony\AI\McpSdk\Message\Response; - -/** - * @author Tobias Nyholm - */ -final class ResourceReadHandler extends BaseRequestHandler -{ - public function __construct( - private readonly ResourceReaderInterface $reader, - ) { - } - - public function createResponse(Request $message): Response|Error - { - $uri = $message->params['uri']; - - try { - $result = $this->reader->read(new ResourceRead(uniqid('', true), $uri)); - } catch (ResourceNotFoundException $e) { - return new Error($message->id, Error::RESOURCE_NOT_FOUND, $e->getMessage()); - } catch (ExceptionInterface) { - return Error::internalError($message->id, 'Error while reading resource'); - } - - return new Response($message->id, [ - 'contents' => [ - [ - 'uri' => $result->uri, - 'mimeType' => $result->mimeType, - $result->type => $result->result, - ], - ], - ]); - } - - protected function supportedMethod(): string - { - return 'resources/read'; - } -} diff --git a/src/mcp-sdk/src/Server/RequestHandler/ToolCallHandler.php b/src/mcp-sdk/src/Server/RequestHandler/ToolCallHandler.php deleted file mode 100644 index 91cbc3e50..000000000 --- a/src/mcp-sdk/src/Server/RequestHandler/ToolCallHandler.php +++ /dev/null @@ -1,75 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace Symfony\AI\McpSdk\Server\RequestHandler; - -use Symfony\AI\McpSdk\Capability\Tool\ToolCall; -use Symfony\AI\McpSdk\Capability\Tool\ToolExecutorInterface; -use Symfony\AI\McpSdk\Exception\ExceptionInterface; -use Symfony\AI\McpSdk\Exception\InvalidArgumentException; -use Symfony\AI\McpSdk\Message\Error; -use Symfony\AI\McpSdk\Message\Request; -use Symfony\AI\McpSdk\Message\Response; - -/** - * @author Christopher Hertel - */ -final class ToolCallHandler extends BaseRequestHandler -{ - public function __construct( - private readonly ToolExecutorInterface $toolExecutor, - ) { - } - - public function createResponse(Request $message): Response|Error - { - $name = $message->params['name']; - $arguments = $message->params['arguments'] ?? []; - - try { - $result = $this->toolExecutor->call(new ToolCall(uniqid('', true), $name, $arguments)); - } catch (ExceptionInterface) { - return Error::internalError($message->id, 'Error while executing tool'); - } - - $content = match ($result->type) { - 'text' => [ - 'type' => 'text', - 'text' => $result->result, - ], - 'image', 'audio' => [ - 'type' => $result->type, - 'data' => $result->result, - 'mimeType' => $result->mimeType, - ], - 'resource' => [ - 'type' => 'resource', - 'resource' => [ - 'uri' => $result->uri, - 'mimeType' => $result->mimeType, - 'text' => $result->result, - ], - ], - // TODO better exception - default => throw new InvalidArgumentException(\sprintf('Unsupported tool result type: %s', $result->type)), - }; - - return new Response($message->id, [ - 'content' => [$content], // TODO: allow multiple `ToolCallResult`s in the future - 'isError' => $result->isError, - ]); - } - - protected function supportedMethod(): string - { - return 'tools/call'; - } -} diff --git a/src/mcp-sdk/src/Server/RequestHandler/ToolListHandler.php b/src/mcp-sdk/src/Server/RequestHandler/ToolListHandler.php deleted file mode 100644 index 62114e6dc..000000000 --- a/src/mcp-sdk/src/Server/RequestHandler/ToolListHandler.php +++ /dev/null @@ -1,78 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace Symfony\AI\McpSdk\Server\RequestHandler; - -use Symfony\AI\McpSdk\Capability\Tool\CollectionInterface; -use Symfony\AI\McpSdk\Message\Request; -use Symfony\AI\McpSdk\Message\Response; - -/** - * @author Christopher Hertel - */ -final class ToolListHandler extends BaseRequestHandler -{ - public function __construct( - private readonly CollectionInterface $collection, - private readonly ?int $pageSize = 20, - ) { - } - - public function createResponse(Request $message): Response - { - $nextCursor = null; - $tools = []; - - $metadataList = $this->collection->getMetadata( - $this->pageSize, - $message->params['cursor'] ?? null - ); - - foreach ($metadataList as $tool) { - $nextCursor = $tool->getName(); - $inputSchema = $tool->getInputSchema(); - $annotations = null === $tool->getAnnotations() ? [] : array_filter([ - 'title' => $tool->getAnnotations()->getTitle(), - 'destructiveHint' => $tool->getAnnotations()->getDestructiveHint(), - 'idempotentHint' => $tool->getAnnotations()->getIdempotentHint(), - 'openWorldHint' => $tool->getAnnotations()->getOpenWorldHint(), - 'readOnlyHint' => $tool->getAnnotations()->getReadOnlyHint(), - ], static fn ($value) => null !== $value); - - $tools[] = array_filter([ - 'name' => $tool->getName(), - 'description' => $tool->getDescription(), - 'inputSchema' => [] === $inputSchema ? [ - 'type' => 'object', - '$schema' => 'http://json-schema.org/draft-07/schema#', - ] : $inputSchema, - 'title' => $tool->getTitle(), - 'outputSchema' => $tool->getOutputSchema(), - 'annotations' => (object) $annotations, - ], static fn ($value) => null !== $value); - } - - $result = [ - 'tools' => $tools, - ]; - - if (null !== $nextCursor && \count($tools) === $this->pageSize) { - $result['nextCursor'] = $nextCursor; - } - - return new Response($message->id, $result); - } - - protected function supportedMethod(): string - { - return 'tools/list'; - } -} diff --git a/src/mcp-sdk/src/Server/RequestHandlerInterface.php b/src/mcp-sdk/src/Server/RequestHandlerInterface.php deleted file mode 100644 index 71a93b6c9..000000000 --- a/src/mcp-sdk/src/Server/RequestHandlerInterface.php +++ /dev/null @@ -1,30 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace Symfony\AI\McpSdk\Server; - -use Symfony\AI\McpSdk\Exception\ExceptionInterface; -use Symfony\AI\McpSdk\Message\Error; -use Symfony\AI\McpSdk\Message\Request; -use Symfony\AI\McpSdk\Message\Response; - -/** - * @author Christopher Hertel - */ -interface RequestHandlerInterface -{ - public function supports(Request $message): bool; - - /** - * @throws ExceptionInterface When the handler encounters an error processing the request - */ - public function createResponse(Request $message): Response|Error; -} diff --git a/src/mcp-sdk/src/Server/Transport/Sse/Store/CachePoolStore.php b/src/mcp-sdk/src/Server/Transport/Sse/Store/CachePoolStore.php deleted file mode 100644 index 57b54391e..000000000 --- a/src/mcp-sdk/src/Server/Transport/Sse/Store/CachePoolStore.php +++ /dev/null @@ -1,62 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace Symfony\AI\McpSdk\Server\Transport\Sse\Store; - -use Psr\Cache\CacheItemPoolInterface; -use Symfony\AI\McpSdk\Server\Transport\Sse\StoreInterface; -use Symfony\Component\Uid\Uuid; - -final readonly class CachePoolStore implements StoreInterface -{ - public function __construct( - private CacheItemPoolInterface $cachePool, - ) { - } - - public function push(Uuid $id, string $message): void - { - $item = $this->cachePool->getItem($this->getCacheKey($id)); - - $messages = $item->isHit() ? $item->get() : []; - $messages[] = $message; - $item->set($messages); - - $this->cachePool->save($item); - } - - public function pop(Uuid $id): ?string - { - $item = $this->cachePool->getItem($this->getCacheKey($id)); - - if (!$item->isHit()) { - return null; - } - - $messages = $item->get(); - $message = array_shift($messages); - - $item->set($messages); - $this->cachePool->save($item); - - return $message; - } - - public function remove(Uuid $id): void - { - $this->cachePool->deleteItem($this->getCacheKey($id)); - } - - private function getCacheKey(Uuid $id): string - { - return 'message_'.$id->toRfc4122(); - } -} diff --git a/src/mcp-sdk/src/Server/Transport/Sse/StoreInterface.php b/src/mcp-sdk/src/Server/Transport/Sse/StoreInterface.php deleted file mode 100644 index b8ae7f2db..000000000 --- a/src/mcp-sdk/src/Server/Transport/Sse/StoreInterface.php +++ /dev/null @@ -1,26 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace Symfony\AI\McpSdk\Server\Transport\Sse; - -use Symfony\Component\Uid\Uuid; - -/** - * @author Christopher Hertel - */ -interface StoreInterface -{ - public function push(Uuid $id, string $message): void; - - public function pop(Uuid $id): ?string; - - public function remove(Uuid $id): void; -} diff --git a/src/mcp-sdk/src/Server/Transport/Sse/StreamTransport.php b/src/mcp-sdk/src/Server/Transport/Sse/StreamTransport.php deleted file mode 100644 index ee28a79c3..000000000 --- a/src/mcp-sdk/src/Server/Transport/Sse/StreamTransport.php +++ /dev/null @@ -1,62 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace Symfony\AI\McpSdk\Server\Transport\Sse; - -use Symfony\AI\McpSdk\Server\TransportInterface; -use Symfony\Component\Uid\Uuid; - -final readonly class StreamTransport implements TransportInterface -{ - public function __construct( - private string $messageEndpoint, - private StoreInterface $store, - private Uuid $id, - ) { - } - - public function initialize(): void - { - ignore_user_abort(true); - $this->flushEvent('endpoint', $this->messageEndpoint); - } - - public function isConnected(): bool - { - return 0 === connection_aborted(); - } - - public function receive(): \Generator - { - yield $this->store->pop($this->id); - } - - public function send(string $data): void - { - $this->flushEvent('message', $data); - } - - public function close(): void - { - $this->store->remove($this->id); - } - - private function flushEvent(string $event, string $data): void - { - echo \sprintf('event: %s', $event).\PHP_EOL; - echo \sprintf('data: %s', $data).\PHP_EOL; - echo \PHP_EOL; - if (false !== ob_get_length()) { - ob_flush(); - } - flush(); - } -} diff --git a/src/mcp-sdk/src/Server/Transport/Stdio/SymfonyConsoleTransport.php b/src/mcp-sdk/src/Server/Transport/Stdio/SymfonyConsoleTransport.php deleted file mode 100644 index c5da15e90..000000000 --- a/src/mcp-sdk/src/Server/Transport/Stdio/SymfonyConsoleTransport.php +++ /dev/null @@ -1,67 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace Symfony\AI\McpSdk\Server\Transport\Stdio; - -use Symfony\AI\McpSdk\Server\TransportInterface; -use Symfony\Component\Console\Input\InputInterface; -use Symfony\Component\Console\Input\StreamableInputInterface; -use Symfony\Component\Console\Output\OutputInterface; - -/** - * Heavily inspired by https://jolicode.com/blog/mcp-the-open-protocol-that-turns-llm-chatbots-into-intelligent-agents. - * - * @author Christopher Hertel - */ -final class SymfonyConsoleTransport implements TransportInterface -{ - private string $buffer = ''; - - public function __construct( - private readonly InputInterface $input, - private readonly OutputInterface $output, - ) { - } - - public function initialize(): void - { - } - - public function isConnected(): bool - { - return true; - } - - public function receive(): \Generator - { - $stream = $this->input instanceof StreamableInputInterface ? $this->input->getStream() ?? \STDIN : \STDIN; - $line = fgets($stream); - if (false === $line) { - return; - } - $this->buffer .= \STDIN === $stream ? rtrim($line).\PHP_EOL : $line; - if (str_contains($this->buffer, \PHP_EOL)) { - $lines = explode(\PHP_EOL, $this->buffer); - $this->buffer = array_pop($lines); - - yield from $lines; - } - } - - public function send(string $data): void - { - $this->output->writeln($data); - } - - public function close(): void - { - } -} diff --git a/src/mcp-sdk/src/Server/TransportInterface.php b/src/mcp-sdk/src/Server/TransportInterface.php deleted file mode 100644 index a75795e73..000000000 --- a/src/mcp-sdk/src/Server/TransportInterface.php +++ /dev/null @@ -1,28 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace Symfony\AI\McpSdk\Server; - -/** - * @author Christopher Hertel - */ -interface TransportInterface -{ - public function initialize(): void; - - public function isConnected(): bool; - - public function receive(): \Generator; - - public function send(string $data): void; - - public function close(): void; -} diff --git a/src/mcp-sdk/tests/Fixtures/InMemoryTransport.php b/src/mcp-sdk/tests/Fixtures/InMemoryTransport.php deleted file mode 100644 index d6f2ca3c8..000000000 --- a/src/mcp-sdk/tests/Fixtures/InMemoryTransport.php +++ /dev/null @@ -1,50 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace Symfony\AI\McpSdk\Tests\Fixtures; - -use Symfony\AI\McpSdk\Server\TransportInterface; - -class InMemoryTransport implements TransportInterface -{ - private bool $connected = true; - - /** - * @param list $messages - */ - public function __construct( - private readonly array $messages = [], - ) { - } - - public function initialize(): void - { - } - - public function isConnected(): bool - { - return $this->connected; - } - - public function receive(): \Generator - { - yield from $this->messages; - $this->connected = false; - } - - public function send(string $data): void - { - } - - public function close(): void - { - } -} diff --git a/src/mcp-sdk/tests/Message/ErrorTest.php b/src/mcp-sdk/tests/Message/ErrorTest.php deleted file mode 100644 index 1fed107b7..000000000 --- a/src/mcp-sdk/tests/Message/ErrorTest.php +++ /dev/null @@ -1,48 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace Symfony\AI\McpSdk\Tests\Message; - -use PHPUnit\Framework\TestCase; -use Symfony\AI\McpSdk\Message\Error; - -final class ErrorTest extends TestCase -{ - public function testWithIntegerId() - { - $error = new Error(1, -32602, 'Another error occurred'); - $expected = [ - 'jsonrpc' => '2.0', - 'id' => 1, - 'error' => [ - 'code' => -32602, - 'message' => 'Another error occurred', - ], - ]; - - $this->assertSame($expected, $error->jsonSerialize()); - } - - public function testWithStringId() - { - $error = new Error('abc', -32602, 'Another error occurred'); - $expected = [ - 'jsonrpc' => '2.0', - 'id' => 'abc', - 'error' => [ - 'code' => -32602, - 'message' => 'Another error occurred', - ], - ]; - - $this->assertSame($expected, $error->jsonSerialize()); - } -} diff --git a/src/mcp-sdk/tests/Message/FactoryTest.php b/src/mcp-sdk/tests/Message/FactoryTest.php deleted file mode 100644 index 6d0cdf10d..000000000 --- a/src/mcp-sdk/tests/Message/FactoryTest.php +++ /dev/null @@ -1,90 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace Symfony\AI\McpSdk\Tests\Message; - -use PHPUnit\Framework\TestCase; -use Symfony\AI\McpSdk\Exception\InvalidInputMessageException; -use Symfony\AI\McpSdk\Message\Factory; -use Symfony\AI\McpSdk\Message\Notification; -use Symfony\AI\McpSdk\Message\Request; - -final class FactoryTest extends TestCase -{ - private Factory $factory; - - protected function setUp(): void - { - $this->factory = new Factory(); - } - - public function testCreateRequest() - { - $json = '{"jsonrpc": "2.0", "method": "test_method", "params": {"foo": "bar"}, "id": 123}'; - - $result = $this->first($this->factory->create($json)); - - $this->assertInstanceOf(Request::class, $result); - $this->assertSame('test_method', $result->method); - $this->assertSame(['foo' => 'bar'], $result->params); - $this->assertSame(123, $result->id); - } - - public function testCreateNotification() - { - $json = '{"jsonrpc": "2.0", "method": "notifications/test_event", "params": {"foo": "bar"}}'; - - $result = $this->first($this->factory->create($json)); - - $this->assertInstanceOf(Notification::class, $result); - $this->assertSame('notifications/test_event', $result->method); - $this->assertSame(['foo' => 'bar'], $result->params); - } - - public function testInvalidJson() - { - $this->expectException(\JsonException::class); - - $this->first($this->factory->create('invalid json')); - } - - public function testMissingMethod() - { - $result = $this->first($this->factory->create('{"jsonrpc": "2.0", "params": {}, "id": 1}')); - $this->assertInstanceOf(InvalidInputMessageException::class, $result); - $this->assertEquals('Invalid JSON-RPC request, missing "method".', $result->getMessage()); - } - - public function testBatchMissingMethod() - { - $results = $this->factory->create('[{"jsonrpc": "2.0", "params": {}, "id": 1}, {"jsonrpc": "2.0", "method": "notifications/test_event", "params": {}, "id": 2}]'); - - $results = iterator_to_array($results); - $result = array_shift($results); - $this->assertInstanceOf(InvalidInputMessageException::class, $result); - $this->assertEquals('Invalid JSON-RPC request, missing "method".', $result->getMessage()); - - $result = array_shift($results); - $this->assertInstanceOf(Notification::class, $result); - } - - /** - * @param iterable $items - */ - private function first(iterable $items): mixed - { - foreach ($items as $item) { - return $item; - } - - return null; - } -} diff --git a/src/mcp-sdk/tests/Message/ResponseTest.php b/src/mcp-sdk/tests/Message/ResponseTest.php deleted file mode 100644 index a8dd98e29..000000000 --- a/src/mcp-sdk/tests/Message/ResponseTest.php +++ /dev/null @@ -1,42 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace Symfony\AI\McpSdk\Tests\Message; - -use PHPUnit\Framework\TestCase; -use Symfony\AI\McpSdk\Message\Response; - -final class ResponseTest extends TestCase -{ - public function testWithIntegerId() - { - $response = new Response(1, ['foo' => 'bar']); - $expected = [ - 'jsonrpc' => '2.0', - 'id' => 1, - 'result' => ['foo' => 'bar'], - ]; - - $this->assertSame($expected, $response->jsonSerialize()); - } - - public function testWithStringId() - { - $response = new Response('abc', ['foo' => 'bar']); - $expected = [ - 'jsonrpc' => '2.0', - 'id' => 'abc', - 'result' => ['foo' => 'bar'], - ]; - - $this->assertSame($expected, $response->jsonSerialize()); - } -} diff --git a/src/mcp-sdk/tests/Server/JsonRpcHandlerTest.php b/src/mcp-sdk/tests/Server/JsonRpcHandlerTest.php deleted file mode 100644 index e3aad8fd2..000000000 --- a/src/mcp-sdk/tests/Server/JsonRpcHandlerTest.php +++ /dev/null @@ -1,86 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace Symfony\AI\McpSdk\Tests\Server; - -use PHPUnit\Framework\Attributes\TestDox; -use PHPUnit\Framework\TestCase; -use Psr\Log\NullLogger; -use Symfony\AI\McpSdk\Message\Factory; -use Symfony\AI\McpSdk\Message\Response; -use Symfony\AI\McpSdk\Server\JsonRpcHandler; -use Symfony\AI\McpSdk\Server\NotificationHandlerInterface; -use Symfony\AI\McpSdk\Server\RequestHandlerInterface; - -class JsonRpcHandlerTest extends TestCase -{ - #[TestDox('Make sure a single notification can be handled by multiple handlers.')] - public function testHandleMultipleNotifications() - { - $handlerA = $this->getMockBuilder(NotificationHandlerInterface::class) - ->disableOriginalConstructor() - ->onlyMethods(['supports', 'handle']) - ->getMock(); - $handlerA->method('supports')->willReturn(true); - $handlerA->expects($this->once())->method('handle'); - - $handlerB = $this->getMockBuilder(NotificationHandlerInterface::class) - ->disableOriginalConstructor() - ->onlyMethods(['supports', 'handle']) - ->getMock(); - $handlerB->method('supports')->willReturn(false); - $handlerB->expects($this->never())->method('handle'); - - $handlerC = $this->getMockBuilder(NotificationHandlerInterface::class) - ->disableOriginalConstructor() - ->onlyMethods(['supports', 'handle']) - ->getMock(); - $handlerC->method('supports')->willReturn(true); - $handlerC->expects($this->once())->method('handle'); - - $jsonRpc = new JsonRpcHandler(new Factory(), [], [$handlerA, $handlerB, $handlerC], new NullLogger()); - $result = $jsonRpc->process( - '{"jsonrpc": "2.0", "id": 1, "method": "notifications/foobar"}' - ); - iterator_to_array($result); - } - - #[TestDox('Make sure a single request can NOT be handled by multiple handlers.')] - public function testHandleMultipleRequests() - { - $handlerA = $this->getMockBuilder(RequestHandlerInterface::class) - ->disableOriginalConstructor() - ->onlyMethods(['supports', 'createResponse']) - ->getMock(); - $handlerA->method('supports')->willReturn(true); - $handlerA->expects($this->once())->method('createResponse')->willReturn(new Response(1)); - - $handlerB = $this->getMockBuilder(RequestHandlerInterface::class) - ->disableOriginalConstructor() - ->onlyMethods(['supports', 'createResponse']) - ->getMock(); - $handlerB->method('supports')->willReturn(false); - $handlerB->expects($this->never())->method('createResponse'); - - $handlerC = $this->getMockBuilder(RequestHandlerInterface::class) - ->disableOriginalConstructor() - ->onlyMethods(['supports', 'createResponse']) - ->getMock(); - $handlerC->method('supports')->willReturn(true); - $handlerC->expects($this->never())->method('createResponse'); - - $jsonRpc = new JsonRpcHandler(new Factory(), [$handlerA, $handlerB, $handlerC], [], new NullLogger()); - $result = $jsonRpc->process( - '{"jsonrpc": "2.0", "id": 1, "method": "request/foobar"}' - ); - iterator_to_array($result); - } -} diff --git a/src/mcp-sdk/tests/Server/RequestHandler/PromptListHandlerTest.php b/src/mcp-sdk/tests/Server/RequestHandler/PromptListHandlerTest.php deleted file mode 100644 index 050bc05ba..000000000 --- a/src/mcp-sdk/tests/Server/RequestHandler/PromptListHandlerTest.php +++ /dev/null @@ -1,76 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace Symfony\AI\McpSdk\Tests\Server\RequestHandler; - -use PHPUnit\Framework\TestCase; -use Symfony\AI\McpSdk\Capability\Prompt\MetadataInterface; -use Symfony\AI\McpSdk\Capability\PromptChain; -use Symfony\AI\McpSdk\Message\Request; -use Symfony\AI\McpSdk\Server\RequestHandler\PromptListHandler; - -class PromptListHandlerTest extends TestCase -{ - public function testHandleEmpty() - { - $handler = new PromptListHandler(new PromptChain([])); - $message = new Request(1, 'prompts/list', []); - $response = $handler->createResponse($message); - $this->assertEquals(1, $response->id); - $this->assertEquals(['prompts' => []], $response->result); - } - - public function testHandleReturnAll() - { - $item = self::createMetadataItem(); - $handler = new PromptListHandler(new PromptChain([$item])); - $message = new Request(1, 'prompts/list', []); - $response = $handler->createResponse($message); - $this->assertCount(1, $response->result['prompts']); - $this->assertArrayNotHasKey('nextCursor', $response->result); - } - - public function testHandlePagination() - { - $item = self::createMetadataItem(); - $handler = new PromptListHandler(new PromptChain([$item, $item]), 2); - $message = new Request(1, 'prompts/list', []); - $response = $handler->createResponse($message); - $this->assertCount(2, $response->result['prompts']); - $this->assertArrayHasKey('nextCursor', $response->result); - } - - private static function createMetadataItem(): MetadataInterface - { - return new class implements MetadataInterface { - public function getName(): string - { - return 'greet'; - } - - public function getDescription(): string - { - return 'Greet a person with a nice message'; - } - - public function getArguments(): array - { - return [ - [ - 'name' => 'first name', - 'description' => 'The name of the person to greet', - 'required' => false, - ], - ]; - } - }; - } -} diff --git a/src/mcp-sdk/tests/Server/RequestHandler/ResourceListHandlerTest.php b/src/mcp-sdk/tests/Server/RequestHandler/ResourceListHandlerTest.php deleted file mode 100644 index fcbe385b0..000000000 --- a/src/mcp-sdk/tests/Server/RequestHandler/ResourceListHandlerTest.php +++ /dev/null @@ -1,113 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace Symfony\AI\McpSdk\Tests\Server\RequestHandler; - -use PHPUnit\Framework\Attributes\DataProvider; -use PHPUnit\Framework\TestCase; -use Symfony\AI\McpSdk\Capability\Resource\CollectionInterface; -use Symfony\AI\McpSdk\Capability\Resource\MetadataInterface; -use Symfony\AI\McpSdk\Message\Request; -use Symfony\AI\McpSdk\Server\RequestHandler\ResourceListHandler; - -class ResourceListHandlerTest extends TestCase -{ - public function testHandleEmpty() - { - $collection = $this->getMockBuilder(CollectionInterface::class) - ->disableOriginalConstructor() - ->onlyMethods(['getMetadata']) - ->getMock(); - $collection->expects($this->once())->method('getMetadata')->willReturn([]); - - $handler = new ResourceListHandler($collection); - $message = new Request(1, 'resources/list', []); - $response = $handler->createResponse($message); - $this->assertEquals(1, $response->id); - $this->assertEquals(['resources' => []], $response->result); - } - - /** - * @param iterable $metadataList - */ - #[DataProvider('metadataProvider')] - public function testHandleReturnAll(iterable $metadataList) - { - $collection = $this->getMockBuilder(CollectionInterface::class) - ->disableOriginalConstructor() - ->onlyMethods(['getMetadata']) - ->getMock(); - $collection->expects($this->once())->method('getMetadata')->willReturn($metadataList); - $handler = new ResourceListHandler($collection); - $message = new Request(1, 'resources/list', []); - $response = $handler->createResponse($message); - $this->assertCount(1, $response->result['resources']); - $this->assertArrayNotHasKey('nextCursor', $response->result); - } - - /** - * @return array> - */ - public static function metadataProvider(): array - { - $item = self::createMetadataItem(); - - return [ - 'array' => [[$item]], - 'generator' => [(function () use ($item) { yield $item; })()], - ]; - } - - public function testHandlePagination() - { - $item = self::createMetadataItem(); - $collection = $this->getMockBuilder(CollectionInterface::class) - ->disableOriginalConstructor() - ->onlyMethods(['getMetadata']) - ->getMock(); - $collection->expects($this->once())->method('getMetadata')->willReturn([$item, $item]); - $handler = new ResourceListHandler($collection, 2); - $message = new Request(1, 'resources/list', []); - $response = $handler->createResponse($message); - $this->assertCount(2, $response->result['resources']); - $this->assertArrayHasKey('nextCursor', $response->result); - } - - private static function createMetadataItem(): MetadataInterface - { - return new class implements MetadataInterface { - public function getUri(): string - { - return 'file:///src/SomeFile.php'; - } - - public function getName(): string - { - return 'src/SomeFile.php'; - } - - public function getDescription(): string - { - return 'File src/SomeFile.php'; - } - - public function getMimeType(): string - { - return 'text/plain'; - } - - public function getSize(): int - { - return 1024; - } - }; - } -} diff --git a/src/mcp-sdk/tests/Server/RequestHandler/ToolListHandlerTest.php b/src/mcp-sdk/tests/Server/RequestHandler/ToolListHandlerTest.php deleted file mode 100644 index 2d291b4ff..000000000 --- a/src/mcp-sdk/tests/Server/RequestHandler/ToolListHandlerTest.php +++ /dev/null @@ -1,119 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace Symfony\AI\McpSdk\Tests\Server\RequestHandler; - -use PHPUnit\Framework\Attributes\DataProvider; -use PHPUnit\Framework\TestCase; -use Symfony\AI\McpSdk\Capability\Tool\CollectionInterface; -use Symfony\AI\McpSdk\Capability\Tool\MetadataInterface; -use Symfony\AI\McpSdk\Capability\Tool\ToolAnnotationsInterface; -use Symfony\AI\McpSdk\Message\Request; -use Symfony\AI\McpSdk\Server\RequestHandler\ToolListHandler; - -class ToolListHandlerTest extends TestCase -{ - public function testHandleEmpty() - { - $collection = $this->getMockBuilder(CollectionInterface::class) - ->disableOriginalConstructor() - ->onlyMethods(['getMetadata']) - ->getMock(); - $collection->expects($this->once())->method('getMetadata')->willReturn([]); - - $handler = new ToolListHandler($collection); - $message = new Request(1, 'tools/list', []); - $response = $handler->createResponse($message); - $this->assertEquals(1, $response->id); - $this->assertEquals(['tools' => []], $response->result); - } - - /** - * @param iterable $metadataList - */ - #[DataProvider('metadataProvider')] - public function testHandleReturnAll(iterable $metadataList) - { - $collection = $this->getMockBuilder(CollectionInterface::class) - ->disableOriginalConstructor() - ->onlyMethods(['getMetadata']) - ->getMock(); - $collection->expects($this->once())->method('getMetadata')->willReturn($metadataList); - $handler = new ToolListHandler($collection); - $message = new Request(1, 'tools/list', []); - $response = $handler->createResponse($message); - $this->assertCount(1, $response->result['tools']); - $this->assertArrayNotHasKey('nextCursor', $response->result); - } - - /** - * @return array> - */ - public static function metadataProvider(): array - { - $item = self::createMetadataItem(); - - return [ - 'array' => [[$item]], - 'generator' => [(function () use ($item) { yield $item; })()], - ]; - } - - public function testHandlePagination() - { - $item = self::createMetadataItem(); - $collection = $this->getMockBuilder(CollectionInterface::class) - ->disableOriginalConstructor() - ->onlyMethods(['getMetadata']) - ->getMock(); - $collection->expects($this->once())->method('getMetadata')->willReturn([$item, $item]); - $handler = new ToolListHandler($collection, 2); - $message = new Request(1, 'tools/list', []); - $response = $handler->createResponse($message); - $this->assertCount(2, $response->result['tools']); - $this->assertArrayHasKey('nextCursor', $response->result); - } - - private static function createMetadataItem(): MetadataInterface - { - return new class implements MetadataInterface { - public function getName(): string - { - return 'test_tool'; - } - - public function getDescription(): string - { - return 'A test tool'; - } - - public function getInputSchema(): array - { - return ['type' => 'object']; - } - - public function getOutputSchema(): ?array - { - return null; - } - - public function getTitle(): string - { - return 'Test tool'; - } - - public function getAnnotations(): ?ToolAnnotationsInterface - { - return null; - } - }; - } -} diff --git a/src/mcp-sdk/tests/ServerTest.php b/src/mcp-sdk/tests/ServerTest.php deleted file mode 100644 index acfc4b3cb..000000000 --- a/src/mcp-sdk/tests/ServerTest.php +++ /dev/null @@ -1,46 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace Symfony\AI\McpSdk\Tests; - -use PHPUnit\Framework\MockObject\Stub\Exception; -use PHPUnit\Framework\TestCase; -use Psr\Log\NullLogger; -use Symfony\AI\McpSdk\Server; -use Symfony\AI\McpSdk\Server\JsonRpcHandler; -use Symfony\AI\McpSdk\Tests\Fixtures\InMemoryTransport; - -class ServerTest extends TestCase -{ - public function testJsonExceptions() - { - $logger = $this->getMockBuilder(NullLogger::class) - ->disableOriginalConstructor() - ->onlyMethods(['error']) - ->getMock(); - $logger->expects($this->once())->method('error'); - - $handler = $this->getMockBuilder(JsonRpcHandler::class) - ->disableOriginalConstructor() - ->onlyMethods(['process']) - ->getMock(); - $handler->expects($this->exactly(2))->method('process')->willReturnOnConsecutiveCalls(new Exception(new \JsonException('foobar')), ['success']); - - $transport = $this->getMockBuilder(InMemoryTransport::class) - ->setConstructorArgs([['foo', 'bar']]) - ->onlyMethods(['send']) - ->getMock(); - $transport->expects($this->once())->method('send')->with('success'); - - $server = new Server($handler, $logger); - $server->connect($transport); - } -}