Skip to content

Commit 9a7fea0

Browse files
[Server] Add Psr6SessionStore and Psr6NotificationBus (#635)
They work like their PSR-16 siblings over a PSR-6 cache pool. `Builder::setDiscovery()` also accepts a PSR-6 pool. The sequence counter of `Psr16NotificationBus` no longer expires, so that its subscribers do not miss the notifications published after a quiet period. Both notification buses read their backlog with one `getMultiple()` or `getItems()` call, not one call per entry.
1 parent 9bbb82e commit 9a7fea0

16 files changed

Lines changed: 438 additions & 36 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ All notable changes to `mcp/sdk` will be documented in this file.
1010
* [BC Break] `DocBlockParser::parseDocBlock()` no longer accepts `null`; pass `ReflectionMethod::getDocComment()` as is
1111
* Fix tools with an array shape parameter whose keys are nullable or unions (e.g. `array{name: string, nickname: string|null}`) rejecting valid arguments with `-32602`: a `null` inside the shape no longer turns the parameter into `array|null`, and such keys get their own types instead of `object`, including lists and nested shapes (`string[]|null`, `array{x: int}|null`).
1212
* Fix `array<string|null>` and other generics with a union element getting `items` of type `object`. A `null` inside a generic no longer makes the parameter nullable, and neither does a DocBlock class whose name contains `null`.
13+
* Fix `Psr16NotificationBus` subscribers missing the notifications published after the bus stayed quiet for longer than its TTL: the sequence counter no longer expires, and a subscriber whose cursor is ahead of a lost counter reads what the bus holds.
14+
* Add `Psr6SessionStore` and `Psr6NotificationBus`, which work like `Psr16SessionStore` and `Psr16NotificationBus` over a PSR-6 cache pool. `Builder::setDiscovery()` also accepts a PSR-6 pool as `$cache`.
1315

1416
### Client
1517

‎docs/get-started/installation.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ Which extras you need depends on what you build:
2727
| emit the response from a standalone HTTP entry point | `laminas/laminas-httphandlerrunner` |
2828
| [connect a client over HTTP](../client/transports.md) | any PSR-18 client, e.g. `symfony/http-client` |
2929
| [validate JWT access tokens](../run/authorization.md) | `firebase/php-jwt` |
30-
| store sessions, cache discovery or share notifications through a PSR-16 cache | `psr/simple-cache` implementation, e.g. `symfony/cache` |
30+
| store sessions, cache discovery or share notifications through a PSR-6 or PSR-16 cache | `psr/cache` or `psr/simple-cache` implementation, e.g. `symfony/cache` |
3131
| [fetch JWT keys from an issuer](../run/authorization.md) with `JwtTokenValidator::fromIssuer()` | `psr/cache` (PSR-6) implementation, e.g. `symfony/cache` |
3232

3333
PSR-17 and PSR-18 implementations are found through

‎docs/run/server-builder.md‎

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -168,7 +168,7 @@ See [First server](../get-started/first-server.md) for the install command.
168168
- `$basePath` (string): Base directory for discovery (typically `__DIR__`)
169169
- `$scanDirs` (array): Directories to recursively scan for `#[McpTool]`, `#[McpResource]`, etc. All subdirectories are included. (default: `['.', 'src']`)
170170
- `$excludeDirs` (array): Directory names to exclude **within** the scanned directories during recursive scanning
171-
- `$cache` (CacheInterface|null): Optional PSR-16 cache to store discovered elements for performance
171+
- `$cache` (CacheInterface|CacheItemPoolInterface|null): Optional PSR-16 cache or PSR-6 pool to store discovered elements for performance
172172
- `$namePatterns` (array): Optional list of patterns (regexp, glob, or string) for file names (default: `['*.php']`)
173173

174174
**Basic Discovery (scans current directory and `src/`):**
@@ -187,10 +187,9 @@ $server = Server::builder()
187187
```php
188188
use Mcp\Server;
189189
use Symfony\Component\Cache\Adapter\FilesystemAdapter;
190-
use Symfony\Component\Cache\Psr16Cache;
191190

192191
// Cache discovered elements to avoid filesystem scanning on every server start
193-
$cache = new Psr16Cache(new FilesystemAdapter('mcp-discovery'));
192+
$cache = new FilesystemAdapter('mcp-discovery');
194193

195194
$server = Server::builder()
196195
->setDiscovery(
@@ -274,15 +273,14 @@ use Mcp\Server;
274273
use Mcp\Server\Session\FileSessionStore;
275274
use Mcp\Capability\Registry\Container;
276275
use Symfony\Component\Cache\Adapter\FilesystemAdapter;
277-
use Symfony\Component\Cache\Psr16Cache;
278276
use Monolog\Logger;
279277
use Monolog\Handler\StreamHandler;
280278

281279
// Setup dependencies
282280
$logger = new Logger('mcp-server');
283281
$logger->pushHandler(new StreamHandler('mcp.log', Logger::INFO));
284282

285-
$cache = new Psr16Cache(new FilesystemAdapter('mcp-discovery'));
283+
$cache = new FilesystemAdapter('mcp-discovery');
286284
$sessionStore = new FileSessionStore(__DIR__ . '/sessions');
287285

288286
// Setup container with dependencies

‎docs/run/sessions.md‎

Lines changed: 21 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ use Mcp\Server;
1212
use Mcp\Server\Session\FileSessionStore;
1313
use Mcp\Server\Session\InMemorySessionStore;
1414
use Mcp\Server\Session\Psr16SessionStore;
15+
use Mcp\Server\Session\Psr6SessionStore;
1516
use Symfony\Component\Cache\Psr16Cache;
1617
use Symfony\Component\Cache\Adapter\RedisAdapter;
1718

@@ -25,34 +26,40 @@ $server = Server::builder()
2526
->setSession(new InMemorySessionStore(3600))
2627
->build();
2728

28-
// Override with PSR-16 cache-based storage
29-
// Requires psr/simple-cache and symfony/cache (or any other PSR-16 implementation)
30-
// composer require psr/simple-cache symfony/cache
29+
// Override with PSR-6 cache-based storage
30+
// Requires symfony/cache (or any other PSR-6 implementation)
31+
// composer require symfony/cache
3132
$redisAdapter = new RedisAdapter(
3233
RedisAdapter::createConnection('redis://localhost:6379'),
3334
'mcp_sessions'
3435
);
3536

3637
$server = Server::builder()
37-
->setSession(new Psr16SessionStore(
38-
cache: new Psr16Cache($redisAdapter),
38+
->setSession(new Psr6SessionStore(
39+
cache: $redisAdapter,
3940
prefix: 'mcp-',
4041
ttl: 3600
4142
))
4243
->build();
44+
45+
// Override with PSR-16 cache-based storage, which takes the same arguments
46+
// Requires psr/simple-cache and a PSR-16 implementation
47+
$server = Server::builder()
48+
->setSession(new Psr16SessionStore(new Psr16Cache($redisAdapter)))
49+
->build();
4350
```
4451

4552
## Sessions over HTTP
4653

4754
With HTTP, PHP often serves each request in a different process (e.g. PHP-FPM workers).
4855
`InMemorySessionStore` keeps sessions in the memory of one process, so the next request
4956
doesn't find them. Use a store that all processes share, like `FileSessionStore` on a shared
50-
directory or `Psr16SessionStore` with a shared cache such as Redis.
57+
directory or `Psr6SessionStore` (or `Psr16SessionStore`) with a shared cache such as Redis.
5158

5259
`FileSessionStore` suits a single host. Once requests are spread over several hosts, or sessions number in the
53-
tens of thousands, use `Psr16SessionStore` with Redis or a similar shared cache: it is shared across hosts,
54-
expires sessions on its own and needs no garbage collection (see below). A network-mounted directory adds latency and
55-
makes session availability depend on the shared filesystem.
60+
tens of thousands, use `Psr6SessionStore` or `Psr16SessionStore` with Redis or a similar shared cache: it is shared
61+
across hosts, expires sessions on its own and needs no garbage collection (see below). A network-mounted directory adds
62+
latency and makes session availability depend on the shared filesystem.
5663

5764
## Garbage Collection Configuration
5865

@@ -113,7 +120,8 @@ $server = Server::builder()
113120
->build();
114121
```
115122

116-
`Psr16SessionStore` doesn't scan anything: it stores each session with the TTL, and its `gc()` does nothing.
123+
`Psr6SessionStore` and `Psr16SessionStore` don't scan anything. They store each session with the TTL, and their `gc()`
124+
does nothing.
117125
With a cache like Redis, the cache expires the keys itself.
118126

119127
**Parameters:**
@@ -153,17 +161,16 @@ For a store shared across machines, use `SymfonyLockSessionLock` with any
153161
[symfony/lock](https://symfony.com/doc/current/components/lock.html) store (`composer require symfony/lock`):
154162

155163
```php
156-
use Mcp\Server\Session\Psr16SessionStore;
164+
use Mcp\Server\Session\Psr6SessionStore;
157165
use Mcp\Server\Session\SymfonyLockSessionLock;
158166
use Symfony\Component\Cache\Adapter\RedisAdapter;
159-
use Symfony\Component\Cache\Psr16Cache;
160167
use Symfony\Component\Lock\LockFactory;
161168
use Symfony\Component\Lock\Store\RedisStore;
162169

163170
$redis = RedisAdapter::createConnection('redis://localhost:6379');
164171

165172
$server = Server::builder()
166-
->setSession(new Psr16SessionStore(new Psr16Cache(new RedisAdapter($redis, 'mcp_sessions'))))
173+
->setSession(new Psr6SessionStore(new RedisAdapter($redis, 'mcp_sessions')))
167174
->setSessionLock(new SymfonyLockSessionLock(new LockFactory(new RedisStore($redis))))
168175
->build();
169176
```
@@ -200,6 +207,7 @@ right now and returns `false` otherwise; `release()` gives it back.
200207

201208
- `InMemorySessionStore`: Fast in-memory storage (default)
202209
- `FileSessionStore`: Persistent file-based storage
210+
- `Psr6SessionStore`: PSR-6 compliant cache-based storage
203211
- `Psr16SessionStore`: PSR-16 compliant cache-based storage
204212

205213
**Custom Session Stores:**

‎docs/run/subscriptions.md‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,12 +19,14 @@ open are often not the same one:
1919
```php
2020
use Mcp\Server\Subscription\InMemoryNotificationBus;
2121
use Mcp\Server\Subscription\Psr16NotificationBus;
22+
use Mcp\Server\Subscription\Psr6NotificationBus;
2223

2324
// stdio, or a persistent runtime where the whole server is one process
2425
->setNotificationBus(new InMemoryNotificationBus())
2526

2627
// PHP-FPM: the publisher and the stream are different workers
27-
->setNotificationBus(new Psr16NotificationBus($cache))
28+
->setNotificationBus(new Psr6NotificationBus($cachePool)) // a PSR-6 pool
29+
->setNotificationBus(new Psr16NotificationBus($cache)) // or a PSR-16 cache
2830
```
2931

3032
Registry changes (`registerTool()`, `unregisterPrompt()`, …) are published automatically, with or

‎examples/server/stateless-lifecycle/server.php‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -92,8 +92,8 @@
9292

9393
// Carries change notifications to open `subscriptions/listen` streams.
9494
// In-memory suits `php -S` and worker runtimes; under PHP-FPM use
95-
// Psr16NotificationBus, since the publisher and the stream are different
96-
// processes there.
95+
// Psr6NotificationBus or Psr16NotificationBus, since the publisher and the
96+
// stream are different processes there.
9797
->setNotificationBus($bus)
9898
->setSubscriptionLifetime(20.0)
9999

‎src/Capability/Discovery/CachedDiscoverer.php‎

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111

1212
namespace Mcp\Capability\Discovery;
1313

14+
use Psr\Cache\CacheItemPoolInterface;
1415
use Psr\Log\LoggerInterface;
1516
use Psr\SimpleCache\CacheInterface;
1617

@@ -30,7 +31,7 @@ final class CachedDiscoverer implements DiscovererInterface
3031

3132
public function __construct(
3233
private readonly DiscovererInterface $discoverer,
33-
private readonly CacheInterface $cache,
34+
private readonly CacheInterface|CacheItemPoolInterface $cache,
3435
private readonly LoggerInterface $logger,
3536
) {
3637
}
@@ -47,7 +48,13 @@ public function discover(string $basePath, array $directories, array $excludeDir
4748
{
4849
$cacheKey = $this->generateCacheKey($basePath, $directories, $excludeDirs);
4950

50-
$cachedResult = $this->cache->get($cacheKey);
51+
if ($this->cache instanceof CacheItemPoolInterface) {
52+
$item = $this->cache->getItem($cacheKey);
53+
$cachedResult = $item->get();
54+
} else {
55+
$cachedResult = $this->cache->get($cacheKey);
56+
}
57+
5158
if (null !== $cachedResult) {
5259
$this->logger->debug('Using cached discovery results', [
5360
'cache_key' => $cacheKey,
@@ -66,7 +73,11 @@ public function discover(string $basePath, array $directories, array $excludeDir
6673

6774
$discoveryState = $this->discoverer->discover($basePath, $directories, $excludeDirs, $namePatterns);
6875

69-
$this->cache->set($cacheKey, $discoveryState);
76+
if ($this->cache instanceof CacheItemPoolInterface) {
77+
$this->cache->save($item->set($discoveryState));
78+
} else {
79+
$this->cache->set($cacheKey, $discoveryState);
80+
}
7081

7182
return $discoveryState;
7283
}

‎src/Server/Builder.php‎

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -64,8 +64,10 @@
6464
use Mcp\Server\Subscription\InMemoryNotificationBus;
6565
use Mcp\Server\Subscription\NotificationBusInterface;
6666
use Mcp\Server\Subscription\Psr16NotificationBus;
67+
use Mcp\Server\Subscription\Psr6NotificationBus;
6768
use Mcp\Server\Subscription\PublishingEventDispatcher;
6869
use Mcp\Server\Wire\CachePolicy;
70+
use Psr\Cache\CacheItemPoolInterface;
6971
use Psr\Container\ContainerInterface;
7072
use Psr\EventDispatcher\EventDispatcherInterface;
7173
use Psr\Log\LoggerInterface;
@@ -99,7 +101,7 @@ final class Builder
99101

100102
private ?LoggerInterface $logger = null;
101103

102-
private ?CacheInterface $discoveryCache = null;
104+
private CacheInterface|CacheItemPoolInterface|null $discoveryCache = null;
103105

104106
private ?EventDispatcherInterface $eventDispatcher = null;
105107

@@ -311,7 +313,8 @@ public function setServerInfo(
311313
* the publisher and the stream share a process.
312314
* {@see InMemoryNotificationBus} is correct for stdio and persistent
313315
* runtimes; under PHP-FPM, where they are different workers, use
314-
* {@see Psr16NotificationBus} or an implementation over your own broker.
316+
* {@see Psr6NotificationBus}, {@see Psr16NotificationBus} or an
317+
* implementation over your own broker.
315318
*
316319
* With the built-in registry, registry changes are published automatically,
317320
* with or without an event dispatcher, and `listChanged` is announced. A
@@ -669,7 +672,7 @@ public function setDiscovery(
669672
string $basePath,
670673
array $scanDirs = ['.', 'src'],
671674
array $excludeDirs = [],
672-
?CacheInterface $cache = null,
675+
CacheInterface|CacheItemPoolInterface|null $cache = null,
673676
array $namePatterns = DiscovererInterface::DEFAULT_NAME_PATERNS,
674677
): self {
675678
$this->discoveryBasePath = $basePath;
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the official PHP MCP SDK.
5+
*
6+
* A collaboration between Symfony and the PHP Foundation.
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Mcp\Server\Session;
13+
14+
use Psr\Cache\CacheItemPoolInterface;
15+
use Symfony\Component\Uid\Uuid;
16+
17+
/**
18+
* Session store over any PSR-6 cache pool.
19+
*
20+
* Each session is stored under the prefix followed by the session id, the same
21+
* key as {@see Psr16SessionStore}.
22+
*
23+
* @author Nicolas Grekas <p@tchwork.com>
24+
*/
25+
final class Psr6SessionStore implements SessionStoreInterface
26+
{
27+
public function __construct(
28+
private readonly CacheItemPoolInterface $cache,
29+
private readonly string $prefix = 'mcp-',
30+
private readonly int $ttl = 3600,
31+
) {
32+
}
33+
34+
public function exists(Uuid $id): bool
35+
{
36+
return $this->cache->hasItem($this->getKey($id));
37+
}
38+
39+
public function read(Uuid $id): string|false
40+
{
41+
return $this->cache->getItem($this->getKey($id))->get() ?? false;
42+
}
43+
44+
public function write(Uuid $id, string $data): bool
45+
{
46+
$item = $this->cache->getItem($this->getKey($id));
47+
48+
return $this->cache->save($item->set($data)->expiresAfter($this->ttl));
49+
}
50+
51+
public function destroy(Uuid $id): bool
52+
{
53+
return $this->cache->deleteItem($this->getKey($id));
54+
}
55+
56+
public function gc(): array
57+
{
58+
return [];
59+
}
60+
61+
private function getKey(Uuid $id): string
62+
{
63+
return $this->prefix.$id;
64+
}
65+
}

‎src/Server/Subscription/InMemoryNotificationBus.php‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,8 @@
2121
* one process (Swoole, FrankenPHP worker mode, ReactPHP). Under PHP-FPM it is
2222
* not: the worker that publishes and the worker holding the stream open are
2323
* different processes, and a notification published in one is invisible to the
24-
* other. {@see Psr16NotificationBus} is the answer there.
24+
* other. {@see Psr6NotificationBus} or {@see Psr16NotificationBus} is the
25+
* answer there.
2526
*
2627
* @author Christopher Hertel <mail@christopher-hertel.de>
2728
*/

0 commit comments

Comments
 (0)