Skip to content

Commit a726559

Browse files
author
klapaudius
committed
#16 Add Symfony Cache as an alternative SSE adapter
Introduced a new adapter leveraging Symfony Cache for handling SSE messages as an alternative to Redis. Updated configuration, README, and tests to support the `cache` adapter, providing more flexibility for environments without Redis. Implemented comprehensive unit tests for the new CachePoolAdapter.
1 parent 9ab8c27 commit a726559

File tree

11 files changed

+909
-28
lines changed

11 files changed

+909
-28
lines changed

README.md

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,10 @@
77
<p align="center">
88
<a href="https://github.com/klapaudius/symfony-mcp-server/actions"><img src="https://github.com/klapaudius/symfony-mcp-server/actions/workflows/tests.yml/badge.svg" alt="Build Status"></a>
99
<a href="https://codecov.io/gh/klapaudius/symfony-mcp-server" > <img src="https://codecov.io/gh/klapaudius/symfony-mcp-server/graph/badge.svg?token=5FXOJVXPZ1" alt="Coverage"/></a>
10+
<a href="https://packagist.org/packages/klapaudius/symfony-mcp-server"><img src="https://img.shields.io/packagist/l/klapaudius/symfony-mcp-server" alt="License"></a>
1011

11-
[//]: # (<a href="https://packagist.org/packages/klapaudius/symfony-mcp-server"><img src="https://img.shields.io/packagist/dt/klapaudius/symfony-mcp-server" alt="Total Downloads"></a>)
1212
[//]: # (<a href="https://packagist.org/packages/klapaudius/symfony-mcp-server"><img src="https://img.shields.io/packagist/v/klapaudius/symfony-mcp-server" alt="Latest Stable Version"></a>)
13-
[//]: # (<a href="https://packagist.org/packages/klapaudius/symfony-mcp-server"><img src="https://img.shields.io/packagist/l/klapaudius/symfony-mcp-server" alt="License"></a>)
13+
[//]: # (<a href="https://packagist.org/packages/klapaudius/symfony-mcp-server"><img src="https://img.shields.io/packagist/dt/klapaudius/symfony-mcp-server" alt="Total Downloads"></a>)
1414
</p>
1515

1616
## Overview
@@ -41,7 +41,7 @@ Key benefits:
4141

4242
- Real-time communication support through Server-Sent Events (SSE) integration specified in the MCP 2024-11-05 version (Streamable HTTP from 2025-03-26 version is planned)
4343
- Implementation of tools and resources compliant with Model Context Protocol specifications
44-
- Adapter-based design architecture with Pub/Sub messaging pattern (starting with Redis, more adapters planned)
44+
- Adapter-based design architecture with Pub/Sub messaging pattern
4545

4646
## Requirements
4747

@@ -63,11 +63,10 @@ Key benefits:
6363
enabled: true # Read the warning section in the default configuration file before disable it
6464
interval: 30
6565
server_provider: 'sse'
66-
sse_adapter: 'redis'
66+
sse_adapter: 'cache'
6767
adapters:
68-
redis:
68+
cache:
6969
prefix: 'mcp_sse_'
70-
host: 'localhost' # Change it as needed
7170
ttl: 100
7271
tools:
7372
- KLP\KlpMcpServer\Services\ToolService\Examples\HelloWorldTool
@@ -138,7 +137,7 @@ php bin/console mcp:test-tool MyCustomTool
138137
php bin/console mcp:test-tool --list
139138
140139
# Test with specific JSON input
141-
php bin/console mcp:test-tool MyCustomTool --input='{"param":"value"}'
140+
php bin/console mcp:test-tool MyCustomTool --input='{"param1":"value"}'
142141
```
143142

144143
This helps you rapidly develop and debug tools by:
@@ -177,7 +176,7 @@ The package implements a publish/subscribe (pub/sub) messaging pattern through i
177176

178177
1. **Publisher (Server)**: When clients send requests to the `/messages` endpoint, the server processes these requests and publishes responses through the configured adapter.
179178

180-
2. **Message Broker (Adapter)**: The adapter (e.g., Redis) maintains message queues for each client, identified by unique client IDs. This provides a reliable asynchronous communication layer.
179+
2. **Message Broker (Adapter)**: The adapter maintains message queues for each client, identified by unique client IDs. This provides a reliable asynchronous communication layer.
181180

182181
3. **Subscriber (SSE Connection)**: Long-lived SSE connections subscribe to messages for their respective clients and deliver them in real-time.
183182

@@ -188,9 +187,9 @@ This architecture enables:
188187
- Efficient handling of multiple concurrent client connections
189188
- Potential for distributed server deployments
190189

191-
### Redis Adapter Configuration
190+
### Redis Adapter Configuration (Optional)
192191

193-
The default Redis adapter can be configured as follows:
192+
A Redis adapter can be configured as follows:
194193

195194

196195
```yaml
@@ -219,6 +218,7 @@ Basic implementation of the Model Context Protocol (MCP) server using Server-Sen
219218
- **Core Features:**
220219
- **Refactoring:** Refactor `TestMcpToolCommand` to reduce technical debt and improve code maintainability.
221220
- **Testing Enhancements:** Enhance test coverage to achieve an acceptable and robust ratio, ensuring reliability and stability.
221+
- **New Adapter**: Symfony Cache adpater for Pub/Sub messaging pattern
222222
- **Documentation:**
223223
- **Examples and Use Cases:** Include additional examples and use cases to illustrate practical applications and best practices.
224224

composer.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,8 @@
88
"require": {
99
"php": "^8.2",
1010
"ext-json": "*",
11-
"ext-redis": "*",
1211
"psr/log": "^3.0",
12+
"symfony/cache": "^7.2",
1313
"symfony/config": "~7.0",
1414
"symfony/console": "~7.0",
1515
"symfony/dependency-injection": "~7.0",
@@ -24,6 +24,7 @@
2424
"phpstan/phpstan-phpunit": "^1.3||^2.0"
2525
},
2626
"suggest": {
27+
"ext-redis": "To enable redis SSE Adapter",
2728
"klapaudius/oauth-server-bundle": "For a complete OAuth2 Authentication mechanism"
2829
},
2930
"autoload": {

src/DependencyInjection/Configuration.php

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ public function getConfigTreeBuilder(): TreeBuilder
1111
{
1212
$treeBuilder = new TreeBuilder('klp_mcp_server');
1313
$rootNode = $treeBuilder->getRootNode();
14-
$supportedAdapters = ['redis'];
14+
$supportedAdapters = ['cache', 'redis'];
1515
$supportedAdaptersServices = array_map(function ($item) {
1616
return $item;
1717
}, $supportedAdapters);
@@ -67,7 +67,7 @@ public function getConfigTreeBuilder(): TreeBuilder
6767

6868
// SSE Adapter
6969
->scalarNode('sse_adapter')
70-
->defaultValue('redis')
70+
->defaultValue('cache')
7171
->cannotBeEmpty()
7272
->validate()
7373
->ifNotInArray($supportedAdaptersServices)
@@ -77,7 +77,7 @@ public function getConfigTreeBuilder(): TreeBuilder
7777

7878
// Adapters for SSE
7979
->arrayNode('adapters')
80-
->useAttributeAsKey('name') // Allows keys like "in_memory" and "redis"
80+
->useAttributeAsKey('name') // Allows keys like "cache" and "redis"
8181
->arrayPrototype()
8282
->children()
8383
->scalarNode('prefix')

src/DependencyInjection/KlpMcpServerExtension.php

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,10 +22,12 @@ public function load(array $configs, ContainerBuilder $container): void
2222
$container->setParameter('klp_mcp_server.ping.enabled', $config['ping']['enabled']);
2323
$container->setParameter('klp_mcp_server.ping.interval', $config['ping']['interval']);
2424
$container->setParameter('klp_mcp_server.provider', 'klp_mcp_server.provider.'.$config['server_provider']);
25-
$container->setParameter('klp_mcp_server.adapter', 'klp_mcp_server.adapter.'.$config['sse_adapter']);
25+
$container->setParameter('klp_mcp_server.sse_adapter', $config['sse_adapter']);
2626
$container->setParameter('klp_mcp_server.adapters.redis.prefix', $config['adapters']['redis']['prefix'] ?? 'mcp_sse');
2727
$container->setParameter('klp_mcp_server.adapters.redis.host', $config['adapters']['redis']['host'] ?? 'default');
2828
$container->setParameter('klp_mcp_server.adapters.redis.ttl', $config['adapters']['redis']['ttl'] ?? 100);
29+
$container->setParameter('klp_mcp_server.adapters.cache.prefix', $config['adapters']['cache']['prefix'] ?? 'mcp_sse');
30+
$container->setParameter('klp_mcp_server.adapters.cache.ttl', $config['adapters']['cache']['ttl'] ?? 100);
2931
$container->setParameter('klp_mcp_server.tools', $config['tools']);
3032
}
3133
}

src/Resources/config/packages/klp_mcp_server.yaml

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,8 +49,13 @@ klp_mcp_server:
4949
# The Redis adapter uses Redis lists as message queues, with each client
5050
# having its own queue identified by a unique client ID. This enables
5151
# efficient and scalable real-time communication in distributed environments
52-
sse_adapter: 'redis'
52+
# If a Redis server is not an option, the 'cache' adapter can be utilized
53+
# as an alternative
54+
sse_adapter: 'redis' # Or 'cache'
5355
adapters:
56+
cache:
57+
prefix: 'mcp_sse_' # Prefix for cache item keys
58+
ttl: 100 # Message TTL in seconds
5459
redis:
5560
prefix: 'mcp_sse_' # Prefix for Redis keys
5661
host: 'localhost' # Change it as needed

src/Resources/config/services.xml

Lines changed: 8 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -27,18 +27,17 @@
2727
<tag name="console.command" />
2828
</service>
2929

30-
<service id="klp_mcp_server.adapter.default" alias="klp_mcp_server.adapter.redis" />
31-
<service id="klp_mcp_server.adapter.redis" class="KLP\KlpMcpServer\Transports\SseAdapters\RedisAdapter">
32-
<argument type="collection">
33-
<argument key="prefix">%klp_mcp_server.adapters.redis.prefix%</argument>
34-
<argument key="host">%klp_mcp_server.adapters.redis.host%</argument>
35-
<argument key="ttl">%klp_mcp_server.adapters.redis.ttl%</argument>
36-
</argument>
37-
<argument type="service" id="logger" on-invalid="null" />
30+
<service id="klp_mcp_server.adapter.factory" class="KLP\KlpMcpServer\Transports\SseAdapters\SseAdapterFactory">
31+
<argument type="service" id="service_container" />
3832
</service>
33+
34+
<service id="klp_mcp_server.adapter" class="KLP\KlpMcpServer\Transports\SseAdapters\SseAdapterInterface">
35+
<factory service="klp_mcp_server.adapter.factory" method="create" />
36+
</service>
37+
3938
<service id="klp_mcp_server.transport.sse" class="KLP\KlpMcpServer\Transports\SseTransport">
4039
<argument type="string">%klp_mcp_server.default_path%</argument>
41-
<argument type="service" id="klp_mcp_server.adapter.redis" on-invalid="null" />
40+
<argument type="service" id="klp_mcp_server.adapter" on-invalid="null" />
4241
<argument type="service" id="logger" on-invalid="null" />
4342
<argument type="binary">%klp_mcp_server.ping.enabled%</argument>
4443
<argument type="string">%klp_mcp_server.ping.interval%</argument>
Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
<?php
2+
3+
namespace KLP\KlpMcpServer\Transports\SseAdapters;
4+
5+
use Psr\Cache\CacheItemPoolInterface;
6+
use Psr\Cache\InvalidArgumentException;
7+
use Psr\Log\LoggerInterface;
8+
9+
class CachePoolAdapter implements SseAdapterInterface
10+
{
11+
private const DEFAULT_PREFIX = 'mcp_sse_';
12+
13+
private const DEFAULT_MESSAGE_TTL = 100;
14+
15+
/**
16+
* Key prefix for SSE messages
17+
*/
18+
private string $keyPrefix;
19+
20+
/**
21+
* Message expiration time in seconds
22+
*/
23+
private int $messageTtl;
24+
25+
public function __construct(
26+
private readonly array $config,
27+
private readonly CacheItemPoolInterface $cache,
28+
private readonly ?LoggerInterface $logger
29+
) {
30+
$this->keyPrefix = $this->config['prefix'] ?? self::DEFAULT_PREFIX;
31+
$this->messageTtl = (int) ($this->config['ttl'] ?? self::DEFAULT_MESSAGE_TTL);
32+
}
33+
34+
/**
35+
* Get the Redis key for a client's message queue
36+
*
37+
* @param string $clientId The client ID
38+
* @return string The Redis key
39+
*/
40+
private function generateQueueKey(string $clientId): string
41+
{
42+
return "$this->keyPrefix|client|$clientId";
43+
}
44+
/**
45+
* @inheritDoc
46+
*/
47+
public function pushMessage(string $clientId, string $message): void
48+
{
49+
try {
50+
$cacheItem = $this->cache->getItem($this->generateQueueKey($clientId));
51+
52+
$messages = $cacheItem->isHit() ? $cacheItem->get() : [] ;
53+
$messages[] = $message;
54+
$cacheItem->set($messages);
55+
$cacheItem->expiresAfter($this->messageTtl);
56+
$this->cache->save($cacheItem);
57+
} catch (InvalidArgumentException $e) {
58+
$this->logger?->error('Failed to add message to cache: '.$e->getMessage());
59+
}
60+
}
61+
62+
/**
63+
* @inheritDoc
64+
*/
65+
public function removeAllMessages(string $clientId): void
66+
{
67+
try {
68+
$this->cache->deleteItem($this->generateQueueKey($clientId));
69+
} catch (InvalidArgumentException $e) {
70+
$this->logger?->error('Failed to remove messages from cache: '.$e->getMessage());
71+
}
72+
}
73+
74+
/**
75+
* @inheritDoc
76+
*/
77+
public function receiveMessages(string $clientId): array
78+
{
79+
return $this->cache->getItem($this->generateQueueKey($clientId))->get() ?? [];
80+
}
81+
82+
/**
83+
* @inheritDoc
84+
*/
85+
public function popMessage(string $clientId): ?string
86+
{
87+
try {
88+
$cacheItem = $this->cache->getItem($this->generateQueueKey($clientId));
89+
$messages = $cacheItem->get() ?? [];
90+
$message = array_shift($messages);
91+
$cacheItem->set($messages);
92+
$cacheItem->expiresAfter($this->messageTtl);
93+
$this->cache->save($cacheItem);
94+
95+
return $message;
96+
} catch (InvalidArgumentException $e) {
97+
$this->logger?->error('Failed to pop message from cache: '.$e->getMessage());
98+
99+
return null;
100+
}
101+
}
102+
103+
/**
104+
* @inheritDoc
105+
*/
106+
public function hasMessages(string $clientId): bool
107+
{
108+
return $this->cache->getItem($this->generateQueueKey($clientId))->isHit();
109+
}
110+
111+
/**
112+
* @inheritDoc
113+
*/
114+
public function getMessageCount(string $clientId): int
115+
{
116+
return count($this->cache->getItem($this->generateQueueKey($clientId))->get() ?? []);
117+
}
118+
119+
/**
120+
* @inheritDoc
121+
*/
122+
public function storeLastPongResponseTimestamp(string $clientId, ?int $timestamp = null): void
123+
{
124+
$cacheItem = $this->cache->getItem($this->generateQueueKey($clientId).'|last_pong');
125+
$cacheItem->set($timestamp ?? time());
126+
$cacheItem->expiresAfter($this->messageTtl);
127+
$this->cache->save($cacheItem);
128+
}
129+
130+
/**
131+
* @inheritDoc
132+
*/
133+
public function getLastPongResponseTimestamp(string $clientId): ?int
134+
{
135+
return $this->cache->getItem($this->generateQueueKey($clientId).'|last_pong')->get();
136+
}
137+
}
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
<?php
2+
3+
namespace KLP\KlpMcpServer\Transports\SseAdapters;
4+
5+
use Psr\Log\LoggerInterface;
6+
use Symfony\Component\DependencyInjection\ContainerInterface;
7+
8+
class SseAdapterFactory
9+
{
10+
private ?\Redis $mockRedis = null;
11+
12+
public function __construct(
13+
private readonly ContainerInterface $container,
14+
private readonly ?LoggerInterface $logger = null
15+
) {}
16+
17+
/**
18+
* Creates and retrieves an instance of SseAdapterInterface.
19+
*
20+
* @return SseAdapterInterface Returns an instance of SseAdapterInterface if the retrieved adapter is valid.
21+
* @throws SseAdapterException Throws an exception if the retrieved adapter is not a valid instance of SseAdapterInterface.
22+
*/
23+
public function create(): SseAdapterInterface
24+
{
25+
return match ($this->container->getParameter('klp_mcp_server.sse_adapter')) {
26+
'redis' => $this->createRedisAdapter(),
27+
'cache' => $this->createCachePoolAdapter(),
28+
default => throw new SseAdapterException('Invalid adapter')
29+
};
30+
}
31+
32+
private function createCachePoolAdapter(): CachePoolAdapter
33+
{
34+
return new CachePoolAdapter([
35+
'prefix' => $this->container->getParameter('klp_mcp_server.adapters.cache.prefix'),
36+
'ttl' => $this->container->getParameter('klp_mcp_server.adapters.cache.ttl'),
37+
],
38+
$this->container->get('cache.app'),
39+
$this->logger
40+
);
41+
}
42+
43+
private function createRedisAdapter(): RedisAdapter
44+
{
45+
return new RedisAdapter(
46+
[
47+
'prefix' => $this->container->getParameter('klp_mcp_server.adapters.redis.prefix'),
48+
'host' => $this->container->getParameter('klp_mcp_server.adapters.redis.host'),
49+
'ttl' => $this->container->getParameter('klp_mcp_server.adapters.redis.ttl'),
50+
],
51+
$this->logger,
52+
$this->mockRedis
53+
);
54+
}
55+
}

0 commit comments

Comments
 (0)