diff --git a/README.md b/README.md index 1683ac9..2a2ffeb 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,7 @@ This SDK enables you to expose your PHP application's functionality as standardi - **๐Ÿงช Completion Providers**: Built-in support for argument completion in tools and prompts - **๐Ÿ”Œ Dependency Injection**: Full PSR-11 container support with auto-wiring - **๐Ÿ“‹ Comprehensive Testing**: Extensive test suite with integration tests for all transports +- **๐Ÿ”Œ Symfony integration**: This SDK can be easily integrated into Symfony applications using the bridge included. This package supports the **2025-03-26** version of the Model Context Protocol with backward compatibility. @@ -1213,6 +1214,61 @@ sudo apt install certbot python3-certbot-nginx sudo certbot --nginx -d mcp.yourdomain.com ``` +## Symfony integration + +This SDK provides a Symfony bundle for easy integration into Symfony applications. The bundle automatically registers MCP elements and provides a convenient way to configure the server. + +### Step 1: Installation + +```php +// config/bundles.php + +return [ + // ... + PhpMcp\Server\Bridge\Symfony\McpServerBundle::class => ['all' => true], + // ... +]; +``` + +### Step 2: Configuration + +```yaml +# config/packages/mcp_server.yaml +mcp_server: + logger: logger # default value, must be a service ID + server_info: + name: MCP Server + version: 1.0.0 +``` + +### Step 3: Registering MCP Elements + +In order to register MCP elements, you must implement the `McpElementInterface` in your services. The bundle will automatically discovers these services and registers them with the MCP server. + +```php +namespace App\Mcp; + +use PhpMcp\Server\Attributes\McpTool; +use PhpMcp\Server\Contracts\McpElementInterface; + +class MyMcpService implements McpElementInterface +{ + #[McpTool(name: 'my_tool')] + public function process(): string + { + // Your tool logic here + } +} +``` + +### Step 4: Running the server + +The bundle provides a command to run the MCP server: + +```bash +bin/console mcp-server:start +``` + ## ๐Ÿ“š Examples & Use Cases Explore comprehensive examples in the [`examples/`](./examples/) directory: diff --git a/composer.json b/composer.json index 842f591..ab5406e 100644 --- a/composer.json +++ b/composer.json @@ -43,10 +43,17 @@ "pestphp/pest": "^2.36.0|^3.5.0", "react/async": "^4.0", "react/child-process": "^0.6.6", + "symfony/config": "^6.4|^7.1", + "symfony/dependency-injection": "^6.4|^7.1", + "symfony/http-kernel": "^6.4|^7.1", "symfony/var-dumper": "^6.4.11|^7.1.5" }, "suggest": { - "react/http": "Required for using the ReactPHP HTTP transport handler (^1.11 recommended)." + "react/http": "Required for using the ReactPHP HTTP transport handler (^1.11 recommended).", + "symfony/config": "To use the Symfony bundle shipped with this package", + "symfony/console": "To use the Symfony bundle shipped with this package", + "symfony/dependency-injection": "To use the Symfony bundle shipped with this package", + "symfony/http-kernel": "To use the Symfony bundle shipped with this package" }, "autoload": { "psr-4": { diff --git a/src/Bridge/Symfony/Command/McpServerStartCommand.php b/src/Bridge/Symfony/Command/McpServerStartCommand.php new file mode 100644 index 0000000..43c9e20 --- /dev/null +++ b/src/Bridge/Symfony/Command/McpServerStartCommand.php @@ -0,0 +1,28 @@ +server->listen(new StdioServerTransport()); + + return Command::SUCCESS; + } +} diff --git a/src/Bridge/Symfony/DependencyInjection/Compiler/McpServerPass.php b/src/Bridge/Symfony/DependencyInjection/Compiler/McpServerPass.php new file mode 100644 index 0000000..17da86b --- /dev/null +++ b/src/Bridge/Symfony/DependencyInjection/Compiler/McpServerPass.php @@ -0,0 +1,87 @@ +getDefinition('mcp_server.server_builder'); + + $mcpElements = []; + + foreach ($container->findTaggedServiceIds('mcp_server.server_element') as $serviceId => $tags) { + $definition = $container->findDefinition($serviceId); + + $mcpElements[$definition->getClass()] = new Reference($serviceId); + + $reflectionClass = new \ReflectionClass($definition->getClass()); + + foreach ([McpPrompt::class, McpResource::class, McpResourceTemplate::class, McpTool::class] as $attributeClass) { + if ([] !== $reflectionAttributes = $reflectionClass->getAttributes($attributeClass, \ReflectionAttribute::IS_INSTANCEOF)) { + if (!$reflectionClass->hasMethod('__invoke')) { + throw new LogicException(sprintf('The class "%s" has attribute "%s" but method "__invoke" is missing, please declare it.', $definition->getClass(), $attributeClass)); + } + + $this->mapElements($attributeClass, $serverBuilderDefinition, [$definition->getClass(), '__invoke'], $reflectionAttributes[0]->getArguments()); + + break; + } + + foreach ($reflectionClass->getMethods() as $reflectionMethod) { + if ([] !== $reflectionAttributes = $reflectionMethod->getAttributes($attributeClass, \ReflectionAttribute::IS_INSTANCEOF)) { + $this->mapElements($attributeClass, $serverBuilderDefinition, [$definition->getClass(), $reflectionMethod->getName()], $reflectionAttributes[0]->getArguments()); + } + } + } + } + + $serverBuilderDefinition->addMethodCall('withContainer', [ServiceLocatorTagPass::register($container, $mcpElements)]); + } + + private function mapElements(string $attributeClass, Definition $serverBuilderDefinition, array $handler, array $attributeArgs): void + { + match ($attributeClass) { + McpPrompt::class => $serverBuilderDefinition->addMethodCall('withPrompt', [ + $handler, + $attributeArgs['name'] ?? null, + $attributeArgs['description'] ?? null, + ]), + McpResource::class => $serverBuilderDefinition->addMethodCall('withResource', [ + $handler, + $attributeArgs['uri'] ?? null, + $attributeArgs['name'] ?? null, + $attributeArgs['description'] ?? null, + $attributeArgs['mimeType'] ?? null, + $attributeArgs['size'] ?? null, + $attributeArgs['annotations'] ?? [], + ]), + McpResourceTemplate::class => $serverBuilderDefinition->addMethodCall('withResourceTemplate', [ + $handler, + $attributeArgs['uriTemplate'] ?? null, + $attributeArgs['name'] ?? null, + $attributeArgs['description'] ?? null, + $attributeArgs['mimeType'] ?? null, + $attributeArgs['annotations'] ?? [], + ]), + McpTool::class => $serverBuilderDefinition->addMethodCall('withTool', [ + $handler, + $attributeArgs['name'] ?? null, + $attributeArgs['description'] ?? null, + ]), + }; + } +} diff --git a/src/Bridge/Symfony/McpServerBundle.php b/src/Bridge/Symfony/McpServerBundle.php new file mode 100644 index 0000000..2cffa67 --- /dev/null +++ b/src/Bridge/Symfony/McpServerBundle.php @@ -0,0 +1,60 @@ +rootNode() + ->children() + ->scalarNode('logger')->cannotBeEmpty()->defaultValue('logger')->end() + ->arrayNode('server_info') + ->isRequired() + ->children() + ->scalarNode('name')->isRequired()->cannotBeEmpty()->end() + ->scalarNode('version')->isRequired()->cannotBeEmpty()->end() + ->end() + ->end() + ->end(); + } + + public function loadExtension(array $config, ContainerConfigurator $container, ContainerBuilder $builder): void + { + $container->services() + ->set('mcp_server.server_builder', ServerBuilder::class) + ->factory([Server::class, 'make']) + ->call('withLogger', [service($config['logger'])]) + ->call('withServerInfo', [$config['server_info']['name'], $config['server_info']['version']]) + + ->set('mcp_server.server', Server::class) + ->factory([service('mcp_server.server_builder'), 'build']) + ->alias(Server::class, 'mcp_server.server') + + ->set('mcp_server.command.server_start', McpServerStartCommand::class) + ->args([service('mcp_server.server')]) + ->tag('console.command'); + + $builder->registerForAutoconfiguration(McpElementInterface::class)->addTag('mcp_server.server_element'); + } + + public function build(ContainerBuilder $container): void + { + parent::build($container); + + $container->addCompilerPass(new McpServerPass()); + } +} diff --git a/src/Contracts/McpElementInterface.php b/src/Contracts/McpElementInterface.php new file mode 100644 index 0000000..d1cc5bb --- /dev/null +++ b/src/Contracts/McpElementInterface.php @@ -0,0 +1,9 @@ +hasDefinition($id))->toBeTrue(); +} + +function expectHasAlias(ContainerBuilder $container, string $id): void +{ + expect($container->hasAlias($id))->toBeTrue(); +} + +#[McpPrompt(name: 'dummy_class_prompt', description: 'A dummy class prompt')] +class DummyClassPrompt implements McpElementInterface +{ + public function __invoke(): void {} +} + +class DummyMethodPrompt implements McpElementInterface +{ + #[McpPrompt(name: 'dummy_method_prompt', description: 'A dummy method prompt')] + public function prompt(): void {} +} + +#[McpResource(uri: 'file:///dummy/class/resource.pdf', name: 'dummy_class_resource', description: 'A dummy class resource')] +class DummyClassResoure implements McpElementInterface +{ + public function __invoke(): void {} +} + +class DummyMethodResource implements McpElementInterface +{ + #[McpResource(uri: 'file:///dummy/method/resource.pdf', name: 'dummy_method_resource', description: 'A dummy method resource')] + public function resource(): void {} +} + +#[McpResourceTemplate(uriTemplate: 'file:///home/{user}/class/resource-template', name: 'dummy_class_resource_template', description: 'A dummy class resource template')] +class DummyClassResoureTemplate implements McpElementInterface +{ + public function __invoke(): void {} +} + +class DummyMethodResourceTemplate implements McpElementInterface +{ + #[McpResourceTemplate(uriTemplate: 'file:///home/{user}/method/resource-template', name: 'dummy_method_resource_template', description: 'A dummy method resource template')] + public function resource(): void {} +} + +#[McpTool(name: 'dummy_class_tool', description: 'A dummy class tool')] +class DummyClassTool implements McpElementInterface +{ + public function __invoke(): void {} +} + +class DummyMethodTool implements McpElementInterface +{ + #[McpTool(name: 'dummy_method_tool', description: 'A dummy method tool')] + public function resource(): void {} +} + +class DummyElement implements McpElementInterface +{ + #[McpPrompt(name: 'dummy_method_prompt_2', description: 'Another dummy method prompt')] + public function prompt(): void {} + + #[McpResource(uri: 'file:///dummy/method/resource-2.pdf', name: 'dummy_method_resource_2', description: 'Another dummy method resource')] + public function resource(): void {} + + #[McpResourceTemplate(uriTemplate: 'file:///home/{user}/method/resource-template-2', name: 'dummy_method_resource_template_2', description: 'Another dummy method resource template')] + public function resourceTemplate(): void {} + + #[McpTool(name: 'dummy_method_tool_2', description: 'Another dummy method tool')] + public function tool(): void {} +} + +it('loads bundle config', function () { + $container = new ContainerBuilder(); + $container->setDefinition('logger', new Definition(NullLogger::class)); + foreach ([ + DummyClassPrompt::class, + DummyMethodPrompt::class, + DummyClassResoure::class, + DummyMethodResource::class, + DummyClassResoureTemplate::class, + DummyMethodResourceTemplate::class, + DummyClassTool::class, + DummyMethodTool::class, + DummyElement::class, + ] as $element) { + $container->setDefinition($element, (new Definition($element))->addTag('mcp_server.server_element')); + } + $container->setParameter('kernel.environment', 'test'); + $container->setParameter('kernel.build_dir', ''); + + $bundle = new McpServerBundle(); + $bundle->getContainerExtension()->load([[ + 'logger' => 'logger', + 'server_info' => [ + 'name' => 'TestServer', + 'version' => '1.0.0', + ], + ]], $container); + + (new McpServerPass())->process($container); + + expectHasDefinition($container, 'mcp_server.server'); + expectHasAlias($container, Server::class); + expectHasDefinition($container, 'mcp_server.command.server_start'); + + $server = $container->get('mcp_server.server'); + + expect($server->getConfiguration()->serverName)->toBe('TestServer'); + expect($server->getConfiguration()->serverVersion)->toBe('1.0.0'); + + foreach (['dummy_class_prompt', 'dummy_method_prompt', 'dummy_method_prompt_2'] as $name) { + expect($server->getRegistry()->findPrompt($name))->toBeInstanceOf(PromptDefinition::class); + } + foreach (['file:///dummy/class/resource.pdf', 'file:///dummy/method/resource.pdf', 'file:///dummy/method/resource-2.pdf'] as $name) { + expect($server->getRegistry()->findResourceByUri($name))->toBeInstanceOf(ResourceDefinition::class); + } + foreach (['file:///home/{user}/class/resource-template', 'file:///home/{user}/method/resource-template', 'file:///home/{user}/method/resource-template-2'] as $name) { + expect($server->getRegistry()->findResourceTemplateByUri($name))->toBeArray(); + } + foreach (['dummy_class_tool', 'dummy_method_tool', 'dummy_method_tool_2'] as $name) { + expect($server->getRegistry()->findTool($name))->toBeInstanceOf(ToolDefinition::class); + } +});