Skip to content

feat: add support for client transports #1

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
Mar 30, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
.PHONY: deps-stable deps-low cs rector phpstan tests coverage run-examples ci ci-stable ci-lowest

deps-stable:
composer update --prefer-stable

deps-low:
composer update --prefer-lowest

cs:
PHP_CS_FIXER_IGNORE_ENV=true vendor/bin/php-cs-fixer fix --diff --verbose

rector:
vendor/bin/rector

phpstan:
vendor/bin/phpstan --memory-limit=-1

ci: ci-stable

ci-stable: deps-stable rector cs phpstan

ci-lowest: deps-low rector cs phpstan
14 changes: 7 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,9 @@ configuration and usable in your chains.

### Act as Server

To use your application as a MCP server, exposing tools to clients like [Claude Desktop](https://claude.ai/download),
you need to configure in the `client` section the transports you want to use. You can use either STDIO or SSE.
To use your application as an MCP server, exposing tools to clients like [Claude Desktop](https://claude.ai/download),
you need to configure in the `client_transports` section the transports you want to expose to clients.
You can use either STDIO or SSE.

## Configuration

Expand All @@ -46,9 +47,8 @@ mcp:
sse:
url: 'http://localhost:8000/sse' # URL to SSE endpoint of MCP server

# Configure this application to act as a MCP server
client:
transports:
stdio: true # Enable STDIO via command
sse: true # Enable Server-Sent Event via controller
# Configure this application to act as an MCP server
client_transports:
stdio: true # Enable STDIO via command
sse: true # Enable Server-Sent Event via controller
```
6 changes: 5 additions & 1 deletion composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,12 @@
"require": {
"php-llm/mcp-sdk": "dev-main",
"symfony/config": "^6.4 || ^7.0",
"symfony/console": "^6.4 || ^7.0",
"symfony/dependency-injection": "^6.4 || ^7.0",
"symfony/framework-bundle": "^6.4 || ^7.0"
"symfony/framework-bundle": "^6.4 || ^7.0",
"symfony/http-foundation": "^6.4 || ^7.0",
"symfony/http-kernel": "^6.4 || ^7.0",
"symfony/routing": "^6.4 || ^7.0"
},
"require-dev": {
"php-cs-fixer/shim": "dev-master",
Expand Down
31 changes: 31 additions & 0 deletions src/Command/McpCommand.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
<?php

declare(strict_types=1);

namespace PhpLlm\McpBundle\Command;

use PhpLlm\McpSdk\Server;
use PhpLlm\McpSdk\Server\Transport\Stdio\SymfonyConsoleTransport;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;

#[AsCommand('mcp:server', 'Starts an MCP server')]
class McpCommand extends Command
{
public function __construct(
private readonly Server $server,
) {
parent::__construct();
}

protected function execute(InputInterface $input, OutputInterface $output): int
{
$this->server->connect(
new SymfonyConsoleTransport($input, $output)
);

return Command::SUCCESS;
}
}
44 changes: 44 additions & 0 deletions src/Controller/McpController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
<?php

declare(strict_types=1);

namespace PhpLlm\McpBundle\Controller;

use PhpLlm\McpSdk\Server;
use PhpLlm\McpSdk\Server\Transport\Sse\Store\CachePoolStore;
use PhpLlm\McpSdk\Server\Transport\Sse\StreamTransport;
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,
) {
}

public function sse(): StreamedResponse
{
$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',
]);
}

public function messages(Request $request, Uuid $id): Response
{
$this->store->push($id, $request->getContent());

return new Response();
}
}
51 changes: 50 additions & 1 deletion src/DependencyInjection/Configuration.php
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,56 @@ public function getConfigTreeBuilder(): TreeBuilder
$treeBuilder = new TreeBuilder('mcp');
$rootNode = $treeBuilder->getRootNode();

//$rootNode
$rootNode
->children()
->scalarNode('app')->defaultValue('app')->end()
->scalarNode('version')->defaultValue('0.0.1')->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()
->arrayNode('client_transports')
->children()
->booleanNode('stdio')->defaultFalse()->end()
->booleanNode('sse')->defaultFalse()->end()
->end()
->end()
->end()
;

return $treeBuilder;
}
Expand Down
36 changes: 36 additions & 0 deletions src/DependencyInjection/McpExtension.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@

namespace PhpLlm\McpBundle\DependencyInjection;

use PhpLlm\McpBundle\Command\McpCommand;
use PhpLlm\McpBundle\Controller\McpController;
use PhpLlm\McpBundle\Routing\RouteLoader;
use Symfony\Component\Config\FileLocator;
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\DependencyInjection\Extension\Extension;
Expand All @@ -19,5 +22,38 @@ public function load(array $configs, ContainerBuilder $container): void
$configuration = new Configuration();
$config = $this->processConfiguration($configuration, $configs);

$container->setParameter('mcp.app', $config['app']);
$container->setParameter('mcp.version', $config['version']);

if (isset($config['client_transports'])) {
$this->configureClient($config['client_transports'], $container);
}
}

/**
* @param array{stdio: bool, sse: bool} $transports
*/
private function configureClient(array $transports, ContainerBuilder $container): void
{
if (!$transports['stdio'] && !$transports['sse']) {
return;
}

if ($transports['stdio']) {
$container->register('mcp.server.command', McpCommand::class)
->setAutowired(true)
->addTag('console.command');
}

if ($transports['sse']) {
$container->register('mcp.server.controller', McpController::class)
->setAutowired(true)
->setPublic(true)
->addTag('controller.service_arguments');
}

$container->register('mcp.server.route_loader', RouteLoader::class)
->setArgument('$sseTransportEnabled', $transports['sse'])
->addTag('routing.route_loader');
}
}
15 changes: 15 additions & 0 deletions src/Resources/config/routes.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
<?php

use PhpLlm\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'])
;
};
53 changes: 53 additions & 0 deletions src/Resources/config/services.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
<?php

declare(strict_types=1);

namespace Symfony\Component\DependencyInjection\Loader\Configurator;

use PhpLlm\McpSdk\Message\Factory;
use PhpLlm\McpSdk\Server;
use PhpLlm\McpSdk\Server\JsonRpcHandler;
use PhpLlm\McpSdk\Server\NotificationHandler;
use PhpLlm\McpSdk\Server\NotificationHandler\InitializedHandler;
use PhpLlm\McpSdk\Server\RequestHandler;
use PhpLlm\McpSdk\Server\RequestHandler\InitializeHandler;
use PhpLlm\McpSdk\Server\RequestHandler\PingHandler;
use PhpLlm\McpSdk\Server\RequestHandler\ToolCallHandler;
use PhpLlm\McpSdk\Server\RequestHandler\ToolListHandler;
use PhpLlm\McpSdk\Server\Transport\Sse\Store\CachePoolStore;

return static function (ContainerConfigurator $container): void {
$container->services()
->defaults()
->autowire()
->autoconfigure()
->instanceof(NotificationHandler::class)
->tag('mcp.server.notification_handler')
->instanceof(RequestHandler::class)
->tag('mcp.server.request_handler')

->set(InitializedHandler::class)
->set(InitializeHandler::class)
->args([
'$name' => param('mcp.app'),
'$version' => param('mcp.version'),
])
->set(PingHandler::class)
->set(ToolCallHandler::class)
->set(ToolListHandler::class)

->set('mcp.message_factory', Factory::class)
->set('mcp.server.json_rpc', JsonRpcHandler::class)
->args([
'$messageFactory' => service('mcp.message_factory'),
'$requestHandlers' => tagged_iterator('mcp.server.request_handler'),
'$notificationHandlers' => tagged_iterator('mcp.server.notification_handler'),
])
->set('mcp.server', Server::class)
->args([
'$jsonRpcHandler' => service('mcp.server.json_rpc'),
])
->alias(Server::class, 'mcp.server')
->set(CachePoolStore::class)
;
};
9 changes: 0 additions & 9 deletions src/Resources/services.php

This file was deleted.

30 changes: 30 additions & 0 deletions src/Routing/RouteLoader.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
<?php

declare(strict_types=1);

namespace PhpLlm\McpBundle\Routing;

use Symfony\Component\Routing\Route;
use Symfony\Component\Routing\RouteCollection;

final readonly class RouteLoader
{
public function __construct(
private bool $sseTransportEnabled,
) {
}

public function __invoke(): RouteCollection
{
if (!$this->sseTransportEnabled) {
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']));

return $collection;
}
}