Skip to content

Commit 25bec57

Browse files
feat(server): Integrate session management in message handling
- Added session support to the `Server` and `Handler` classes, allowing for session data to be managed during message processing. - Updated `TransportInterface` to include session context in the `send` method. - Refactored various request handlers to utilize session information, ensuring proper session handling for incoming requests. - Introduced a file-based session store for persistent session data management
1 parent 3cf1838 commit 25bec57

21 files changed

+415
-101
lines changed
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
sessions/

examples/10-simple-http-transport/server.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
use Nyholm\Psr7\Factory\Psr17Factory;
99
use Nyholm\Psr7Server\ServerRequestCreator;
1010
use Laminas\HttpHandlerRunner\Emitter\SapiEmitter;
11+
use Mcp\Server\Session\FileSessionStore;
1112

1213
$psr17Factory = new Psr17Factory();
1314
$creator = new ServerRequestCreator($psr17Factory, $psr17Factory, $psr17Factory, $psr17Factory);
@@ -17,6 +18,7 @@
1718
$server = Server::make()
1819
->withServerInfo('HTTP MCP Server', '1.0.0', 'MCP Server over HTTP transport')
1920
->withContainer(container())
21+
->withSession(new FileSessionStore(__DIR__ . '/sessions'))
2022
->withDiscovery(__DIR__, ['.'])
2123
->build();
2224

src/JsonRpc/Handler.php

Lines changed: 114 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -25,8 +25,12 @@
2525
use Mcp\Schema\JsonRpc\HasMethodInterface;
2626
use Mcp\Schema\JsonRpc\Response;
2727
use Mcp\Server\MethodHandlerInterface;
28+
use Mcp\Server\Session\SessionInterface;
2829
use Mcp\Server\NotificationHandler;
2930
use Mcp\Server\RequestHandler;
31+
use Mcp\Server\Session\SessionFactoryInterface;
32+
use Mcp\Server\Session\SessionStoreInterface;
33+
use Mcp\Schema\Request\InitializeRequest;
3034
use Symfony\Component\Uid\Uuid;
3135
use Psr\Log\LoggerInterface;
3236
use Psr\Log\NullLogger;
@@ -48,6 +52,8 @@ class Handler
4852
*/
4953
public function __construct(
5054
private readonly MessageFactory $messageFactory,
55+
private readonly SessionFactoryInterface $sessionFactory,
56+
private readonly SessionStoreInterface $sessionStore,
5157
iterable $methodHandlers,
5258
private readonly LoggerInterface $logger = new NullLogger(),
5359
) {
@@ -63,10 +69,14 @@ public static function make(
6369
ToolCallerInterface $toolCaller,
6470
ResourceReaderInterface $resourceReader,
6571
PromptGetterInterface $promptGetter,
72+
SessionStoreInterface $sessionStore,
73+
SessionFactoryInterface $sessionFactory,
6674
LoggerInterface $logger = new NullLogger(),
6775
): self {
6876
return new self(
6977
messageFactory: MessageFactory::make(),
78+
sessionFactory: $sessionFactory,
79+
sessionStore: $sessionStore,
7080
methodHandlers: [
7181
new NotificationHandler\InitializedHandler(),
7282
new RequestHandler\InitializeHandler($registry->getCapabilities(), $implementation),
@@ -83,7 +93,7 @@ public static function make(
8393
}
8494

8595
/**
86-
* @return iterable<string|null>
96+
* @return iterable<array{string|null, array<string, mixed>}>
8797
*
8898
* @throws ExceptionInterface When a handler throws an exception during message processing
8999
* @throws \JsonException When JSON encoding of the response fails
@@ -92,20 +102,65 @@ public function process(string $input, ?Uuid $sessionId): iterable
92102
{
93103
$this->logger->info('Received message to process.', ['message' => $input]);
94104

105+
$this->runGarbageCollection();
106+
95107
try {
96-
$messages = $this->messageFactory->create($input);
108+
$messages = iterator_to_array($this->messageFactory->create($input));
97109
} catch (\JsonException $e) {
98110
$this->logger->warning('Failed to decode json message.', ['exception' => $e]);
99-
100-
yield $this->encodeResponse(Error::forParseError($e->getMessage()));
111+
$error = Error::forParseError($e->getMessage());
112+
yield [$this->encodeResponse($error), []];
101113

102114
return;
103115
}
104116

117+
$hasInitializeRequest = false;
118+
foreach ($messages as $message) {
119+
if ($message instanceof InitializeRequest) {
120+
$hasInitializeRequest = true;
121+
break;
122+
}
123+
}
124+
125+
$session = null;
126+
127+
if ($hasInitializeRequest) {
128+
// Spec: An initialize request must not be part of a batch.
129+
if (count($messages) > 1) {
130+
$error = Error::forInvalidRequest('The "initialize" request MUST NOT be part of a batch.');
131+
yield [$this->encodeResponse($error), []];
132+
return;
133+
}
134+
135+
// Spec: An initialize request must not have a session ID.
136+
if ($sessionId) {
137+
$error = Error::forInvalidRequest('A session ID MUST NOT be sent with an "initialize" request.');
138+
yield [$this->encodeResponse($error), []];
139+
return;
140+
}
141+
142+
$session = $this->sessionFactory->create($this->sessionStore);
143+
} else {
144+
if (!$sessionId) {
145+
$error = Error::forInvalidRequest('A valid session id is REQUIRED for non-initialize requests.');
146+
yield [$this->encodeResponse($error), ['status_code' => 400]];
147+
return;
148+
}
149+
150+
if (!$this->sessionStore->exists($sessionId)) {
151+
$error = Error::forInvalidRequest('Session not found or has expired.');
152+
yield [$this->encodeResponse($error), ['status_code' => 404]];
153+
return;
154+
}
155+
156+
$session = $this->sessionFactory->createWithId($sessionId, $this->sessionStore);
157+
}
158+
105159
foreach ($messages as $message) {
106160
if ($message instanceof InvalidInputMessageException) {
107161
$this->logger->warning('Failed to create message.', ['exception' => $message]);
108-
yield $this->encodeResponse(Error::forInvalidRequest($message->getMessage(), 0));
162+
$error = Error::forInvalidRequest($message->getMessage(), 0);
163+
yield [$this->encodeResponse($error), []];
109164
continue;
110165
}
111166

@@ -114,26 +169,32 @@ public function process(string $input, ?Uuid $sessionId): iterable
114169
]);
115170

116171
try {
117-
yield $this->encodeResponse($this->handle($message));
172+
$response = $this->encodeResponse($this->handle($message, $session));
173+
yield [$response, ['session_id' => $session->getId()]];
118174
} catch (\DomainException) {
119-
yield null;
175+
yield [null, []];
120176
} catch (NotFoundExceptionInterface $e) {
121177
$this->logger->warning(
122178
\sprintf('Failed to create response: %s', $e->getMessage()),
123179
['exception' => $e],
124180
);
125181

126-
yield $this->encodeResponse(Error::forMethodNotFound($e->getMessage()));
182+
$error = Error::forMethodNotFound($e->getMessage());
183+
yield [$this->encodeResponse($error), []];
127184
} catch (\InvalidArgumentException $e) {
128185
$this->logger->warning(\sprintf('Invalid argument: %s', $e->getMessage()), ['exception' => $e]);
129186

130-
yield $this->encodeResponse(Error::forInvalidParams($e->getMessage()));
187+
$error = Error::forInvalidParams($e->getMessage());
188+
yield [$this->encodeResponse($error), []];
131189
} catch (\Throwable $e) {
132190
$this->logger->critical(\sprintf('Uncaught exception: %s', $e->getMessage()), ['exception' => $e]);
133191

134-
yield $this->encodeResponse(Error::forInternalError($e->getMessage()));
192+
$error = Error::forInternalError($e->getMessage());
193+
yield [$this->encodeResponse($error), []];
135194
}
136195
}
196+
197+
$session->save();
137198
}
138199

139200
/**
@@ -162,26 +223,28 @@ private function encodeResponse(Response|Error|null $response): ?string
162223
* @throws NotFoundExceptionInterface When no handler is found for the request method
163224
* @throws ExceptionInterface When a request handler throws an exception
164225
*/
165-
private function handle(HasMethodInterface $message): Response|Error|null
226+
private function handle(HasMethodInterface $message, SessionInterface $session): Response|Error|null
166227
{
167228
$this->logger->info(\sprintf('Handling message for method "%s".', $message::getMethod()), [
168229
'message' => $message,
169230
]);
170231

171232
$handled = false;
172233
foreach ($this->methodHandlers as $handler) {
173-
if ($handler->supports($message)) {
174-
$return = $handler->handle($message);
175-
$handled = true;
176-
177-
$this->logger->debug(\sprintf('Message handled by "%s".', $handler::class), [
178-
'method' => $message::getMethod(),
179-
'response' => $return,
180-
]);
181-
182-
if (null !== $return) {
183-
return $return;
184-
}
234+
if (!$handler->supports($message)) {
235+
continue;
236+
}
237+
238+
$return = $handler->handle($message, $session);
239+
$handled = true;
240+
241+
$this->logger->debug(\sprintf('Message handled by "%s".', $handler::class), [
242+
'method' => $message::getMethod(),
243+
'response' => $return,
244+
]);
245+
246+
if (null !== $return) {
247+
return $return;
185248
}
186249
}
187250

@@ -191,4 +254,32 @@ private function handle(HasMethodInterface $message): Response|Error|null
191254

192255
throw new HandlerNotFoundException(\sprintf('No handler found for method "%s".', $message::getMethod()));
193256
}
257+
258+
/**
259+
* Run garbage collection on expired sessions.
260+
* Uses the session store's internal TTL configuration.
261+
*/
262+
private function runGarbageCollection(): void
263+
{
264+
if (random_int(0, 100) > 1) {
265+
return;
266+
}
267+
268+
$deletedSessions = $this->sessionStore->gc();
269+
if (!empty($deletedSessions)) {
270+
$this->logger->debug('Garbage collected expired sessions.', [
271+
'count' => count($deletedSessions),
272+
'session_ids' => array_map(fn(Uuid $id) => $id->toRfc4122(), $deletedSessions),
273+
]);
274+
}
275+
}
276+
277+
/**
278+
* Destroy a specific session.
279+
*/
280+
public function destroySession(Uuid $sessionId): void
281+
{
282+
$this->sessionStore->destroy($sessionId);
283+
$this->logger->info('Session destroyed.', ['session_id' => $sessionId->toRfc4122()]);
284+
}
194285
}

src/Server.php

Lines changed: 17 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -13,23 +13,19 @@
1313

1414
use Mcp\JsonRpc\Handler;
1515
use Mcp\Server\ServerBuilder;
16-
use Mcp\Server\Session\SessionFactoryInterface;
17-
use Mcp\Server\Session\SessionStoreInterface;
1816
use Mcp\Server\TransportInterface;
1917
use Psr\Log\LoggerInterface;
2018
use Psr\Log\NullLogger;
2119
use Symfony\Component\Uid\Uuid;
2220

2321
/**
2422
* @author Christopher Hertel <mail@christopher-hertel.de>
23+
* @author Kyrian Obikwelu <koshnawaza@gmail.com>
2524
*/
2625
final class Server
2726
{
2827
public function __construct(
2928
private readonly Handler $jsonRpcHandler,
30-
private readonly SessionFactoryInterface $sessionFactory,
31-
private readonly SessionStoreInterface $sessionStore,
32-
private readonly int $sessionTtl,
3329
private readonly LoggerInterface $logger = new NullLogger(),
3430
) {}
3531

@@ -47,25 +43,24 @@ public function connect(TransportInterface $transport): void
4743
]);
4844

4945
$transport->onMessage(function (string $message, ?Uuid $sessionId) use ($transport) {
50-
$this->handleMessage($message, $sessionId, $transport);
51-
});
52-
}
46+
try {
47+
foreach ($this->jsonRpcHandler->process($message, $sessionId) as [$response, $context]) {
48+
if (null === $response) {
49+
continue;
50+
}
5351

54-
private function handleMessage(string $message, ?Uuid $sessionId, TransportInterface $transport): void
55-
{
56-
try {
57-
foreach ($this->jsonRpcHandler->process($message, $sessionId) as $response) {
58-
if (null === $response) {
59-
continue;
52+
$transport->send($response, $context);
6053
}
61-
62-
$transport->send($response);
54+
} catch (\JsonException $e) {
55+
$this->logger->error('Failed to encode response to JSON.', [
56+
'message' => $message,
57+
'exception' => $e,
58+
]);
6359
}
64-
} catch (\JsonException $e) {
65-
$this->logger->error('Failed to encode response to JSON.', [
66-
'message' => $message,
67-
'exception' => $e,
68-
]);
69-
}
60+
});
61+
62+
$transport->onSessionEnd(function (Uuid $sessionId) {
63+
$this->jsonRpcHandler->destroySession($sessionId);
64+
});
7065
}
7166
}

src/Server/MethodHandlerInterface.php

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
use Mcp\Schema\JsonRpc\HasMethodInterface;
1717
use Mcp\Schema\JsonRpc\Request;
1818
use Mcp\Schema\JsonRpc\Response;
19+
use Mcp\Server\Session\SessionInterface;
1920

2021
/**
2122
* @author Christopher Hertel <mail@christopher-hertel.de>
@@ -27,5 +28,5 @@ public function supports(HasMethodInterface $message): bool;
2728
/**
2829
* @throws ExceptionInterface When the handler encounters an error processing the request
2930
*/
30-
public function handle(HasMethodInterface $message): Response|Error|null;
31+
public function handle(HasMethodInterface $message, SessionInterface $session): Response|Error|null;
3132
}

src/Server/NotificationHandler/InitializedHandler.php

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
use Mcp\Schema\JsonRpc\Response;
1717
use Mcp\Schema\Notification\InitializedNotification;
1818
use Mcp\Server\MethodHandlerInterface;
19+
use Mcp\Server\Session\SessionInterface;
1920

2021
/**
2122
* @author Christopher Hertel <mail@christopher-hertel.de>
@@ -27,8 +28,10 @@ public function supports(HasMethodInterface $message): bool
2728
return $message instanceof InitializedNotification;
2829
}
2930

30-
public function handle(InitializedNotification|HasMethodInterface $message): Response|Error|null
31+
public function handle(InitializedNotification|HasMethodInterface $message, SessionInterface $session): Response|Error|null
3132
{
33+
$session->set('initialized', true);
34+
3235
return null;
3336
}
3437
}

src/Server/RequestHandler/CallToolHandler.php

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
use Mcp\Schema\JsonRpc\Response;
1919
use Mcp\Schema\Request\CallToolRequest;
2020
use Mcp\Server\MethodHandlerInterface;
21+
use Mcp\Server\Session\SessionInterface;
2122
use Psr\Log\LoggerInterface;
2223
use Psr\Log\NullLogger;
2324

@@ -30,15 +31,14 @@ final class CallToolHandler implements MethodHandlerInterface
3031
public function __construct(
3132
private readonly ToolCallerInterface $toolCaller,
3233
private readonly LoggerInterface $logger = new NullLogger(),
33-
) {
34-
}
34+
) {}
3535

3636
public function supports(HasMethodInterface $message): bool
3737
{
3838
return $message instanceof CallToolRequest;
3939
}
4040

41-
public function handle(CallToolRequest|HasMethodInterface $message): Response|Error
41+
public function handle(CallToolRequest|HasMethodInterface $message, SessionInterface $session): Response|Error
4242
{
4343
\assert($message instanceof CallToolRequest);
4444

0 commit comments

Comments
 (0)