Skip to content

Commit fdb5e17

Browse files
feat(server): Introduce a formal session management system
1 parent eb28fda commit fdb5e17

File tree

11 files changed

+445
-11
lines changed

11 files changed

+445
-11
lines changed

composer.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
"ext-fileinfo": "*",
2323
"opis/json-schema": "^2.4",
2424
"phpdocumentor/reflection-docblock": "^5.6",
25+
"psr/clock": "^1.0",
2526
"psr/container": "^2.0",
2627
"psr/event-dispatcher": "^1.0",
2728
"psr/http-factory": "^1.1",

src/Server.php

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@
1313

1414
use Mcp\JsonRpc\Handler;
1515
use Mcp\Server\ServerBuilder;
16+
use Mcp\Server\Session\SessionFactoryInterface;
17+
use Mcp\Server\Session\SessionStoreInterface;
1618
use Mcp\Server\TransportInterface;
1719
use Psr\Log\LoggerInterface;
1820
use Psr\Log\NullLogger;
@@ -24,6 +26,9 @@ final class Server
2426
{
2527
public function __construct(
2628
private readonly Handler $jsonRpcHandler,
29+
private readonly SessionFactoryInterface $sessionFactory,
30+
private readonly SessionStoreInterface $sessionStore,
31+
private readonly int $sessionTtl,
2732
private readonly LoggerInterface $logger = new NullLogger(),
2833
) {}
2934

@@ -45,10 +50,10 @@ public function connect(TransportInterface $transport): void
4550
});
4651
}
4752

48-
private function handleMessage(string $rawMessage, TransportInterface $transport): void
53+
private function handleMessage(string $message, TransportInterface $transport): void
4954
{
5055
try {
51-
foreach ($this->jsonRpcHandler->process($rawMessage) as $response) {
56+
foreach ($this->jsonRpcHandler->process($message) as $response) {
5257
if (null === $response) {
5358
continue;
5459
}
@@ -57,7 +62,7 @@ private function handleMessage(string $rawMessage, TransportInterface $transport
5762
}
5863
} catch (\JsonException $e) {
5964
$this->logger->error('Failed to encode response to JSON.', [
60-
'message' => $rawMessage,
65+
'message' => $message,
6166
'exception' => $e,
6267
]);
6368
}

src/Server/NativeClock.php

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Mcp\Server;
6+
7+
use Psr\Clock\ClockInterface;
8+
use DateTimeImmutable;
9+
10+
class NativeClock implements ClockInterface
11+
{
12+
public function now(): DateTimeImmutable
13+
{
14+
return new DateTimeImmutable();
15+
}
16+
}

src/Server/ServerBuilder.php

Lines changed: 25 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -38,11 +38,14 @@
3838
use Mcp\Schema\Tool;
3939
use Mcp\Schema\ToolAnnotations;
4040
use Mcp\Server;
41+
use Mcp\Server\Session\SessionFactory;
42+
use Mcp\Server\Session\InMemorySessionStore;
43+
use Mcp\Server\Session\SessionFactoryInterface;
44+
use Mcp\Server\Session\SessionStoreInterface;
4145
use Psr\Container\ContainerInterface;
4246
use Psr\EventDispatcher\EventDispatcherInterface;
4347
use Psr\Log\LoggerInterface;
4448
use Psr\Log\NullLogger;
45-
use Psr\SimpleCache\CacheInterface;
4649

4750
/**
4851
* @author Kyrian Obikwelu <[email protected]>
@@ -65,6 +68,10 @@ final class ServerBuilder
6568

6669
private ?ContainerInterface $container = null;
6770

71+
private ?SessionFactoryInterface $sessionFactory = null;
72+
private ?SessionStoreInterface $sessionStore = null;
73+
private ?int $sessionTtl = 3600;
74+
6875
private ?int $paginationLimit = 50;
6976

7077
private ?string $instructions = null;
@@ -193,6 +200,18 @@ public function setContainer(ContainerInterface $container): self
193200
return $this;
194201
}
195202

203+
public function withSession(
204+
SessionFactoryInterface $sessionFactory,
205+
SessionStoreInterface $sessionStore,
206+
int $ttl = 3600
207+
): self {
208+
$this->sessionFactory = $sessionFactory;
209+
$this->sessionStore = $sessionStore;
210+
$this->sessionTtl = $ttl;
211+
212+
return $this;
213+
}
214+
196215
public function setDiscovery(
197216
string $basePath,
198217
array $scanDirs = ['.', 'src'],
@@ -327,7 +346,7 @@ private function registerCapabilities(
327346
$reflection = HandlerResolver::resolve($data['handler']);
328347

329348
if ($reflection instanceof \ReflectionFunction) {
330-
$name = $data['name'] ?? 'closure_tool_'.spl_object_id($data['handler']);
349+
$name = $data['name'] ?? 'closure_tool_' . spl_object_id($data['handler']);
331350
$description = $data['description'] ?? null;
332351
} else {
333352
$classShortName = $reflection->getDeclaringClass()->getShortName();
@@ -362,7 +381,7 @@ private function registerCapabilities(
362381
$reflection = HandlerResolver::resolve($data['handler']);
363382

364383
if ($reflection instanceof \ReflectionFunction) {
365-
$name = $data['name'] ?? 'closure_resource_'.spl_object_id($data['handler']);
384+
$name = $data['name'] ?? 'closure_resource_' . spl_object_id($data['handler']);
366385
$description = $data['description'] ?? null;
367386
} else {
368387
$classShortName = $reflection->getDeclaringClass()->getShortName();
@@ -400,7 +419,7 @@ private function registerCapabilities(
400419
$reflection = HandlerResolver::resolve($data['handler']);
401420

402421
if ($reflection instanceof \ReflectionFunction) {
403-
$name = $data['name'] ?? 'closure_template_'.spl_object_id($data['handler']);
422+
$name = $data['name'] ?? 'closure_template_' . spl_object_id($data['handler']);
404423
$description = $data['description'] ?? null;
405424
} else {
406425
$classShortName = $reflection->getDeclaringClass()->getShortName();
@@ -438,7 +457,7 @@ private function registerCapabilities(
438457
$reflection = HandlerResolver::resolve($data['handler']);
439458

440459
if ($reflection instanceof \ReflectionFunction) {
441-
$name = $data['name'] ?? 'closure_prompt_'.spl_object_id($data['handler']);
460+
$name = $data['name'] ?? 'closure_prompt_' . spl_object_id($data['handler']);
442461
$description = $data['description'] ?? null;
443462
} else {
444463
$classShortName = $reflection->getDeclaringClass()->getShortName();
@@ -461,7 +480,7 @@ private function registerCapabilities(
461480
continue;
462481
}
463482

464-
$paramTag = $paramTags['$'.$param->getName()] ?? null;
483+
$paramTag = $paramTags['$' . $param->getName()] ?? null;
465484
$arguments[] = new PromptArgument(
466485
$param->getName(),
467486
$paramTag ? trim((string) $paramTag->getDescription()) : null,
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Mcp\Server\Session;
6+
7+
use Mcp\Server\Session\SessionStoreInterface;
8+
use Mcp\Server\NativeClock;
9+
use Psr\Clock\ClockInterface;
10+
use Symfony\Component\Uid\Uuid;
11+
12+
class InMemorySessionStore implements SessionStoreInterface
13+
{
14+
/**
15+
* @var array<string, array{ data: array, timestamp: int }>
16+
*/
17+
protected array $store = [];
18+
19+
public function __construct(
20+
protected readonly int $ttl = 3600,
21+
protected readonly ClockInterface $clock = new NativeClock(),
22+
) {}
23+
24+
public function read(Uuid $sessionId): string|false
25+
{
26+
$session = $this->store[$sessionId->toRfc4122()] ?? '';
27+
if ($session === '') {
28+
return false;
29+
}
30+
31+
$currentTimestamp = $this->clock->now()->getTimestamp();
32+
33+
if ($currentTimestamp - $session['timestamp'] > $this->ttl) {
34+
unset($this->store[$sessionId]);
35+
return false;
36+
}
37+
38+
return $session['data'];
39+
}
40+
41+
public function write(Uuid $sessionId, string $data): bool
42+
{
43+
$this->store[$sessionId->toRfc4122()] = [
44+
'data' => $data,
45+
'timestamp' => $this->clock->now()->getTimestamp(),
46+
];
47+
48+
return true;
49+
}
50+
51+
public function destroy(Uuid $sessionId): bool
52+
{
53+
if (isset($this->store[$sessionId->toRfc4122()])) {
54+
unset($this->store[$sessionId]);
55+
}
56+
57+
return true;
58+
}
59+
60+
public function gc(int $maxLifetime): array
61+
{
62+
$currentTimestamp = $this->clock->now()->getTimestamp();
63+
$deletedSessions = [];
64+
65+
foreach ($this->store as $sessionId => $session) {
66+
$sessionId = Uuid::fromString($sessionId);
67+
if ($currentTimestamp - $session['timestamp'] > $maxLifetime) {
68+
unset($this->store[$sessionId->toRfc4122()]);
69+
$deletedSessions[] = $sessionId;
70+
}
71+
}
72+
73+
return $deletedSessions;
74+
}
75+
}

src/Server/Session/Session.php

Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Mcp\Server\Session;
6+
7+
use Mcp\Server\Session\SessionStoreInterface;
8+
use Mcp\Server\Session\SessionInterface;
9+
use Symfony\Component\Uid\Uuid;
10+
use Symfony\Component\Uid\UuidV4;
11+
12+
/**
13+
* @author Kyrian Obikwelu <[email protected]>
14+
*/
15+
class Session implements SessionInterface
16+
{
17+
/**
18+
* @param array<string, mixed> $data Stores all session data.
19+
* Keys are snake_case by convention for MCP-specific data.
20+
*
21+
* Official keys are:
22+
* - initialized: bool
23+
* - client_info: array|null
24+
* - protocol_version: string|null
25+
* - log_level: string|null
26+
*/
27+
public function __construct(
28+
protected SessionStoreInterface $store,
29+
protected Uuid $id = new UuidV4(),
30+
protected array $data = [],
31+
) {
32+
if ($rawData = $this->store->read($this->id)) {
33+
$this->data = json_decode($rawData, true) ?? [];
34+
}
35+
}
36+
37+
public function getId(): Uuid
38+
{
39+
return $this->id;
40+
}
41+
42+
public function getStore(): SessionStoreInterface
43+
{
44+
return $this->store;
45+
}
46+
47+
public function save(): void
48+
{
49+
$this->store->write($this->id, json_encode($this->data, \JSON_THROW_ON_ERROR));
50+
}
51+
52+
public function get(string $key, mixed $default = null): mixed
53+
{
54+
$key = explode('.', $key);
55+
$data = $this->data;
56+
57+
foreach ($key as $segment) {
58+
if (is_array($data) && array_key_exists($segment, $data)) {
59+
$data = $data[$segment];
60+
} else {
61+
return $default;
62+
}
63+
}
64+
65+
return $data;
66+
}
67+
68+
public function set(string $key, mixed $value, bool $overwrite = true): void
69+
{
70+
$segments = explode('.', $key);
71+
$data = &$this->data;
72+
73+
while (count($segments) > 1) {
74+
$segment = array_shift($segments);
75+
if (!isset($data[$segment]) || !is_array($data[$segment])) {
76+
$data[$segment] = [];
77+
}
78+
$data = &$data[$segment];
79+
}
80+
81+
$lastKey = array_shift($segments);
82+
if ($overwrite || !isset($data[$lastKey])) {
83+
$data[$lastKey] = $value;
84+
}
85+
}
86+
87+
public function has(string $key): bool
88+
{
89+
$key = explode('.', $key);
90+
$data = $this->data;
91+
92+
foreach ($key as $segment) {
93+
if (is_array($data) && array_key_exists($segment, $data)) {
94+
$data = $data[$segment];
95+
} elseif (is_object($data) && isset($data->{$segment})) {
96+
$data = $data->{$segment};
97+
} else {
98+
return false;
99+
}
100+
}
101+
102+
return true;
103+
}
104+
105+
public function forget(string $key): void
106+
{
107+
$segments = explode('.', $key);
108+
$data = &$this->data;
109+
110+
while (count($segments) > 1) {
111+
$segment = array_shift($segments);
112+
if (!isset($data[$segment]) || !is_array($data[$segment])) {
113+
$data[$segment] = [];
114+
}
115+
$data = &$data[$segment];
116+
}
117+
118+
$lastKey = array_shift($segments);
119+
if (isset($data[$lastKey])) {
120+
unset($data[$lastKey]);
121+
}
122+
}
123+
124+
public function clear(): void
125+
{
126+
$this->data = [];
127+
}
128+
129+
public function pull(string $key, mixed $default = null): mixed
130+
{
131+
$value = $this->get($key, $default);
132+
$this->forget($key);
133+
return $value;
134+
}
135+
136+
public function all(): array
137+
{
138+
return $this->data;
139+
}
140+
141+
public function hydrate(array $attributes): void
142+
{
143+
$this->data = $attributes;
144+
}
145+
146+
public function jsonSerialize(): array
147+
{
148+
return $this->all();
149+
}
150+
}

0 commit comments

Comments
 (0)