diff --git a/src/Request.php b/src/Request.php index 2a8a022..5fdb28d 100644 --- a/src/Request.php +++ b/src/Request.php @@ -23,6 +23,7 @@ class Request implements Arrayable */ public function __construct( protected array $arguments = [], + protected ?string $sessionId = null ) { // } @@ -81,4 +82,9 @@ public function user(?string $guard = null): ?Authenticatable return call_user_func($auth->userResolver(), $guard); } + + public function sessionId(): ?string + { + return $this->sessionId; + } } diff --git a/src/Server.php b/src/Server.php index 4581ef0..f7bdce2 100644 --- a/src/Server.php +++ b/src/Server.php @@ -5,6 +5,7 @@ namespace Laravel\Mcp; use Illuminate\Container\Container; +use Illuminate\Support\Str; use Laravel\Mcp\Server\Contracts\Method; use Laravel\Mcp\Server\Contracts\Transport; use Laravel\Mcp\Server\Exceptions\JsonRpcException; @@ -161,7 +162,7 @@ public function handle(string $rawMessage): void } $request = isset($jsonRequest['id']) - ? JsonRpcRequest::from($jsonRequest) + ? JsonRpcRequest::from($jsonRequest, $this->transport->sessionId()) : JsonRpcNotification::from($jsonRequest); if ($request instanceof JsonRpcNotification) { @@ -249,7 +250,12 @@ protected function handleInitializeMessage(JsonRpcRequest $request, ServerContex { $response = (new Initialize)->handle($request, $context); - $this->transport->send($response->toJson()); + $this->transport->send($response->toJson(), $this->generateSessionId()); + } + + protected function generateSessionId(): string + { + return Str::uuid()->toString(); } /** diff --git a/src/Server/Contracts/Transport.php b/src/Server/Contracts/Transport.php index e5d31a4..60417ff 100644 --- a/src/Server/Contracts/Transport.php +++ b/src/Server/Contracts/Transport.php @@ -12,7 +12,7 @@ public function onReceive(Closure $handler): void; public function run(); // @phpstan-ignore-line - public function send(string $message): void; + public function send(string $message, ?string $sessionId = null): void; public function sessionId(): ?string; diff --git a/src/Server/Transport/JsonRpcRequest.php b/src/Server/Transport/JsonRpcRequest.php index 1d4a5eb..1ee3287 100644 --- a/src/Server/Transport/JsonRpcRequest.php +++ b/src/Server/Transport/JsonRpcRequest.php @@ -16,6 +16,7 @@ public function __construct( public int|string $id, public string $method, public array $params, + public ?string $sessionId = null ) { // } @@ -25,7 +26,7 @@ public function __construct( * * @throws JsonRpcException */ - public static function from(array $jsonRequest): static + public static function from(array $jsonRequest, ?string $sessionId = null): static { $requestId = $jsonRequest['id']; @@ -44,7 +45,8 @@ public static function from(array $jsonRequest): static return new static( id: $requestId, method: $jsonRequest['method'], - params: $jsonRequest['params'] ?? [] + params: $jsonRequest['params'] ?? [], + sessionId: $sessionId, ); } @@ -60,6 +62,6 @@ public function get(string $key, mixed $default = null): mixed public function toRequest(): Request { - return new Request($this->params['arguments'] ?? []); + return new Request($this->params['arguments'] ?? [], $this->sessionId); } } diff --git a/tests/Feature/Console/StartCommandTest.php b/tests/Feature/Console/StartCommandTest.php index a2003c1..b867cc0 100644 --- a/tests/Feature/Console/StartCommandTest.php +++ b/tests/Feature/Console/StartCommandTest.php @@ -1,6 +1,7 @@ json())->toEqual(expectedInitializeResponse()); }); +it('receives a session id over http', function (): void { + /** @var TestResponse $response */ + $response = $this->postJson('test-mcp', initializeMessage()); + + $response->assertHeader('Mcp-Session-Id'); + + // https://modelcontextprotocol.io/specification/2025-06-18/basic/transports#session-management + expect($response->headers->get('Mcp-Session-Id'))->toMatch('/^[\x21-\x7E]+$/'); +}); + it('can list resources over http', function (): void { $sessionId = initializeHttpConnection($this); diff --git a/tests/Fixtures/ArrayTransport.php b/tests/Fixtures/ArrayTransport.php index 55251e7..3fdf40a 100644 --- a/tests/Fixtures/ArrayTransport.php +++ b/tests/Fixtures/ArrayTransport.php @@ -29,7 +29,7 @@ public function run(): void // } - public function send(string $message): void + public function send(string $message, ?string $sessionId = null): void { $this->sent[] = $message; } diff --git a/tests/Fixtures/ExampleServer.php b/tests/Fixtures/ExampleServer.php index 9aa39fa..926f9b2 100644 --- a/tests/Fixtures/ExampleServer.php +++ b/tests/Fixtures/ExampleServer.php @@ -16,4 +16,9 @@ class ExampleServer extends Server DailyPlanResource::class, RecentMeetingRecordingResource::class, ]; + + protected function generateSessionId(): string + { + return 'overridden-'.uniqid(); + } } diff --git a/tests/Unit/Transport/JsonRpcRequestTest.php b/tests/Unit/Transport/JsonRpcRequestTest.php index ed8f364..e234f4b 100644 --- a/tests/Unit/Transport/JsonRpcRequestTest.php +++ b/tests/Unit/Transport/JsonRpcRequestTest.php @@ -22,6 +22,17 @@ ->and($request->params)->toEqual(['name' => 'echo', 'arguments' => ['message' => 'Hello, world!']]); }); +it('stores session id when provided', function (): void { + $sessionId = 'i-am-your-session-luke'; + $request = JsonRpcRequest::from([ + 'jsonrpc' => '2.0', + 'id' => 1, + 'method' => 'tools/call', + ], $sessionId); + + expect($request->sessionId)->toBe($sessionId); +}); + it('throws exception for missing jsonrpc version', function (): void { $this->expectException(JsonRpcException::class); $this->expectExceptionMessage('Invalid Request: The [jsonrpc] member must be exactly [2.0].');