From 0d2c21c520698be28991e049d780a6bb663572c8 Mon Sep 17 00:00:00 2001 From: Christopher Hertel Date: Tue, 10 Mar 2026 23:15:07 +0100 Subject: [PATCH 1/2] 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 --- CHANGELOG.md | 1 + docs/server-builder.md | 46 +++++++++-- src/Server/Builder.php | 16 ++++ src/Server/Session/SessionManager.php | 18 ++++- .../Server/Session/SessionManagerTest.php | 79 +++++++++++++++++++ 5 files changed, 153 insertions(+), 7 deletions(-) create mode 100644 tests/Unit/Server/Session/SessionManagerTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 8c3e7037..43cefdaa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ All notable changes to `mcp/sdk` will be documented in this file. * Add built-in authentication middleware for HTTP transport using OAuth * Add client component for building MCP clients +* Add configurable session garbage collection (`gcProbability`/`gcDivisor`) 0.4.0 ----- diff --git a/docs/server-builder.md b/docs/server-builder.md index c149bf14..90b3165b 100644 --- a/docs/server-builder.md +++ b/docs/server-builder.md @@ -154,11 +154,6 @@ use Mcp\Server\Session\Psr16SessionStore; use Symfony\Component\Cache\Psr16Cache; use Symfony\Component\Cache\Adapter\RedisAdapter; -// Use default in-memory sessions with custom TTL -$server = Server::builder() - ->setSession(ttl: 7200) // 2 hours - ->build(); - // Override with file-based storage $server = Server::builder() ->setSession(new FileSessionStore(__DIR__ . '/sessions')) @@ -186,6 +181,45 @@ $server = Server::builder() ->build(); ``` +### Garbage Collection Configuration + +The SDK periodically runs garbage collection to clean up expired sessions, similar to PHP's native +`session.gc_probability` and `session.gc_divisor` settings. The probability that GC runs on any given +request is `gcProbability / gcDivisor`. + +```php +// Default: 1/100 (1% chance per request) +$server = Server::builder() + ->setSession(new FileSessionStore(__DIR__ . '/sessions')) + ->build(); + +// Higher frequency: 1/10 (10% chance per request) +$server = Server::builder() + ->setSession( + new FileSessionStore(__DIR__ . '/sessions'), + gcProbability: 1, + gcDivisor: 10, + ) + ->build(); + +// Run GC on every request +$server = Server::builder() + ->setSession(gcProbability: 1, gcDivisor: 1) + ->build(); + +// Disable GC entirely (e.g. when using an external cleanup process) +$server = Server::builder() + ->setSession(gcProbability: 0) + ->build(); +``` + +**Parameters:** +- `$gcProbability` (int): The numerator of the GC probability fraction (default: `1`). Set to `0` to disable GC. +- `$gcDivisor` (int): The denominator of the GC probability fraction (default: `100`). Must be >= 1. + +> **Note**: When providing a custom `SessionManagerInterface` via the `$sessionManager` parameter, +> the `gcProbability` and `gcDivisor` settings are ignored — you control GC behavior in your own implementation. + **Available Session Stores:** - `InMemorySessionStore`: Fast in-memory storage (default) - `FileSessionStore`: Persistent file-based storage @@ -569,7 +603,7 @@ $server = Server::builder() | `setPaginationLimit()` | limit | Set max items per page | | `setInstructions()` | instructions | Set usage instructions | | `setDiscovery()` | basePath, scanDirs?, excludeDirs?, cache? | Configure attribute discovery | -| `setSession()` | store?, factory?, ttl? | Configure session management | +| `setSession()` | sessionStore?, sessionManager?, gcProbability?, gcDivisor? | Configure session management | | `setLogger()` | logger | Set PSR-3 logger | | `setContainer()` | container | Set PSR-11 container | | `setEventDispatcher()` | dispatcher | Set PSR-14 event dispatcher | diff --git a/src/Server/Builder.php b/src/Server/Builder.php index f97dd509..bd987fb4 100644 --- a/src/Server/Builder.php +++ b/src/Server/Builder.php @@ -75,6 +75,10 @@ final class Builder private ?SessionStoreInterface $sessionStore = null; + private int $gcProbability = 1; + + private int $gcDivisor = 100; + private int $paginationLimit = 50; private ?string $instructions = null; @@ -319,12 +323,22 @@ public function setResourceSubscriptionManager(SubscriptionManagerInterface $sub return $this; } + /** + * Configures the session layer. + * + * @param int $gcProbability The numerator of the GC probability fraction (like PHP's session.gc_probability). Set to 0 to disable GC. + * @param int $gcDivisor The denominator of the GC probability fraction (like PHP's session.gc_divisor). Probability = gcProbability/gcDivisor. + */ public function setSession( ?SessionStoreInterface $sessionStore = null, ?SessionManagerInterface $sessionManager = null, + int $gcProbability = 1, + int $gcDivisor = 100, ): self { $this->sessionStore = $sessionStore; $this->sessionManager = $sessionManager; + $this->gcProbability = $gcProbability; + $this->gcDivisor = $gcDivisor; if (null !== $sessionManager && null !== $sessionStore) { throw new InvalidArgumentException('Cannot set both SessionStore and SessionManager. Set only one or the other.'); @@ -510,6 +524,8 @@ public function build(): Server $sessionManager = $this->sessionManager ?? new SessionManager( $this->sessionStore ?? new InMemorySessionStore(), $logger, + $this->gcProbability, + $this->gcDivisor, ); if (null !== $this->discoveryBasePath) { diff --git a/src/Server/Session/SessionManager.php b/src/Server/Session/SessionManager.php index 4932d396..2ed504c7 100644 --- a/src/Server/Session/SessionManager.php +++ b/src/Server/Session/SessionManager.php @@ -22,10 +22,22 @@ */ class SessionManager implements SessionManagerInterface { + /** + * @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. + * @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. + */ public function __construct( private readonly SessionStoreInterface $store, private readonly LoggerInterface $logger = new NullLogger(), + private readonly int $gcProbability = 1, + private readonly int $gcDivisor = 100, ) { + if ($gcProbability < 0) { + throw new \InvalidArgumentException('gcProbability must be greater than or equal to 0.'); + } + if ($gcDivisor < 1) { + throw new \InvalidArgumentException('gcDivisor must be greater than or equal to 1.'); + } } public function create(): SessionInterface @@ -54,7 +66,11 @@ public function destroy(Uuid $id): bool */ public function gc(): void { - if (random_int(0, 100) > 1) { + if (0 === $this->gcProbability) { + return; + } + + if (random_int(1, $this->gcDivisor) > $this->gcProbability) { return; } diff --git a/tests/Unit/Server/Session/SessionManagerTest.php b/tests/Unit/Server/Session/SessionManagerTest.php new file mode 100644 index 00000000..f7324f2e --- /dev/null +++ b/tests/Unit/Server/Session/SessionManagerTest.php @@ -0,0 +1,79 @@ +createMock(InMemorySessionStore::class); + $store->expects($this->never())->method('gc'); + + $manager = new SessionManager($store, gcProbability: 0); + + // Call gc many times — it should never trigger + for ($i = 0; $i < 100; ++$i) { + $manager->gc(); + } + } + + public function testGcAlwaysRunsWhenProbabilityEqualsDivisor(): void + { + $store = $this->createMock(InMemorySessionStore::class); + $store->expects($this->exactly(10))->method('gc')->willReturn([]); + + $manager = new SessionManager($store, gcProbability: 1, gcDivisor: 1); + + for ($i = 0; $i < 10; ++$i) { + $manager->gc(); + } + } + + public function testGcAlwaysRunsWhenProbabilityExceedsDivisor(): void + { + $store = $this->createMock(InMemorySessionStore::class); + $store->expects($this->exactly(5))->method('gc')->willReturn([]); + + $manager = new SessionManager($store, gcProbability: 100, gcDivisor: 1); + + for ($i = 0; $i < 5; ++$i) { + $manager->gc(); + } + } + + public function testGcProbabilityMustBeNonNegative(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('gcProbability must be greater than or equal to 0.'); + + new SessionManager(new InMemorySessionStore(), gcProbability: -1); + } + + public function testGcDivisorMustBePositive(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('gcDivisor must be greater than or equal to 1.'); + + new SessionManager(new InMemorySessionStore(), gcDivisor: 0); + } + + public function testDefaultGcConfiguration(): void + { + // Default should be 1/100 — just verify construction works + $manager = new SessionManager(new InMemorySessionStore()); + $this->assertInstanceOf(SessionManager::class, $manager); + } +} From 3641de9b44623d617f5b02e28937d248a5dd0206 Mon Sep 17 00:00:00 2001 From: Christopher Hertel Date: Tue, 10 Mar 2026 23:19:41 +0100 Subject: [PATCH 2/2] Use Mcp\Exception\InvalidArgumentException instead of native Co-Authored-By: Claude Opus 4.6 --- src/Server/Session/SessionManager.php | 5 +++-- tests/Unit/Server/Session/SessionManagerTest.php | 5 +++-- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/src/Server/Session/SessionManager.php b/src/Server/Session/SessionManager.php index 2ed504c7..2a08f7ee 100644 --- a/src/Server/Session/SessionManager.php +++ b/src/Server/Session/SessionManager.php @@ -11,6 +11,7 @@ namespace Mcp\Server\Session; +use Mcp\Exception\InvalidArgumentException; use Psr\Log\LoggerInterface; use Psr\Log\NullLogger; use Symfony\Component\Uid\Uuid; @@ -33,10 +34,10 @@ public function __construct( private readonly int $gcDivisor = 100, ) { if ($gcProbability < 0) { - throw new \InvalidArgumentException('gcProbability must be greater than or equal to 0.'); + throw new InvalidArgumentException('gcProbability must be greater than or equal to 0.'); } if ($gcDivisor < 1) { - throw new \InvalidArgumentException('gcDivisor must be greater than or equal to 1.'); + throw new InvalidArgumentException('gcDivisor must be greater than or equal to 1.'); } } diff --git a/tests/Unit/Server/Session/SessionManagerTest.php b/tests/Unit/Server/Session/SessionManagerTest.php index f7324f2e..e145292b 100644 --- a/tests/Unit/Server/Session/SessionManagerTest.php +++ b/tests/Unit/Server/Session/SessionManagerTest.php @@ -11,6 +11,7 @@ namespace Mcp\Tests\Unit\Server\Session; +use Mcp\Exception\InvalidArgumentException; use Mcp\Server\Session\InMemorySessionStore; use Mcp\Server\Session\SessionManager; use PHPUnit\Framework\TestCase; @@ -56,7 +57,7 @@ public function testGcAlwaysRunsWhenProbabilityExceedsDivisor(): void public function testGcProbabilityMustBeNonNegative(): void { - $this->expectException(\InvalidArgumentException::class); + $this->expectException(InvalidArgumentException::class); $this->expectExceptionMessage('gcProbability must be greater than or equal to 0.'); new SessionManager(new InMemorySessionStore(), gcProbability: -1); @@ -64,7 +65,7 @@ public function testGcProbabilityMustBeNonNegative(): void public function testGcDivisorMustBePositive(): void { - $this->expectException(\InvalidArgumentException::class); + $this->expectException(InvalidArgumentException::class); $this->expectExceptionMessage('gcDivisor must be greater than or equal to 1.'); new SessionManager(new InMemorySessionStore(), gcDivisor: 0);