diff --git a/src/Transport/HttpTransport.php b/src/Transport/HttpTransport.php index 1a3cb22..a78fc74 100644 --- a/src/Transport/HttpTransport.php +++ b/src/Transport/HttpTransport.php @@ -14,6 +14,10 @@ class HttpTransport implements Transport { protected int $requestId = 0; + protected bool $started = false; + + protected ?string $sessionId = null; + /** * @param array $config */ @@ -22,7 +26,49 @@ public function __construct( ) {} #[\Override] - public function start(): void {} + public function start(): void + { + if ($this->started) { + return; + } + + $this->requestId++; + + $initializePayload = [ + 'jsonrpc' => '2.0', + 'id' => (string) $this->requestId, + 'method' => 'initialize', + 'params' => [ + 'protocolVersion' => '2025-03-26', + 'capabilities' => new \stdClass, + 'clientInfo' => [ + 'name' => 'prism-relay', + 'version' => '1.0.0', + ], + ], + ]; + + $initializeResponse = $this->sendHttpRequest($initializePayload); + $this->validateHttpResponse($initializeResponse); + $this->sessionId = $initializeResponse->header('Mcp-Session-Id'); + + $initializeJson = $this->parseJsonRpcResponse($initializeResponse); + $this->validateJsonRpcResponse($initializeJson); + + if (isset($initializeJson['error'])) { + $this->handleJsonRpcError($initializeJson['error']); + } + + $initializedNotification = [ + 'jsonrpc' => '2.0', + 'method' => 'notifications/initialized', + ]; + + $notificationResponse = $this->sendHttpRequest($initializedNotification); + $this->validateHttpResponse($notificationResponse); + + $this->started = true; + } /** * @param array $params @@ -33,6 +79,8 @@ public function start(): void {} #[\Override] public function sendRequest(string $method, array $params = []): array { + $this->start(); + $this->requestId++; $requestPayload = $this->createRequestPayload($method, $params); @@ -65,7 +113,8 @@ protected function createRequestPayload(string $method, array $params = []): arr 'jsonrpc' => '2.0', 'id' => (string) $this->requestId, 'method' => $method, - 'params' => $params, + // Some MCP HTTP servers require params to be an object, not an array. + 'params' => $params === [] ? new \stdClass : $params, ]; } @@ -74,18 +123,23 @@ protected function createRequestPayload(string $method, array $params = []): arr */ protected function sendHttpRequest(array $payload): Response { + $headers = array_merge([ + // MCP Streamable HTTP requires both content types to be accepted. + 'Accept' => 'application/json, text/event-stream', + ], $this->getHeaders()); + + if ($this->sessionId) { + $headers['Mcp-Session-Id'] = $this->sessionId; + } + $token = $this->resolveAuthToken(); return Http::timeout($this->getTimeout()) - ->acceptJson() + ->withHeaders($headers) ->when( $token !== null, fn ($http) => $http->withToken((string) $token) ) - ->when( - $this->hasHeaders(), - fn ($http) => $http->withHeaders($this->getHeaders()) - ) ->post($this->getServerUrl(), $payload); } @@ -158,7 +212,7 @@ protected function getServerUrl(): string protected function processResponse(Response $response): array { $this->validateHttpResponse($response); - $jsonResponse = $response->json(); + $jsonResponse = $this->parseJsonRpcResponse($response); $this->validateJsonRpcResponse($jsonResponse); if (isset($jsonResponse['error'])) { @@ -168,6 +222,84 @@ protected function processResponse(Response $response): array return $jsonResponse['result'] ?? []; } + /** + * @return array + * + * @throws TransportException + */ + protected function parseJsonRpcResponse(Response $response): array + { + $contentType = strtolower($response->header('Content-Type')); + + if (str_contains($contentType, 'text/event-stream')) { + return $this->parseSseJsonRpcResponse($response->body()); + } + + $json = $response->json(); + + if (! is_array($json)) { + throw new TransportException('Invalid JSON response received from MCP server'); + } + + return $json; + } + + /** + * Parse JSON-RPC payload from an SSE response body. + * + * @return array + * + * @throws TransportException + */ + protected function parseSseJsonRpcResponse(string $body): array + { + $lines = preg_split("/\r\n|\n|\r/", $body) ?: []; + $dataLines = []; + $messages = []; + + foreach ($lines as $line) { + $line = trim($line); + + if ($line === '') { + if ($dataLines !== []) { + $decoded = json_decode(implode("\n", $dataLines), true); + + if (is_array($decoded) && isset($decoded['jsonrpc'])) { + $messages[] = $decoded; + } + + $dataLines = []; + } + + continue; + } + + if (str_starts_with($line, 'data:')) { + $dataLines[] = ltrim(substr($line, 5)); + } + } + + if ($dataLines !== []) { + $decoded = json_decode(implode("\n", $dataLines), true); + + if (is_array($decoded) && isset($decoded['jsonrpc'])) { + $messages[] = $decoded; + } + } + + if ($messages === []) { + throw new TransportException('No JSON-RPC message found in SSE response'); + } + + foreach ($messages as $message) { + if (isset($message['id']) && (string) $message['id'] === (string) $this->requestId) { + return $message; + } + } + + return end($messages) ?: []; + } + /** * @throws AuthorizationException * @throws TransportException diff --git a/tests/Unit/RelayFactoryWithTokenTest.php b/tests/Unit/RelayFactoryWithTokenTest.php index d8fc017..7b9a436 100644 --- a/tests/Unit/RelayFactoryWithTokenTest.php +++ b/tests/Unit/RelayFactoryWithTokenTest.php @@ -34,19 +34,22 @@ it('RelayBuilder::tools calls Relay::tools with the token injected', function (): void { Http::fake([ - 'http://example.com/api' => Http::response([ - 'jsonrpc' => '2.0', - 'id' => '1', - 'result' => [ - 'tools' => [ - [ - 'name' => 'test_tool', - 'description' => 'A test tool', - 'inputSchema' => ['type' => 'object', 'properties' => []], + 'http://example.com/api' => Http::sequence() + ->push(['jsonrpc' => '2.0', 'id' => '1', 'result' => ['protocolVersion' => '2025-03-26']]) + ->push('', 202) + ->push([ + 'jsonrpc' => '2.0', + 'id' => '2', + 'result' => [ + 'tools' => [ + [ + 'name' => 'test_tool', + 'description' => 'A test tool', + 'inputSchema' => ['type' => 'object', 'properties' => []], + ], ], ], - ], - ]), + ]), ]); $factory = new RelayFactory; diff --git a/tests/Unit/RelayOAuthTest.php b/tests/Unit/RelayOAuthTest.php index 1cbfbf2..001d71e 100644 --- a/tests/Unit/RelayOAuthTest.php +++ b/tests/Unit/RelayOAuthTest.php @@ -47,19 +47,22 @@ it('withToken injects token into HTTP requests when tools() is called', function (): void { Http::fake([ - 'http://example.com/api' => Http::response([ - 'jsonrpc' => '2.0', - 'id' => '1', - 'result' => [ - 'tools' => [ - [ - 'name' => 'test_tool', - 'description' => 'A test tool', - 'inputSchema' => ['type' => 'object', 'properties' => []], + 'http://example.com/api' => Http::sequence() + ->push(['jsonrpc' => '2.0', 'id' => '1', 'result' => ['protocolVersion' => '2025-03-26']]) + ->push('', 202) + ->push([ + 'jsonrpc' => '2.0', + 'id' => '2', + 'result' => [ + 'tools' => [ + [ + 'name' => 'test_tool', + 'description' => 'A test tool', + 'inputSchema' => ['type' => 'object', 'properties' => []], + ], ], ], - ], - ]), + ]), ]); $relay = new Relay($this->serverName); diff --git a/tests/Unit/Transport/HttpTransportOAuthTest.php b/tests/Unit/Transport/HttpTransportOAuthTest.php index 773f1a8..7281660 100644 --- a/tests/Unit/Transport/HttpTransportOAuthTest.php +++ b/tests/Unit/Transport/HttpTransportOAuthTest.php @@ -10,11 +10,10 @@ it('sends access_token as Bearer Authorization header', function (): void { Http::fake([ - 'http://example.com/api' => Http::response([ - 'jsonrpc' => '2.0', - 'id' => '1', - 'result' => ['status' => 'success'], - ]), + 'http://example.com/api' => Http::sequence() + ->push(['jsonrpc' => '2.0', 'id' => '1', 'result' => ['protocolVersion' => '2025-03-26']]) + ->push('', 202) + ->push(['jsonrpc' => '2.0', 'id' => '2', 'result' => ['status' => 'success']]), ]); $transport = new HttpTransport([ @@ -30,11 +29,10 @@ it('uses access_token over api_key when both are present', function (): void { Http::fake([ - 'http://example.com/api' => Http::response([ - 'jsonrpc' => '2.0', - 'id' => '1', - 'result' => ['status' => 'success'], - ]), + 'http://example.com/api' => Http::sequence() + ->push(['jsonrpc' => '2.0', 'id' => '1', 'result' => ['protocolVersion' => '2025-03-26']]) + ->push('', 202) + ->push(['jsonrpc' => '2.0', 'id' => '2', 'result' => ['status' => 'success']]), ]); $transport = new HttpTransport([ @@ -51,11 +49,10 @@ it('falls back to api_key when no access_token is set', function (): void { Http::fake([ - 'http://example.com/api' => Http::response([ - 'jsonrpc' => '2.0', - 'id' => '1', - 'result' => ['status' => 'success'], - ]), + 'http://example.com/api' => Http::sequence() + ->push(['jsonrpc' => '2.0', 'id' => '1', 'result' => ['protocolVersion' => '2025-03-26']]) + ->push('', 202) + ->push(['jsonrpc' => '2.0', 'id' => '2', 'result' => ['status' => 'success']]), ]); $transport = new HttpTransport([ diff --git a/tests/Unit/Transport/HttpTransportTest.php b/tests/Unit/Transport/HttpTransportTest.php index 0a71f0f..d62f59d 100644 --- a/tests/Unit/Transport/HttpTransportTest.php +++ b/tests/Unit/Transport/HttpTransportTest.php @@ -2,225 +2,212 @@ declare(strict_types=1); -use Illuminate\Http\Client\Response; use Illuminate\Support\Facades\Http; +use Prism\Relay\Exceptions\TransportException; use Prism\Relay\Transport\HttpTransport; -beforeEach(function (): void { +it('starts by sending initialize and initialized notification', function (): void { Http::fake([ - '*' => Http::response([ - 'jsonrpc' => '2.0', - 'id' => '1', - 'result' => ['status' => 'success'], - ]), + 'http://example.com/api' => Http::sequence() + ->push([ + 'jsonrpc' => '2.0', + 'id' => '1', + 'result' => ['protocolVersion' => '2025-03-26'], + ], 200, ['Mcp-Session-Id' => 'session-123']) + ->push('', 202), ]); -}); -it('starts with no operation', function (): void { $transport = new HttpTransport([ 'url' => 'http://example.com/api', - 'api_key' => 'test-key', - 'timeout' => 30, ]); - // Should not throw exception $transport->start(); - expect(true)->toBeTrue(); -}); - -it('closes with no operation', function (): void { - $transport = new HttpTransport([ - 'url' => 'http://example.com/api', - 'api_key' => 'test-key', - 'timeout' => 30, - ]); - // Should not throw exception - $transport->close(); - expect(true)->toBeTrue(); + Http::assertSentCount(2); + Http::assertSent(fn ($request): bool => (json_decode((string) $request->body(), true)['method'] ?? null) === 'initialize'); + Http::assertSent(fn ($request): bool => (json_decode((string) $request->body(), true)['method'] ?? null) === 'notifications/initialized'); }); -it('sends requests properly', function (): void { - // Set up fake HTTP +it('does not initialize twice when start is called repeatedly', function (): void { Http::fake([ - 'http://example.com/api' => Http::response([ - 'jsonrpc' => '2.0', - 'id' => '1', - 'result' => ['status' => 'success'], - ]), + 'http://example.com/api' => Http::sequence() + ->push([ + 'jsonrpc' => '2.0', + 'id' => '1', + 'result' => ['protocolVersion' => '2025-03-26'], + ], 200) + ->push('', 202), ]); $transport = new HttpTransport([ 'url' => 'http://example.com/api', - 'api_key' => 'test-key', - 'timeout' => 30, ]); - $result = $transport->sendRequest('test/method', ['param' => 'value']); - - expect($result)->toBe(['status' => 'success']); + $transport->start(); + $transport->start(); - // Verify the request was sent as expected - Http::assertSent(fn ($request): bool => $request->url() === 'http://example.com/api' && - $request['method'] === 'test/method' && - $request['params']['param'] === 'value'); + Http::assertSentCount(2); }); -it('throws exception on HTTP failure', function (): void { - // This test simply verifies that HTTP failures are handled - // Since we can't easily mock a failed response in this context, - // just verify the transport can be created - $transport = new HttpTransport([ - 'url' => 'http://example.com/api', - 'timeout' => 30, +it('sends initialize flow and request payload with object params', function (): void { + Http::fake([ + 'http://example.com/api' => Http::sequence() + ->push([ + 'jsonrpc' => '2.0', + 'id' => '1', + 'result' => ['protocolVersion' => '2025-03-26'], + ], 200, ['Mcp-Session-Id' => 'session-123']) + ->push('', 202) + ->push([ + 'jsonrpc' => '2.0', + 'id' => '2', + 'result' => ['status' => 'success'], + ]), ]); - expect($transport)->toBeInstanceOf(HttpTransport::class); -}); - -it('handles invalid JSON-RPC responses', function (): void { - // Just verify the transport creation since we can't easily - // mock invalid responses in the test context $transport = new HttpTransport([ 'url' => 'http://example.com/api', - 'timeout' => 30, ]); - expect($transport)->toBeInstanceOf(HttpTransport::class); -}); - -it('handles JSON-RPC errors', function (): void { - // Just verify the transport creation since we can't easily - // mock error responses in the test context - $transport = new HttpTransport([ - 'url' => 'http://example.com/api', - 'timeout' => 30, - ]); + $result = $transport->sendRequest('tools/list'); - expect($transport)->toBeInstanceOf(HttpTransport::class); + expect($result)->toBe(['status' => 'success']); + Http::assertSent(function ($request): bool { + $body = json_decode((string) $request->body()); + + return isset($body->method, $body->params) + && $body->method === 'tools/list' + && is_object($body->params) + && get_object_vars($body->params) === []; + }); }); -it('supports API key authentication', function (): void { +it('sets accept header for json and sse', function (): void { $transport = new HttpTransport([ 'url' => 'http://example.com/api', - 'api_key' => 'test-key', - 'timeout' => 30, ]); - // Laravel's HTTP facade makes this easier Http::fake([ - 'http://example.com/api' => Http::response([ - 'jsonrpc' => '2.0', - 'id' => '1', - 'result' => ['status' => 'success'], - ]), + 'http://example.com/api' => Http::sequence() + ->push([ + 'jsonrpc' => '2.0', + 'id' => '1', + 'result' => ['protocolVersion' => '2025-03-26'], + ]) + ->push('', 202) + ->push([ + 'jsonrpc' => '2.0', + 'id' => '2', + 'result' => ['status' => 'success'], + ]), ]); $transport->sendRequest('test/method'); - Http::assertSent(fn ($request) => $request->hasHeader('Authorization', 'Bearer test-key')); + Http::assertSent(fn ($request): bool => $request->hasHeader('Accept', 'application/json, text/event-stream')); }); -it('works without API key', function (): void { - $transport = new HttpTransport([ - 'url' => 'http://example.com/api', - 'timeout' => 30, - ]); +it('attaches session id header after initialize', function (): void { + $requests = []; - Http::fake([ - 'http://example.com/api' => Http::response([ - 'jsonrpc' => '2.0', - 'id' => '1', - 'result' => ['status' => 'success'], - ]), - ]); + Http::fake(function ($request) use (&$requests) { + $requests[] = $request; - $transport->sendRequest('test/method'); + $method = json_decode((string) $request->body(), true)['method'] ?? null; - Http::assertSent(fn ($request): bool => ! $request->hasHeader('Authorization')); -}); + if ($method === 'initialize') { + return Http::response([ + 'jsonrpc' => '2.0', + 'id' => '1', + 'result' => ['protocolVersion' => '2025-03-26'], + ], 200, ['Mcp-Session-Id' => 'session-123']); + } -it('uses default timeout when not specified', function (): void { - $transport = new HttpTransport([ - 'url' => 'http://example.com/api', - ]); + if ($method === 'notifications/initialized') { + return Http::response('', 202); + } - Http::fake([ - 'http://example.com/api' => Http::response([ + return Http::response([ 'jsonrpc' => '2.0', - 'id' => '1', + 'id' => '2', 'result' => ['status' => 'success'], - ]), - ]); + ]); + }); - $result = $transport->sendRequest('test/method'); - expect($result)->toBeArray(); + $transport = new HttpTransport(['url' => 'http://example.com/api']); + $transport->sendRequest('tools/list'); - // Can't easily test the timeout but at least we can confirm it doesn't break + expect($requests)->toHaveCount(3) + ->and($requests[2]->hasHeader('Mcp-Session-Id', 'session-123'))->toBeTrue(); }); -it('can make requests to tools/list', function (): void { - $transport = new HttpTransport([ - 'url' => 'http://example.com/api', - ]); - - // Simply verify we can send the request and get any result - // The actual response format depends on the fake HTTP response - $result = $transport->sendRequest('tools/list'); - expect($result)->toBeArray(); -}); +it('parses json-rpc payload from sse response', function (): void { + $transport = new HttpTransport(['url' => 'http://example.com/api']); -it('can make requests to tools/call', function (): void { - $transport = new HttpTransport([ - 'url' => 'http://example.com/api', + Http::fake([ + 'http://example.com/api' => Http::sequence() + ->push([ + 'jsonrpc' => '2.0', + 'id' => '1', + 'result' => ['protocolVersion' => '2025-03-26'], + ]) + ->push('', 202) + ->push( + "event: message\n". + "data: {\"jsonrpc\":\"2.0\",\"id\":\"2\",\"result\":{\"status\":\"success\"}}\n\n", + 200, + ['Content-Type' => 'text/event-stream'] + ), ]); - // Simply verify we can send the request and get any result - $result = $transport->sendRequest('tools/call', [ - 'name' => 'test_tool', - 'arguments' => ['param1' => 'value1'], - ]); + $result = $transport->sendRequest('tools/list'); - expect($result)->toBeArray(); + expect($result)->toBe(['status' => 'success']); }); -it('sends requests with JSON Accept header', function (): void { - $transport = new HttpTransport([ - 'url' => 'http://example.com/api', - 'timeout' => 30, - ]); +it('throws on invalid sse payload', function (): void { + $transport = new HttpTransport(['url' => 'http://example.com/api']); Http::fake([ - 'http://example.com/api' => Http::response([ - 'jsonrpc' => '2.0', - 'id' => '1', - 'result' => ['status' => 'success'], - ]), + 'http://example.com/api' => Http::sequence() + ->push([ + 'jsonrpc' => '2.0', + 'id' => '1', + 'result' => ['protocolVersion' => '2025-03-26'], + ]) + ->push('', 202) + ->push("event: message\ndata: not-json\n\n", 200, ['Content-Type' => 'text/event-stream']), ]); - $transport->sendRequest('test/method'); - - Http::assertSent(fn ($request) => $request->hasHeader('Accept', 'application/json')); -}); + $transport->sendRequest('tools/list'); +})->throws(TransportException::class, 'No JSON-RPC message found in SSE response'); -it('supports passing arbitrary request headers', function (): void { +it('supports api key and custom headers', function (): void { $transport = new HttpTransport([ 'url' => 'http://example.com/api', - 'timeout' => 30, + 'api_key' => 'test-key', 'headers' => [ 'User-Agent' => 'prism-php-relay/1.0', ], ]); Http::fake([ - 'http://example.com/api' => Http::response([ - 'jsonrpc' => '2.0', - 'id' => '1', - 'result' => ['status' => 'success'], - ]), + 'http://example.com/api' => Http::sequence() + ->push([ + 'jsonrpc' => '2.0', + 'id' => '1', + 'result' => ['protocolVersion' => '2025-03-26'], + ]) + ->push('', 202) + ->push([ + 'jsonrpc' => '2.0', + 'id' => '2', + 'result' => ['status' => 'success'], + ]), ]); $transport->sendRequest('test/method'); - Http::assertSent(fn ($request) => $request->hasHeader('User-Agent', 'prism-php-relay/1.0')); + Http::assertSent(fn ($request): bool => $request->hasHeader('Authorization', 'Bearer test-key')); + Http::assertSent(fn ($request): bool => $request->hasHeader('User-Agent', 'prism-php-relay/1.0')); });