Skip to content

Commit 59306eb

Browse files
author
klapaudius
committed
Refactor MCP transport with ping/pong handling and client tracking
Added support for server-client connection health monitoring through ping/pong mechanism integrated with `SseTransportInterface`. Updated handlers to manage message IDs, ensure client tracking, and maintain active connections. Introduced tests to validate ping intervals, connection states, and message composition. @see https://modelcontextprotocol.io/specification/2024-11-05/basic/utilities/ping
1 parent 57c5520 commit 59306eb

File tree

16 files changed

+423
-25
lines changed

16 files changed

+423
-25
lines changed

src/Data/Requests/NotificationData.php

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,17 @@
1010
*/
1111
class NotificationData
1212
{
13+
14+
/**
15+
* The client ID of the client that sent the notification.
16+
*
17+
*/
18+
public string $clientId;
19+
1320
/**
1421
* The method to be invoked.
1522
*/
16-
public string $method;
23+
public ?string $method = null;
1724

1825
/**
1926
* The JSON-RPC version string. MUST be "2.0".
@@ -30,11 +37,11 @@ class NotificationData
3037
/**
3138
* Constructor for NotificationData.
3239
*
33-
* @param string $method The notification method name.
40+
* @param string|null $method The notification method name.
3441
* @param string $jsonRpc The JSON-RPC version (should be "2.0").
3542
* @param array<mixed>|null $params The notification parameters.
3643
*/
37-
public function __construct(string $method, string $jsonRpc, ?array $params)
44+
public function __construct(?string $method, string $jsonRpc, ?array $params)
3845
{
3946
$this->method = $method;
4047
$this->jsonRpc = $jsonRpc;
@@ -49,8 +56,11 @@ public function __construct(string $method, string $jsonRpc, ?array $params)
4956
*/
5057
public static function fromArray(array $data): self
5158
{
59+
if (isset($data['clientId']) && empty($data['params'])) {
60+
$data['params'] = ['clientId' => $data['clientId']];
61+
}
5262
return new self(
53-
method: $data['method'],
63+
method: $data['method'] ?? null,
5464
jsonRpc: $data['jsonrpc'],
5565
params: $data['params'] ?? null
5666
);

src/Protocol/Handlers/NotificationHandler.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,5 +6,5 @@ interface NotificationHandler
66
{
77
public function execute(?array $params = null): array;
88

9-
public function isHandle(string $method): bool;
9+
public function isHandle(?string $method): bool;
1010
}

src/Protocol/Handlers/RequestHandler.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44

55
interface RequestHandler
66
{
7-
public function execute(string $method, ?array $params = null): array;
7+
public function execute(string $method, string|int $messageId, ?array $params = null): array;
88

99
public function isHandle(string $method): bool;
1010
}

src/Protocol/MCPProtocol.php

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,9 @@
1212
use KLP\KlpMcpServer\Exceptions\ToolParamsValidatorException;
1313
use KLP\KlpMcpServer\Protocol\Handlers\NotificationHandler;
1414
use KLP\KlpMcpServer\Protocol\Handlers\RequestHandler;
15+
use KLP\KlpMcpServer\Server\Notification\PongHandler;
16+
use KLP\KlpMcpServer\Server\Request\PingHandler;
17+
use KLP\KlpMcpServer\Transports\SseTransportInterface;
1518
use KLP\KlpMcpServer\Transports\TransportInterface;
1619
use KLP\KlpMcpServer\Utils\DataUtil;
1720

@@ -41,6 +44,10 @@ final class MCPProtocol implements MCPProtocolInterface
4144
public function __construct(private readonly TransportInterface $transport)
4245
{
4346
$this->transport->onMessage([$this, 'handleMessage']);
47+
if ($this->transport instanceof SseTransportInterface) {
48+
$this->registerNotificationHandler(new PongHandler($this->transport->getAdapter()));
49+
$this->registerRequestHandler(new PingHandler( $this->transport ));
50+
}
4451
}
4552

4653
/**
@@ -125,7 +132,7 @@ public function handleMessage(string $clientId, array $message): void
125132
throw new JsonRpcErrorException(message: 'Invalid Request: Not a valid JSON-RPC 2.0 message', code: JsonRpcErrorCode::INVALID_REQUEST, data: $message);
126133
}
127134

128-
$requestData = DataUtil::makeRequestData(message: $message);
135+
$requestData = DataUtil::makeRequestData(clientId: $clientId, message: $message);
129136
if ($requestData instanceof RequestData) {
130137
$this->handleRequestProcess(clientId: $clientId, requestData: $requestData);
131138

@@ -159,7 +166,7 @@ private function handleRequestProcess(string $clientId, RequestData $requestData
159166
try {
160167
foreach ($this->requestHandlers as $handler) {
161168
if ($handler->isHandle(method: $requestData->method)) {
162-
$result = $handler->execute(method: $requestData->method, params: $requestData->params);
169+
$result = $handler->execute(method: $requestData->method, messageId: $requestData->id, params: $requestData->params);
163170

164171
$resultResource = new JsonRpcResultResource(id: $requestData->id, result: $result);
165172
$this->pushMessage(clientId: $clientId, message: $resultResource);
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
<?php
2+
3+
namespace KLP\KlpMcpServer\Server\Notification;
4+
5+
use Exception;
6+
use KLP\KlpMcpServer\Protocol\Handlers\NotificationHandler;
7+
use KLP\KlpMcpServer\Transports\SseAdapters\SseAdapterInterface;
8+
9+
10+
/**
11+
* Handles the processing of "Pong" notifications for managing client-server interactions.
12+
* This class specifically implements the NotificationHandler interface.
13+
*
14+
* @see https://modelcontextprotocol.io/specification/2024-11-05/basic/utilities/ping
15+
*/
16+
readonly class PongHandler implements NotificationHandler
17+
{
18+
public function __construct(private ?SseAdapterInterface $adapter = null)
19+
{
20+
}
21+
22+
public function isHandle(?string $method): bool
23+
{
24+
return is_null($method);
25+
}
26+
27+
/**
28+
* Executes a specified method with optional parameters.
29+
*
30+
* @param array|null $params Optional parameters to pass to the method. Null if not provided.
31+
* @return array The result of the execution, returned as an array.
32+
*/
33+
public function execute(?array $params = null): array
34+
{
35+
try {
36+
$this->adapter?->storeLastPongResponseTimestamp($params['clientId'], time());
37+
} catch (Exception) {
38+
// Nothing to do here the client will be disconnected anyway
39+
}
40+
41+
return [];
42+
}
43+
}

src/Server/Request/InitializeHandler.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ public function isHandle(string $method): bool
2424
/**
2525
* @throws JsonRpcErrorException
2626
*/
27-
public function execute(string $method, ?array $params = null): array
27+
public function execute(string $method, string|int $messageId, ?array $params = null): array
2828
{
2929
$data = InitializeData::fromArray(data: $params);
3030
$result = $this->server->initialize(data: $data);

src/Server/Request/PingHandler.php

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
<?php
2+
3+
namespace KLP\KlpMcpServer\Server\Request;
4+
5+
use KLP\KlpMcpServer\Data\Requests\InitializeData;
6+
use KLP\KlpMcpServer\Exceptions\JsonRpcErrorException;
7+
use KLP\KlpMcpServer\Protocol\Handlers\RequestHandler;
8+
use KLP\KlpMcpServer\Server\MCPServer;
9+
use KLP\KlpMcpServer\Transports\SseTransportInterface;
10+
11+
class PingHandler implements RequestHandler
12+
{
13+
14+
public function __construct(private readonly SseTransportInterface $transport)
15+
{
16+
}
17+
18+
public function isHandle(string $method): bool
19+
{
20+
return $method === 'ping';
21+
}
22+
23+
24+
public function execute(string $method, string|int $messageId, ?array $params = null): array
25+
{
26+
$this->transport->send(["id" => $messageId, "jsonrpc" => "2.0", "result" => []]);
27+
return [];
28+
}
29+
}

src/Server/Request/ToolsCallHandler.php

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,13 +27,14 @@ public function isHandle(string $method): bool
2727
* Executes a specified method with provided parameters and returns the result.
2828
*
2929
* @param string $method The method to be executed.
30+
* @param string|int $messageId The ID of the request message. Used for response identification.
3031
* @param array|null $params An associative array of parameters required for execution. Must include 'name' as the tool identifier and optionally 'arguments'.
3132
* @return array The response array containing the execution result, which may vary based on the method.
3233
*
3334
* @throws JsonRpcErrorException If the tool name is missing or the tool is not found
3435
* @throws ToolParamsValidatorException If the provided arguments are invalid.
3536
*/
36-
public function execute(string $method, ?array $params = null): array
37+
public function execute(string $method, string|int $messageId, ?array $params = null): array
3738
{
3839
$name = $params['name'] ?? null;
3940
if ($name === null) {

src/Server/Request/ToolsListHandler.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ public function isHandle(string $method): bool
1919
return $method === 'tools/list';
2020
}
2121

22-
public function execute(string $method, ?array $params = null): array
22+
public function execute(string $method, string|int $messageId, ?array $params = null): array
2323
{
2424
return [
2525
'tools' => $this->toolRepository->getToolSchemas(),

src/Transports/SseAdapters/RedisAdapter.php

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -194,4 +194,54 @@ public function getMessageCount(string $clientId): int
194194
return 0;
195195
}
196196
}
197+
198+
/**
199+
* Store the last pong response timestamp for a specific client
200+
*
201+
* @param string $clientId The unique identifier for the client
202+
* @param int|null $timestamp The timestamp to store (defaults to current time if null)
203+
*
204+
* @throws Exception If the timestamp cannot be stored
205+
*/
206+
public function storeLastPongResponseTimestamp(string $clientId, ?int $timestamp = null): void
207+
{
208+
try {
209+
$key = $this->getQueueKey($clientId).":last_pong";
210+
$timestamp = $timestamp ?? time();
211+
212+
$this->redis->set($key, $timestamp);
213+
$this->redis->expire($key, $this->messageTtl);
214+
215+
} catch (Exception $e) {
216+
$this->logger?->error('Failed to store last pong timestamp: '.$e->getMessage());
217+
throw new Exception('Failed to store last pong timestamp: '.$e->getMessage());
218+
}
219+
}
220+
221+
/**
222+
* Get the last pong response timestamp for a specific client
223+
*
224+
* @param string $clientId The unique identifier for the client
225+
* @return int|null The timestamp or null if no timestamp is stored
226+
*
227+
* @throws Exception If the timestamp cannot be retrieved
228+
*/
229+
public function getLastPongResponseTimestamp(string $clientId): ?int
230+
{
231+
try {
232+
$key = $this->getQueueKey($clientId).":last_pong";
233+
234+
$timestamp = $this->redis->get($key);
235+
236+
if ($timestamp === false) {
237+
return null;
238+
}
239+
240+
return (int) $timestamp;
241+
242+
} catch (Exception $e) {
243+
$this->logger?->error('Failed to get last pong timestamp: '.$e->getMessage());
244+
throw new Exception('Failed to get last pong timestamp: '.$e->getMessage());
245+
}
246+
}
197247
}

0 commit comments

Comments
 (0)