Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
-----
Expand Down
46 changes: 40 additions & 6 deletions docs/server-builder.md
Original file line number Diff line number Diff line change
Expand Up @@ -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'))
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 |
Expand Down
16 changes: 16 additions & 0 deletions src/Server/Builder.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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.');
Expand Down Expand Up @@ -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) {
Expand Down
19 changes: 18 additions & 1 deletion src/Server/Session/SessionManager.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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
Expand Down Expand Up @@ -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;
}

Expand Down
80 changes: 80 additions & 0 deletions tests/Unit/Server/Session/SessionManagerTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
<?php

/*
* This file is part of the official PHP MCP SDK.
*
* A collaboration between Symfony and the PHP Foundation.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

namespace Mcp\Tests\Unit\Server\Session;

use Mcp\Exception\InvalidArgumentException;
use Mcp\Server\Session\InMemorySessionStore;
use Mcp\Server\Session\SessionManager;
use PHPUnit\Framework\TestCase;

class SessionManagerTest extends TestCase
{
public function testGcDisabledWhenProbabilityIsZero(): void
{
$store = $this->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);
}
}
Loading