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..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; @@ -22,10 +23,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 +67,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..e145292b --- /dev/null +++ b/tests/Unit/Server/Session/SessionManagerTest.php @@ -0,0 +1,80 @@ +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); + } +}