Skip to content

Commit 53950d0

Browse files
chr-hertelclaude
andcommitted
Add configurable session garbage collection (gcProbability/gcDivisor)
Make session GC probability configurable via gcProbability and gcDivisor parameters, mirroring PHP's session.gc_probability/session.gc_divisor. Exposed through both SessionManager constructor and Builder::setSession(). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 7af5a34 commit 53950d0

File tree

5 files changed

+155
-7
lines changed

5 files changed

+155
-7
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ All notable changes to `mcp/sdk` will be documented in this file.
1414
* Add OAuth 2.0 Dynamic Client Registration middleware (RFC 7591)
1515
* Add optional `title` field to `Prompt` and `McpPrompt` for MCP spec compliance
1616
* [BC Break] `Builder::addPrompt()` signature changed — `$title` parameter added between `$name` and `$description`. Callers using positional arguments for `$description` must switch to named arguments.
17+
* Add configurable session garbage collection (`gcProbability`/`gcDivisor`)
1718

1819
0.4.0
1920
-----

docs/server-builder.md

Lines changed: 40 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -154,11 +154,6 @@ use Mcp\Server\Session\Psr16SessionStore;
154154
use Symfony\Component\Cache\Psr16Cache;
155155
use Symfony\Component\Cache\Adapter\RedisAdapter;
156156

157-
// Use default in-memory sessions with custom TTL
158-
$server = Server::builder()
159-
->setSession(ttl: 7200) // 2 hours
160-
->build();
161-
162157
// Override with file-based storage
163158
$server = Server::builder()
164159
->setSession(new FileSessionStore(__DIR__ . '/sessions'))
@@ -186,6 +181,45 @@ $server = Server::builder()
186181
->build();
187182
```
188183

184+
### Garbage Collection Configuration
185+
186+
The SDK periodically runs garbage collection to clean up expired sessions, similar to PHP's native
187+
`session.gc_probability` and `session.gc_divisor` settings. The probability that GC runs on any given
188+
request is `gcProbability / gcDivisor`.
189+
190+
```php
191+
// Default: 1/100 (1% chance per request)
192+
$server = Server::builder()
193+
->setSession(new FileSessionStore(__DIR__ . '/sessions'))
194+
->build();
195+
196+
// Higher frequency: 1/10 (10% chance per request)
197+
$server = Server::builder()
198+
->setSession(
199+
new FileSessionStore(__DIR__ . '/sessions'),
200+
gcProbability: 1,
201+
gcDivisor: 10,
202+
)
203+
->build();
204+
205+
// Run GC on every request
206+
$server = Server::builder()
207+
->setSession(gcProbability: 1, gcDivisor: 1)
208+
->build();
209+
210+
// Disable GC entirely (e.g. when using an external cleanup process)
211+
$server = Server::builder()
212+
->setSession(gcProbability: 0)
213+
->build();
214+
```
215+
216+
**Parameters:**
217+
- `$gcProbability` (int): The numerator of the GC probability fraction (default: `1`). Set to `0` to disable GC.
218+
- `$gcDivisor` (int): The denominator of the GC probability fraction (default: `100`). Must be >= 1.
219+
220+
> **Note**: When providing a custom `SessionManagerInterface` via the `$sessionManager` parameter,
221+
> the `gcProbability` and `gcDivisor` settings are ignored — you control GC behavior in your own implementation.
222+
189223
**Available Session Stores:**
190224
- `InMemorySessionStore`: Fast in-memory storage (default)
191225
- `FileSessionStore`: Persistent file-based storage
@@ -570,7 +604,7 @@ $server = Server::builder()
570604
| `setPaginationLimit()` | limit | Set max items per page |
571605
| `setInstructions()` | instructions | Set usage instructions |
572606
| `setDiscovery()` | basePath, scanDirs?, excludeDirs?, cache? | Configure attribute discovery |
573-
| `setSession()` | store?, factory?, ttl? | Configure session management |
607+
| `setSession()` | sessionStore?, sessionManager?, gcProbability?, gcDivisor? | Configure session management |
574608
| `setLogger()` | logger | Set PSR-3 logger |
575609
| `setContainer()` | container | Set PSR-11 container |
576610
| `setEventDispatcher()` | dispatcher | Set PSR-14 event dispatcher |

src/Server/Builder.php

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,10 @@ final class Builder
7979

8080
private ?SessionStoreInterface $sessionStore = null;
8181

82+
private int $gcProbability = 1;
83+
84+
private int $gcDivisor = 100;
85+
8286
private int $paginationLimit = 50;
8387

8488
private ?string $instructions = null;
@@ -330,12 +334,22 @@ public function setResourceSubscriptionManager(SubscriptionManagerInterface $sub
330334
return $this;
331335
}
332336

337+
/**
338+
* Configures the session layer.
339+
*
340+
* @param int $gcProbability The numerator of the GC probability fraction (like PHP's session.gc_probability). Set to 0 to disable GC.
341+
* @param int $gcDivisor The denominator of the GC probability fraction (like PHP's session.gc_divisor). Probability = gcProbability/gcDivisor.
342+
*/
333343
public function setSession(
334344
?SessionStoreInterface $sessionStore = null,
335345
?SessionManagerInterface $sessionManager = null,
346+
int $gcProbability = 1,
347+
int $gcDivisor = 100,
336348
): self {
337349
$this->sessionStore = $sessionStore;
338350
$this->sessionManager = $sessionManager;
351+
$this->gcProbability = $gcProbability;
352+
$this->gcDivisor = $gcDivisor;
339353

340354
if (null !== $sessionManager && null !== $sessionStore) {
341355
throw new InvalidArgumentException('Cannot set both SessionStore and SessionManager. Set only one or the other.');
@@ -522,6 +536,8 @@ public function build(): Server
522536
$sessionManager = $this->sessionManager ?? new SessionManager(
523537
$this->sessionStore ?? new InMemorySessionStore(),
524538
$logger,
539+
$this->gcProbability,
540+
$this->gcDivisor,
525541
);
526542

527543
if (null !== $this->discoveryBasePath) {

src/Server/Session/SessionManager.php

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111

1212
namespace Mcp\Server\Session;
1313

14+
use Mcp\Exception\InvalidArgumentException;
1415
use Psr\Log\LoggerInterface;
1516
use Psr\Log\NullLogger;
1617
use Symfony\Component\Uid\Uuid;
@@ -22,10 +23,22 @@
2223
*/
2324
class SessionManager implements SessionManagerInterface
2425
{
26+
/**
27+
* @param int $gcProbability The probability (numerator) that GC will run on any given request. Combined with $gcDivisor to calculate the actual probability. Set to 0 to disable GC. Similar to PHP's session.gc_probability.
28+
* @param int $gcDivisor The divisor used with $gcProbability to calculate GC probability. The probability is gcProbability/gcDivisor (e.g. 1/100 = 1%). Similar to PHP's session.gc_divisor.
29+
*/
2530
public function __construct(
2631
private readonly SessionStoreInterface $store,
2732
private readonly LoggerInterface $logger = new NullLogger(),
33+
private readonly int $gcProbability = 1,
34+
private readonly int $gcDivisor = 100,
2835
) {
36+
if ($gcProbability < 0) {
37+
throw new InvalidArgumentException('gcProbability must be greater than or equal to 0.');
38+
}
39+
if ($gcDivisor < 1) {
40+
throw new InvalidArgumentException('gcDivisor must be greater than or equal to 1.');
41+
}
2942
}
3043

3144
public function create(): SessionInterface
@@ -54,7 +67,11 @@ public function destroy(Uuid $id): bool
5467
*/
5568
public function gc(): void
5669
{
57-
if (random_int(0, 100) > 1) {
70+
if (0 === $this->gcProbability) {
71+
return;
72+
}
73+
74+
if (random_int(1, $this->gcDivisor) > $this->gcProbability) {
5875
return;
5976
}
6077

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
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\Tests\Unit\Server\Session;
13+
14+
use Mcp\Exception\InvalidArgumentException;
15+
use Mcp\Server\Session\InMemorySessionStore;
16+
use Mcp\Server\Session\SessionManager;
17+
use PHPUnit\Framework\TestCase;
18+
19+
class SessionManagerTest extends TestCase
20+
{
21+
public function testGcDisabledWhenProbabilityIsZero(): void
22+
{
23+
$store = $this->createMock(InMemorySessionStore::class);
24+
$store->expects($this->never())->method('gc');
25+
26+
$manager = new SessionManager($store, gcProbability: 0);
27+
28+
// Call gc many times — it should never trigger
29+
for ($i = 0; $i < 100; ++$i) {
30+
$manager->gc();
31+
}
32+
}
33+
34+
public function testGcAlwaysRunsWhenProbabilityEqualsDivisor(): void
35+
{
36+
$store = $this->createMock(InMemorySessionStore::class);
37+
$store->expects($this->exactly(10))->method('gc')->willReturn([]);
38+
39+
$manager = new SessionManager($store, gcProbability: 1, gcDivisor: 1);
40+
41+
for ($i = 0; $i < 10; ++$i) {
42+
$manager->gc();
43+
}
44+
}
45+
46+
public function testGcAlwaysRunsWhenProbabilityExceedsDivisor(): void
47+
{
48+
$store = $this->createMock(InMemorySessionStore::class);
49+
$store->expects($this->exactly(5))->method('gc')->willReturn([]);
50+
51+
$manager = new SessionManager($store, gcProbability: 100, gcDivisor: 1);
52+
53+
for ($i = 0; $i < 5; ++$i) {
54+
$manager->gc();
55+
}
56+
}
57+
58+
public function testGcProbabilityMustBeNonNegative(): void
59+
{
60+
$this->expectException(InvalidArgumentException::class);
61+
$this->expectExceptionMessage('gcProbability must be greater than or equal to 0.');
62+
63+
new SessionManager(new InMemorySessionStore(), gcProbability: -1);
64+
}
65+
66+
public function testGcDivisorMustBePositive(): void
67+
{
68+
$this->expectException(InvalidArgumentException::class);
69+
$this->expectExceptionMessage('gcDivisor must be greater than or equal to 1.');
70+
71+
new SessionManager(new InMemorySessionStore(), gcDivisor: 0);
72+
}
73+
74+
public function testDefaultGcConfiguration(): void
75+
{
76+
// Default should be 1/100 — just verify construction works
77+
$manager = new SessionManager(new InMemorySessionStore());
78+
$this->assertInstanceOf(SessionManager::class, $manager);
79+
}
80+
}

0 commit comments

Comments
 (0)