This is page 7 of 7. Use http://codebase.md/php-mcp/server?lines=true&page={x} to view the full context. # Directory Structure ``` ├── .github │ └── workflows │ ├── changelog.yml │ └── tests.yml ├── .gitignore ├── .php-cs-fixer.php ├── CHANGELOG.md ├── composer.json ├── CONTRIBUTING.md ├── examples │ ├── 01-discovery-stdio-calculator │ │ ├── McpElements.php │ │ └── server.php │ ├── 02-discovery-http-userprofile │ │ ├── McpElements.php │ │ ├── server.php │ │ └── UserIdCompletionProvider.php │ ├── 03-manual-registration-stdio │ │ ├── server.php │ │ └── SimpleHandlers.php │ ├── 04-combined-registration-http │ │ ├── DiscoveredElements.php │ │ ├── ManualHandlers.php │ │ └── server.php │ ├── 05-stdio-env-variables │ │ ├── EnvToolHandler.php │ │ └── server.php │ ├── 06-custom-dependencies-stdio │ │ ├── McpTaskHandlers.php │ │ ├── server.php │ │ └── Services.php │ ├── 07-complex-tool-schema-http │ │ ├── EventTypes.php │ │ ├── McpEventScheduler.php │ │ └── server.php │ └── 08-schema-showcase-streamable │ ├── SchemaShowcaseElements.php │ └── server.php ├── LICENSE ├── phpunit.xml ├── README.md ├── src │ ├── Attributes │ │ ├── CompletionProvider.php │ │ ├── McpPrompt.php │ │ ├── McpResource.php │ │ ├── McpResourceTemplate.php │ │ ├── McpTool.php │ │ └── Schema.php │ ├── Configuration.php │ ├── Context.php │ ├── Contracts │ │ ├── CompletionProviderInterface.php │ │ ├── EventStoreInterface.php │ │ ├── LoggerAwareInterface.php │ │ ├── LoopAwareInterface.php │ │ ├── ServerTransportInterface.php │ │ ├── SessionHandlerInterface.php │ │ └── SessionInterface.php │ ├── Defaults │ │ ├── ArrayCache.php │ │ ├── BasicContainer.php │ │ ├── DefaultUuidSessionIdGenerator.php │ │ ├── EnumCompletionProvider.php │ │ ├── FileCache.php │ │ ├── InMemoryEventStore.php │ │ ├── ListCompletionProvider.php │ │ └── SystemClock.php │ ├── Dispatcher.php │ ├── Elements │ │ ├── RegisteredElement.php │ │ ├── RegisteredPrompt.php │ │ ├── RegisteredResource.php │ │ ├── RegisteredResourceTemplate.php │ │ └── RegisteredTool.php │ ├── Exception │ │ ├── ConfigurationException.php │ │ ├── DiscoveryException.php │ │ ├── McpServerException.php │ │ ├── ProtocolException.php │ │ └── TransportException.php │ ├── Protocol.php │ ├── Registry.php │ ├── Server.php │ ├── ServerBuilder.php │ ├── Session │ │ ├── ArraySessionHandler.php │ │ ├── CacheSessionHandler.php │ │ ├── Session.php │ │ ├── SessionManager.php │ │ └── SubscriptionManager.php │ ├── Transports │ │ ├── HttpServerTransport.php │ │ ├── StdioServerTransport.php │ │ └── StreamableHttpServerTransport.php │ └── Utils │ ├── Discoverer.php │ ├── DocBlockParser.php │ ├── HandlerResolver.php │ ├── SchemaGenerator.php │ └── SchemaValidator.php └── tests ├── Fixtures │ ├── Discovery │ │ ├── DiscoverablePromptHandler.php │ │ ├── DiscoverableResourceHandler.php │ │ ├── DiscoverableTemplateHandler.php │ │ ├── DiscoverableToolHandler.php │ │ ├── EnhancedCompletionHandler.php │ │ ├── InvocablePromptFixture.php │ │ ├── InvocableResourceFixture.php │ │ ├── InvocableResourceTemplateFixture.php │ │ ├── InvocableToolFixture.php │ │ ├── NonDiscoverableClass.php │ │ └── SubDir │ │ └── HiddenTool.php │ ├── Enums │ │ ├── BackedIntEnum.php │ │ ├── BackedStringEnum.php │ │ ├── PriorityEnum.php │ │ ├── StatusEnum.php │ │ └── UnitEnum.php │ ├── General │ │ ├── CompletionProviderFixture.php │ │ ├── DocBlockTestFixture.php │ │ ├── InvokableHandlerFixture.php │ │ ├── PromptHandlerFixture.php │ │ ├── RequestAttributeChecker.php │ │ ├── ResourceHandlerFixture.php │ │ ├── ToolHandlerFixture.php │ │ └── VariousTypesHandler.php │ ├── Middlewares │ │ ├── ErrorMiddleware.php │ │ ├── FirstMiddleware.php │ │ ├── HeaderMiddleware.php │ │ ├── RequestAttributeMiddleware.php │ │ ├── SecondMiddleware.php │ │ ├── ShortCircuitMiddleware.php │ │ └── ThirdMiddleware.php │ ├── Schema │ │ └── SchemaGenerationTarget.php │ ├── ServerScripts │ │ ├── HttpTestServer.php │ │ ├── StdioTestServer.php │ │ └── StreamableHttpTestServer.php │ └── Utils │ ├── AttributeFixtures.php │ ├── DockBlockParserFixture.php │ └── SchemaGeneratorFixture.php ├── Integration │ ├── DiscoveryTest.php │ ├── HttpServerTransportTest.php │ ├── SchemaGenerationTest.php │ ├── StdioServerTransportTest.php │ └── StreamableHttpServerTransportTest.php ├── Mocks │ ├── Clients │ │ ├── MockJsonHttpClient.php │ │ ├── MockSseClient.php │ │ └── MockStreamHttpClient.php │ └── Clock │ └── FixedClock.php ├── Pest.php ├── TestCase.php └── Unit ├── Attributes │ ├── CompletionProviderTest.php │ ├── McpPromptTest.php │ ├── McpResourceTemplateTest.php │ ├── McpResourceTest.php │ └── McpToolTest.php ├── ConfigurationTest.php ├── Defaults │ ├── EnumCompletionProviderTest.php │ └── ListCompletionProviderTest.php ├── DispatcherTest.php ├── Elements │ ├── RegisteredElementTest.php │ ├── RegisteredPromptTest.php │ ├── RegisteredResourceTemplateTest.php │ ├── RegisteredResourceTest.php │ └── RegisteredToolTest.php ├── ProtocolTest.php ├── RegistryTest.php ├── ServerBuilderTest.php ├── ServerTest.php ├── Session │ ├── ArraySessionHandlerTest.php │ ├── CacheSessionHandlerTest.php │ ├── SessionManagerTest.php │ └── SessionTest.php └── Utils ├── DocBlockParserTest.php ├── HandlerResolverTest.php └── SchemaValidatorTest.php ``` # Files -------------------------------------------------------------------------------- /tests/Integration/StreamableHttpServerTransportTest.php: -------------------------------------------------------------------------------- ```php 1 | <?php 2 | 3 | use PhpMcp\Server\Protocol; 4 | use PhpMcp\Server\Tests\Mocks\Clients\MockJsonHttpClient; 5 | use PhpMcp\Server\Tests\Mocks\Clients\MockStreamHttpClient; 6 | use React\ChildProcess\Process; 7 | use React\Http\Browser; 8 | use React\Http\Message\ResponseException; 9 | use React\Stream\ReadableStreamInterface; 10 | 11 | use function React\Async\await; 12 | use function React\Promise\resolve; 13 | 14 | const STREAMABLE_HTTP_SCRIPT_PATH = __DIR__ . '/../Fixtures/ServerScripts/StreamableHttpTestServer.php'; 15 | const STREAMABLE_HTTP_PROCESS_TIMEOUT = 9; 16 | const STREAMABLE_HTTP_HOST = '127.0.0.1'; 17 | const STREAMABLE_MCP_PATH = 'mcp_streamable_json_mode'; 18 | 19 | beforeEach(function () { 20 | if (!is_file(STREAMABLE_HTTP_SCRIPT_PATH)) { 21 | $this->markTestSkipped("Server script not found: " . STREAMABLE_HTTP_SCRIPT_PATH); 22 | } 23 | if (!is_executable(STREAMABLE_HTTP_SCRIPT_PATH)) { 24 | chmod(STREAMABLE_HTTP_SCRIPT_PATH, 0755); 25 | } 26 | 27 | $phpPath = PHP_BINARY ?: 'php'; 28 | $commandPhpPath = str_contains($phpPath, ' ') ? '"' . $phpPath . '"' : $phpPath; 29 | $commandScriptPath = escapeshellarg(STREAMABLE_HTTP_SCRIPT_PATH); 30 | $this->port = findFreePort(); 31 | 32 | $jsonModeCommandArgs = [ 33 | escapeshellarg(STREAMABLE_HTTP_HOST), 34 | escapeshellarg((string)$this->port), 35 | escapeshellarg(STREAMABLE_MCP_PATH), 36 | escapeshellarg('true'), // enableJsonResponse = true 37 | ]; 38 | $this->jsonModeCommand = $commandPhpPath . ' ' . $commandScriptPath . ' ' . implode(' ', $jsonModeCommandArgs); 39 | 40 | $streamModeCommandArgs = [ 41 | escapeshellarg(STREAMABLE_HTTP_HOST), 42 | escapeshellarg((string)$this->port), 43 | escapeshellarg(STREAMABLE_MCP_PATH), 44 | escapeshellarg('false'), // enableJsonResponse = false 45 | ]; 46 | $this->streamModeCommand = $commandPhpPath . ' ' . $commandScriptPath . ' ' . implode(' ', $streamModeCommandArgs); 47 | 48 | $statelessModeCommandArgs = [ 49 | escapeshellarg(STREAMABLE_HTTP_HOST), 50 | escapeshellarg((string)$this->port), 51 | escapeshellarg(STREAMABLE_MCP_PATH), 52 | escapeshellarg('true'), // enableJsonResponse = true 53 | escapeshellarg('false'), // useEventStore = false 54 | escapeshellarg('true'), // stateless = true 55 | ]; 56 | $this->statelessModeCommand = $commandPhpPath . ' ' . $commandScriptPath . ' ' . implode(' ', $statelessModeCommandArgs); 57 | 58 | $this->process = null; 59 | }); 60 | 61 | afterEach(function () { 62 | if ($this->process instanceof Process && $this->process->isRunning()) { 63 | if ($this->process->stdout instanceof ReadableStreamInterface) { 64 | $this->process->stdout->close(); 65 | } 66 | if ($this->process->stderr instanceof ReadableStreamInterface) { 67 | $this->process->stderr->close(); 68 | } 69 | 70 | $this->process->terminate(SIGTERM); 71 | try { 72 | await(delay(0.02)); 73 | } catch (\Throwable $e) { 74 | } 75 | if ($this->process->isRunning()) { 76 | $this->process->terminate(SIGKILL); 77 | } 78 | } 79 | $this->process = null; 80 | }); 81 | 82 | describe('JSON MODE', function () { 83 | beforeEach(function () { 84 | $this->process = new Process($this->jsonModeCommand, getcwd() ?: null, null, []); 85 | $this->process->start(); 86 | 87 | $this->jsonClient = new MockJsonHttpClient(STREAMABLE_HTTP_HOST, $this->port, STREAMABLE_MCP_PATH); 88 | 89 | await(delay(0.2)); 90 | }); 91 | 92 | it('server starts, initializes via POST JSON, calls a tool, and closes', function () { 93 | // 1. Initialize 94 | $initResult = await($this->jsonClient->sendRequest('initialize', [ 95 | 'protocolVersion' => Protocol::LATEST_PROTOCOL_VERSION, 96 | 'clientInfo' => ['name' => 'JsonModeClient', 'version' => '1.0'], 97 | 'capabilities' => [] 98 | ], 'init-json-1')); 99 | 100 | expect($initResult['statusCode'])->toBe(200); 101 | expect($initResult['body']['id'])->toBe('init-json-1'); 102 | expect($initResult['body'])->not->toHaveKey('error'); 103 | expect($initResult['body']['result']['protocolVersion'])->toBe(Protocol::LATEST_PROTOCOL_VERSION); 104 | expect($initResult['body']['result']['serverInfo']['name'])->toBe('StreamableHttpIntegrationServer'); 105 | expect($this->jsonClient->sessionId)->toBeString()->not->toBeEmpty(); 106 | 107 | // 2. Initialized notification 108 | $notifResult = await($this->jsonClient->sendNotification('notifications/initialized')); 109 | expect($notifResult['statusCode'])->toBe(202); 110 | 111 | // 3. Call a tool 112 | $toolResult = await($this->jsonClient->sendRequest('tools/call', [ 113 | 'name' => 'greet_streamable_tool', 114 | 'arguments' => ['name' => 'JSON Mode User'] 115 | ], 'tool-json-1')); 116 | 117 | expect($toolResult['statusCode'])->toBe(200); 118 | expect($toolResult['body']['id'])->toBe('tool-json-1'); 119 | expect($toolResult['body'])->not->toHaveKey('error'); 120 | expect($toolResult['body']['result']['content'][0]['text'])->toBe('Hello, JSON Mode User!'); 121 | 122 | // Server process is terminated in afterEach 123 | })->group('integration', 'streamable_http_json'); 124 | 125 | 126 | it('return HTTP 400 error response for invalid JSON in POST request', function () { 127 | $malformedJson = '{"jsonrpc":"2.0", "id": "bad-json-post-1", "method": "tools/list", "params": {"broken"}'; 128 | 129 | $promise = $this->jsonClient->browser->post( 130 | $this->jsonClient->baseUrl, 131 | ['Content-Type' => 'application/json', 'Accept' => 'application/json'], 132 | $malformedJson 133 | ); 134 | 135 | try { 136 | await(timeout($promise, STREAMABLE_HTTP_PROCESS_TIMEOUT - 2)); 137 | } catch (ResponseException $e) { 138 | expect($e->getResponse()->getStatusCode())->toBe(400); 139 | $bodyContent = (string) $e->getResponse()->getBody(); 140 | $decodedBody = json_decode($bodyContent, true); 141 | 142 | expect($decodedBody['jsonrpc'])->toBe('2.0'); 143 | expect($decodedBody['id'])->toBe(''); 144 | expect($decodedBody['error']['code'])->toBe(-32700); 145 | expect($decodedBody['error']['message'])->toContain('Invalid JSON'); 146 | } 147 | })->group('integration', 'streamable_http_json'); 148 | 149 | it('returns JSON-RPC error result for request for non-existent method', function () { 150 | // 1. Initialize 151 | await($this->jsonClient->sendRequest('initialize', ['protocolVersion' => Protocol::LATEST_PROTOCOL_VERSION, 'clientInfo' => ['name' => 'JsonModeClient', 'version' => '1.0'], 'capabilities' => []], 'init-json-err')); 152 | await($this->jsonClient->sendNotification('notifications/initialized')); 153 | 154 | // 2. Request non-existent method 155 | $errorResult = await($this->jsonClient->sendRequest('non/existentToolViaJson', [], 'err-meth-json-1')); 156 | 157 | expect($errorResult['statusCode'])->toBe(200); 158 | expect($errorResult['body']['id'])->toBe('err-meth-json-1'); 159 | expect($errorResult['body']['error']['code'])->toBe(-32601); 160 | expect($errorResult['body']['error']['message'])->toContain("Method 'non/existentToolViaJson' not found"); 161 | })->group('integration', 'streamable_http_json'); 162 | 163 | it('can handle batch requests correctly', function () { 164 | // 1. Initialize 165 | await($this->jsonClient->sendRequest('initialize', [ 166 | 'protocolVersion' => Protocol::LATEST_PROTOCOL_VERSION, 167 | 'clientInfo' => ['name' => 'JsonModeClient', 'version' => '1.0'], 168 | 'capabilities' => [] 169 | ], 'init-json-batch')); 170 | expect($this->jsonClient->sessionId)->toBeString()->not->toBeEmpty(); 171 | await($this->jsonClient->sendNotification('notifications/initialized')); 172 | 173 | // 2. Send Batch Request 174 | $batchRequests = [ 175 | ['jsonrpc' => '2.0', 'id' => 'batch-req-1', 'method' => 'tools/call', 'params' => ['name' => 'greet_streamable_tool', 'arguments' => ['name' => 'Batch Item 1']]], 176 | ['jsonrpc' => '2.0', 'method' => 'notifications/something'], 177 | ['jsonrpc' => '2.0', 'id' => 'batch-req-2', 'method' => 'tools/call', 'params' => ['name' => 'sum_streamable_tool', 'arguments' => ['a' => 10, 'b' => 20]]], 178 | ['jsonrpc' => '2.0', 'id' => 'batch-req-3', 'method' => 'nonexistent/method'] 179 | ]; 180 | 181 | $batchResponse = await($this->jsonClient->sendBatchRequest($batchRequests)); 182 | 183 | 184 | 185 | $findResponseById = function (array $batch, $id) { 186 | foreach ($batch as $item) { 187 | if (isset($item['id']) && $item['id'] === $id) { 188 | return $item; 189 | } 190 | } 191 | return null; 192 | }; 193 | 194 | expect($batchResponse['statusCode'])->toBe(200); 195 | expect($batchResponse['body'])->toBeArray()->toHaveCount(3); 196 | 197 | $response1 = $findResponseById($batchResponse['body'], 'batch-req-1'); 198 | $response2 = $findResponseById($batchResponse['body'], 'batch-req-2'); 199 | $response3 = $findResponseById($batchResponse['body'], 'batch-req-3'); 200 | 201 | expect($response1['result']['content'][0]['text'])->toBe('Hello, Batch Item 1!'); 202 | expect($response2['result']['content'][0]['text'])->toBe('30'); 203 | expect($response3['error']['code'])->toBe(-32601); 204 | expect($response3['error']['message'])->toContain("Method 'nonexistent/method' not found"); 205 | })->group('integration', 'streamable_http_json'); 206 | 207 | it('can handle tool list request', function () { 208 | await($this->jsonClient->sendRequest('initialize', [ 209 | 'protocolVersion' => Protocol::LATEST_PROTOCOL_VERSION, 210 | 'clientInfo' => ['name' => 'JsonModeClient', 'version' => '1.0'], 211 | 'capabilities' => [] 212 | ], 'init-json-tools')); 213 | await($this->jsonClient->sendNotification('notifications/initialized')); 214 | 215 | $toolListResult = await($this->jsonClient->sendRequest('tools/list', [], 'tool-list-json-1')); 216 | 217 | expect($toolListResult['statusCode'])->toBe(200); 218 | expect($toolListResult['body']['id'])->toBe('tool-list-json-1'); 219 | expect($toolListResult['body']['result']['tools'])->toBeArray(); 220 | expect(count($toolListResult['body']['result']['tools']))->toBe(4); 221 | expect($toolListResult['body']['result']['tools'][0]['name'])->toBe('greet_streamable_tool'); 222 | expect($toolListResult['body']['result']['tools'][1]['name'])->toBe('sum_streamable_tool'); 223 | expect($toolListResult['body']['result']['tools'][2]['name'])->toBe('tool_reads_context'); 224 | })->group('integration', 'streamable_http_json'); 225 | 226 | it('passes request in Context', function () { 227 | await($this->jsonClient->sendRequest('initialize', [ 228 | 'protocolVersion' => Protocol::LATEST_PROTOCOL_VERSION, 229 | 'clientInfo' => ['name' => 'JsonModeClient', 'version' => '1.0'], 230 | 'capabilities' => [] 231 | ], 'init-json-context')); 232 | await($this->jsonClient->sendNotification('notifications/initialized')); 233 | 234 | $toolResult = await($this->jsonClient->sendRequest('tools/call', [ 235 | 'name' => 'tool_reads_context', 236 | 'arguments' => [] 237 | ], 'tool-json-context-1', ['X-Test-Header' => 'TestValue'])); 238 | 239 | expect($toolResult['statusCode'])->toBe(200); 240 | expect($toolResult['body']['id'])->toBe('tool-json-context-1'); 241 | expect($toolResult['body'])->not->toHaveKey('error'); 242 | expect($toolResult['body']['result']['content'][0]['text'])->toBe('TestValue'); 243 | })->group('integration', 'streamable_http_json'); 244 | 245 | it('can read a registered resource', function () { 246 | await($this->jsonClient->sendRequest('initialize', [ 247 | 'protocolVersion' => Protocol::LATEST_PROTOCOL_VERSION, 248 | 'clientInfo' => ['name' => 'JsonModeClient', 'version' => '1.0'], 249 | 'capabilities' => [] 250 | ], 'init-json-res')); 251 | await($this->jsonClient->sendNotification('notifications/initialized')); 252 | 253 | $resourceResult = await($this->jsonClient->sendRequest('resources/read', ['uri' => 'test://streamable/static'], 'res-read-json-1')); 254 | 255 | expect($resourceResult['statusCode'])->toBe(200); 256 | expect($resourceResult['body']['id'])->toBe('res-read-json-1'); 257 | $contents = $resourceResult['body']['result']['contents']; 258 | expect($contents[0]['uri'])->toBe('test://streamable/static'); 259 | expect($contents[0]['text'])->toBe(\PhpMcp\Server\Tests\Fixtures\General\ResourceHandlerFixture::$staticTextContent); 260 | })->group('integration', 'streamable_http_json'); 261 | 262 | it('can get a registered prompt', function () { 263 | await($this->jsonClient->sendRequest('initialize', ['protocolVersion' => Protocol::LATEST_PROTOCOL_VERSION, 'clientInfo' => ['name' => 'JsonModeClient', 'version' => '1.0'], 'capabilities' => []], 'init-json-prompt')); 264 | await($this->jsonClient->sendNotification('notifications/initialized')); 265 | 266 | $promptResult = await($this->jsonClient->sendRequest('prompts/get', [ 267 | 'name' => 'simple_streamable_prompt', 268 | 'arguments' => ['name' => 'JsonPromptUser', 'style' => 'terse'] 269 | ], 'prompt-get-json-1')); 270 | 271 | expect($promptResult['statusCode'])->toBe(200); 272 | expect($promptResult['body']['id'])->toBe('prompt-get-json-1'); 273 | $messages = $promptResult['body']['result']['messages']; 274 | expect($messages[0]['content']['text'])->toBe('Craft a terse greeting for JsonPromptUser.'); 275 | })->group('integration', 'streamable_http_json'); 276 | 277 | it('rejects subsequent requests if client does not send initialized notification', function () { 278 | // 1. Initialize ONLY 279 | await($this->jsonClient->sendRequest('initialize', ['protocolVersion' => Protocol::LATEST_PROTOCOL_VERSION, 'clientInfo' => ['name' => 'JsonModeClient', 'version' => '1.0'], 'capabilities' => []], 'init-json-noack')); 280 | // Client "forgets" to send notifications/initialized back 281 | 282 | // 2. Attempt to Call a tool 283 | $toolResult = await($this->jsonClient->sendRequest('tools/call', [ 284 | 'name' => 'greet_streamable_tool', 285 | 'arguments' => ['name' => 'NoAckJsonUser'] 286 | ], 'tool-json-noack')); 287 | 288 | expect($toolResult['statusCode'])->toBe(200); // HTTP is fine 289 | expect($toolResult['body']['id'])->toBe('tool-json-noack'); 290 | expect($toolResult['body']['error']['code'])->toBe(-32600); // Invalid Request 291 | expect($toolResult['body']['error']['message'])->toContain('Client session not initialized'); 292 | })->group('integration', 'streamable_http_json'); 293 | 294 | it('returns HTTP 400 error for non-initialize requests without Mcp-Session-Id', function () { 295 | await($this->jsonClient->sendRequest('initialize', ['protocolVersion' => Protocol::LATEST_PROTOCOL_VERSION, 'clientInfo' => ['name' => 'JsonModeClient', 'version' => '1.0'], 'capabilities' => []], 'init-sess-test')); 296 | $this->jsonClient->sessionId = null; 297 | 298 | try { 299 | await($this->jsonClient->sendRequest('tools/list', [], 'tools-list-no-session')); 300 | } catch (ResponseException $e) { 301 | expect($e->getResponse()->getStatusCode())->toBe(400); 302 | $bodyContent = (string) $e->getResponse()->getBody(); 303 | $decodedBody = json_decode($bodyContent, true); 304 | 305 | expect($decodedBody['jsonrpc'])->toBe('2.0'); 306 | expect($decodedBody['id'])->toBe('tools-list-no-session'); 307 | expect($decodedBody['error']['code'])->toBe(-32600); 308 | expect($decodedBody['error']['message'])->toContain('Mcp-Session-Id header required'); 309 | } 310 | })->group('integration', 'streamable_http_json'); 311 | }); 312 | 313 | describe('STREAM MODE', function () { 314 | beforeEach(function () { 315 | $this->process = new Process($this->streamModeCommand, getcwd() ?: null, null, []); 316 | $this->process->start(); 317 | $this->streamClient = new MockStreamHttpClient(STREAMABLE_HTTP_HOST, $this->port, STREAMABLE_MCP_PATH); 318 | await(delay(0.2)); 319 | }); 320 | afterEach(function () { 321 | if ($this->streamClient ?? null) { 322 | $this->streamClient->closeMainSseStream(); 323 | } 324 | }); 325 | 326 | it('server starts, initializes via POST JSON, calls a tool, and closes', function () { 327 | // 1. Initialize Request 328 | $initResponse = await($this->streamClient->sendInitializeRequest([ 329 | 'protocolVersion' => Protocol::LATEST_PROTOCOL_VERSION, 330 | 'clientInfo' => ['name' => 'StreamModeClient', 'version' => '1.0'], 331 | 'capabilities' => [] 332 | ], 'init-stream-1')); 333 | 334 | expect($this->streamClient->sessionId)->toBeString()->not->toBeEmpty(); 335 | expect($initResponse['id'])->toBe('init-stream-1'); 336 | expect($initResponse)->not->toHaveKey('error'); 337 | expect($initResponse['result']['protocolVersion'])->toBe(Protocol::LATEST_PROTOCOL_VERSION); 338 | expect($initResponse['result']['serverInfo']['name'])->toBe('StreamableHttpIntegrationServer'); 339 | 340 | // 2. Send Initialized Notification 341 | $notifResult = await($this->streamClient->sendHttpNotification('notifications/initialized')); 342 | expect($notifResult['statusCode'])->toBe(202); 343 | 344 | // 3. Call a tool 345 | $toolResponse = await($this->streamClient->sendRequest('tools/call', [ 346 | 'name' => 'greet_streamable_tool', 347 | 'arguments' => ['name' => 'Stream Mode User'] 348 | ], 'tool-stream-1')); 349 | 350 | expect($toolResponse['id'])->toBe('tool-stream-1'); 351 | expect($toolResponse)->not->toHaveKey('error'); 352 | expect($toolResponse['result']['content'][0]['text'])->toBe('Hello, Stream Mode User!'); 353 | })->group('integration', 'streamable_http_stream'); 354 | 355 | it('return HTTP 400 error response for invalid JSON in POST request', function () { 356 | $malformedJson = '{"jsonrpc":"2.0", "id": "bad-json-stream-1", "method": "tools/list", "params": {"broken"}'; 357 | 358 | $postPromise = $this->streamClient->browser->post( 359 | $this->streamClient->baseMcpUrl, 360 | ['Content-Type' => 'application/json', 'Accept' => 'text/event-stream'], 361 | $malformedJson 362 | ); 363 | 364 | try { 365 | await(timeout($postPromise, STREAMABLE_HTTP_PROCESS_TIMEOUT - 2)); 366 | } catch (ResponseException $e) { 367 | $httpResponse = $e->getResponse(); 368 | $bodyContent = (string) $httpResponse->getBody(); 369 | $decodedBody = json_decode($bodyContent, true); 370 | 371 | expect($httpResponse->getStatusCode())->toBe(400); 372 | expect($decodedBody['jsonrpc'])->toBe('2.0'); 373 | expect($decodedBody['id'])->toBe(''); 374 | expect($decodedBody['error']['code'])->toBe(-32700); 375 | expect($decodedBody['error']['message'])->toContain('Invalid JSON'); 376 | } 377 | })->group('integration', 'streamable_http_stream'); 378 | 379 | it('returns JSON-RPC error result for request for non-existent method', function () { 380 | // 1. Initialize 381 | await($this->streamClient->sendInitializeRequest([ 382 | 'protocolVersion' => Protocol::LATEST_PROTOCOL_VERSION, 383 | 'clientInfo' => ['name' => 'StreamModeClient', 'version' => '1.0'], 384 | 'capabilities' => [] 385 | ], 'init-stream-err')); 386 | await($this->streamClient->sendHttpNotification('notifications/initialized')); 387 | 388 | // 2. Send Request 389 | $errorResponse = await($this->streamClient->sendRequest('non/existentToolViaStream', [], 'err-meth-stream-1')); 390 | 391 | expect($errorResponse['id'])->toBe('err-meth-stream-1'); 392 | expect($errorResponse['error']['code'])->toBe(-32601); 393 | expect($errorResponse['error']['message'])->toContain("Method 'non/existentToolViaStream' not found"); 394 | })->group('integration', 'streamable_http_stream'); 395 | 396 | it('can handle batch requests correctly', function () { 397 | await($this->streamClient->sendInitializeRequest([ 398 | 'protocolVersion' => Protocol::LATEST_PROTOCOL_VERSION, 399 | 'clientInfo' => ['name' => 'StreamModeBatchClient', 'version' => '1.0'], 400 | 'capabilities' => [] 401 | ], 'init-stream-batch')); 402 | expect($this->streamClient->sessionId)->toBeString()->not->toBeEmpty(); 403 | await($this->streamClient->sendHttpNotification('notifications/initialized')); 404 | 405 | $batchRequests = [ 406 | ['jsonrpc' => '2.0', 'id' => 'batch-req-1', 'method' => 'tools/call', 'params' => ['name' => 'greet_streamable_tool', 'arguments' => ['name' => 'Batch Item 1']]], 407 | ['jsonrpc' => '2.0', 'method' => 'notifications/something'], 408 | ['jsonrpc' => '2.0', 'id' => 'batch-req-2', 'method' => 'tools/call', 'params' => ['name' => 'sum_streamable_tool', 'arguments' => ['a' => 10, 'b' => 20]]], 409 | ['jsonrpc' => '2.0', 'id' => 'batch-req-3', 'method' => 'nonexistent/method'] 410 | ]; 411 | 412 | $batchResponseArray = await($this->streamClient->sendBatchRequest($batchRequests)); 413 | 414 | expect($batchResponseArray)->toBeArray()->toHaveCount(3); 415 | 416 | $findResponseById = function (array $batch, $id) { 417 | foreach ($batch as $item) { 418 | if (isset($item['id']) && $item['id'] === $id) { 419 | return $item; 420 | } 421 | } 422 | return null; 423 | }; 424 | 425 | $response1 = $findResponseById($batchResponseArray, 'batch-req-1'); 426 | $response2 = $findResponseById($batchResponseArray, 'batch-req-2'); 427 | $response3 = $findResponseById($batchResponseArray, 'batch-req-3'); 428 | 429 | expect($response1['result']['content'][0]['text'])->toBe('Hello, Batch Item 1!'); 430 | expect($response2['result']['content'][0]['text'])->toBe('30'); 431 | expect($response3['error']['code'])->toBe(-32601); 432 | expect($response3['error']['message'])->toContain("Method 'nonexistent/method' not found"); 433 | })->group('integration', 'streamable_http_stream'); 434 | 435 | it('passes request in Context', function () { 436 | await($this->streamClient->sendInitializeRequest([ 437 | 'protocolVersion' => Protocol::LATEST_PROTOCOL_VERSION, 438 | 'clientInfo' => ['name' => 'StreamModeClient', 'version' => '1.0'], 439 | 'capabilities' => [] 440 | ], 'init-stream-context')); 441 | expect($this->streamClient->sessionId)->toBeString()->not->toBeEmpty(); 442 | await($this->streamClient->sendHttpNotification('notifications/initialized')); 443 | 444 | $toolResult = await($this->streamClient->sendRequest('tools/call', [ 445 | 'name' => 'tool_reads_context', 446 | 'arguments' => [] 447 | ], 'tool-stream-context-1', ['X-Test-Header' => 'TestValue'])); 448 | 449 | expect($toolResult['id'])->toBe('tool-stream-context-1'); 450 | expect($toolResult)->not->toHaveKey('error'); 451 | expect($toolResult['result']['content'][0]['text'])->toBe('TestValue'); 452 | })->group('integration', 'streamable_http_stream'); 453 | 454 | it('can handle tool list request', function () { 455 | await($this->streamClient->sendInitializeRequest(['protocolVersion' => Protocol::LATEST_PROTOCOL_VERSION, 'clientInfo' => []], 'init-stream-tools')); 456 | await($this->streamClient->sendHttpNotification('notifications/initialized')); 457 | 458 | $toolListResponse = await($this->streamClient->sendRequest('tools/list', [], 'tool-list-stream-1')); 459 | 460 | expect($toolListResponse['id'])->toBe('tool-list-stream-1'); 461 | expect($toolListResponse)->not->toHaveKey('error'); 462 | expect($toolListResponse['result']['tools'])->toBeArray(); 463 | expect(count($toolListResponse['result']['tools']))->toBe(4); 464 | expect($toolListResponse['result']['tools'][0]['name'])->toBe('greet_streamable_tool'); 465 | expect($toolListResponse['result']['tools'][1]['name'])->toBe('sum_streamable_tool'); 466 | expect($toolListResponse['result']['tools'][2]['name'])->toBe('tool_reads_context'); 467 | })->group('integration', 'streamable_http_stream'); 468 | 469 | it('can read a registered resource', function () { 470 | await($this->streamClient->sendInitializeRequest(['protocolVersion' => Protocol::LATEST_PROTOCOL_VERSION, 'clientInfo' => []], 'init-stream-res')); 471 | await($this->streamClient->sendHttpNotification('notifications/initialized')); 472 | 473 | $resourceResponse = await($this->streamClient->sendRequest('resources/read', ['uri' => 'test://streamable/static'], 'res-read-stream-1')); 474 | 475 | expect($resourceResponse['id'])->toBe('res-read-stream-1'); 476 | $contents = $resourceResponse['result']['contents']; 477 | expect($contents[0]['uri'])->toBe('test://streamable/static'); 478 | expect($contents[0]['text'])->toBe(\PhpMcp\Server\Tests\Fixtures\General\ResourceHandlerFixture::$staticTextContent); 479 | })->group('integration', 'streamable_http_stream'); 480 | 481 | it('can get a registered prompt', function () { 482 | await($this->streamClient->sendInitializeRequest(['protocolVersion' => Protocol::LATEST_PROTOCOL_VERSION, 'clientInfo' => []], 'init-stream-prompt')); 483 | await($this->streamClient->sendHttpNotification('notifications/initialized')); 484 | 485 | $promptResponse = await($this->streamClient->sendRequest('prompts/get', [ 486 | 'name' => 'simple_streamable_prompt', 487 | 'arguments' => ['name' => 'StreamPromptUser', 'style' => 'formal'] 488 | ], 'prompt-get-stream-1')); 489 | 490 | expect($promptResponse['id'])->toBe('prompt-get-stream-1'); 491 | $messages = $promptResponse['result']['messages']; 492 | expect($messages[0]['content']['text'])->toBe('Craft a formal greeting for StreamPromptUser.'); 493 | })->group('integration', 'streamable_http_stream'); 494 | 495 | it('rejects subsequent requests if client does not send initialized notification', function () { 496 | await($this->streamClient->sendInitializeRequest([ 497 | 'protocolVersion' => Protocol::LATEST_PROTOCOL_VERSION, 498 | 'clientInfo' => ['name' => 'StreamModeClient', 'version' => '1.0'], 499 | 'capabilities' => [] 500 | ], 'init-stream-noack')); 501 | 502 | $toolResponse = await($this->streamClient->sendRequest('tools/call', [ 503 | 'name' => 'greet_streamable_tool', 504 | 'arguments' => ['name' => 'NoAckStreamUser'] 505 | ], 'tool-stream-noack')); 506 | 507 | expect($toolResponse['id'])->toBe('tool-stream-noack'); 508 | expect($toolResponse['error']['code'])->toBe(-32600); 509 | expect($toolResponse['error']['message'])->toContain('Client session not initialized'); 510 | })->group('integration', 'streamable_http_stream'); 511 | 512 | it('returns HTTP 400 error for non-initialize requests without Mcp-Session-Id', function () { 513 | await($this->streamClient->sendInitializeRequest([ 514 | 'protocolVersion' => Protocol::LATEST_PROTOCOL_VERSION, 515 | 'clientInfo' => ['name' => 'StreamModeClient', 'version' => '1.0'], 516 | 'capabilities' => [] 517 | ], 'init-stream-sess-test')); 518 | $validSessionId = $this->streamClient->sessionId; 519 | $this->streamClient->sessionId = null; 520 | 521 | try { 522 | await($this->streamClient->sendRequest('tools/list', [], 'tools-list-no-session-stream')); 523 | $this->fail("Expected request to tools/list to fail with 400, but it succeeded."); 524 | } catch (ResponseException $e) { 525 | expect($e->getResponse()->getStatusCode())->toBe(400); 526 | // Body can't be a json since the header accepts only text/event-stream 527 | } 528 | 529 | $this->streamClient->sessionId = $validSessionId; 530 | })->group('integration', 'streamable_http_stream'); 531 | }); 532 | 533 | /** 534 | * STATELESS MODE TESTS 535 | * 536 | * Tests for the stateless mode of StreamableHttpServerTransport, which: 537 | * - Generates session IDs internally but doesn't expose them to clients 538 | * - Doesn't require session IDs in requests after initialization 539 | * - Doesn't include session IDs in response headers 540 | * - Disables GET requests (SSE streaming) 541 | * - Makes DELETE requests meaningless (but returns 204) 542 | * - Treats each request as independent (no persistent session state) 543 | * 544 | * This mode is designed to work with clients like OpenAI's MCP implementation 545 | * that have issues with session management in "never require approval" mode. 546 | */ 547 | describe('STATELESS MODE', function () { 548 | beforeEach(function () { 549 | $this->process = new Process($this->statelessModeCommand, getcwd() ?: null, null, []); 550 | $this->process->start(); 551 | $this->statelessClient = new MockJsonHttpClient(STREAMABLE_HTTP_HOST, $this->port, STREAMABLE_MCP_PATH); 552 | await(delay(0.2)); 553 | }); 554 | 555 | it('allows tool calls without having to send initialized notification', function () { 556 | // 1. Initialize Request 557 | $initResult = await($this->statelessClient->sendRequest('initialize', [ 558 | 'protocolVersion' => Protocol::LATEST_PROTOCOL_VERSION, 559 | 'clientInfo' => ['name' => 'StatelessModeClient', 'version' => '1.0'], 560 | 'capabilities' => [] 561 | ], 'init-stateless-1')); 562 | 563 | expect($initResult['statusCode'])->toBe(200); 564 | expect($initResult['body']['id'])->toBe('init-stateless-1'); 565 | expect($initResult['body'])->not->toHaveKey('error'); 566 | expect($initResult['body']['result']['protocolVersion'])->toBe(Protocol::LATEST_PROTOCOL_VERSION); 567 | expect($initResult['body']['result']['serverInfo']['name'])->toBe('StreamableHttpIntegrationServer'); 568 | expect($this->statelessClient->sessionId)->toBeString()->toBeEmpty(); 569 | 570 | // 2. Call a tool 571 | $toolResult = await($this->statelessClient->sendRequest('tools/call', [ 572 | 'name' => 'greet_streamable_tool', 573 | 'arguments' => ['name' => 'Stateless Mode User'] 574 | ], 'tool-stateless-1')); 575 | 576 | expect($toolResult['statusCode'])->toBe(200); 577 | expect($toolResult['body']['id'])->toBe('tool-stateless-1'); 578 | expect($toolResult['body'])->not->toHaveKey('error'); 579 | expect($toolResult['body']['result']['content'][0]['text'])->toBe('Hello, Stateless Mode User!'); 580 | })->group('integration', 'streamable_http_stateless'); 581 | 582 | it('return HTTP 400 error response for invalid JSON in POST request', function () { 583 | $malformedJson = '{"jsonrpc":"2.0", "id": "bad-json-stateless-1", "method": "tools/list", "params": {"broken"}'; 584 | 585 | $postPromise = $this->statelessClient->browser->post( 586 | $this->statelessClient->baseUrl, 587 | ['Content-Type' => 'application/json', 'Accept' => 'application/json'], 588 | $malformedJson 589 | ); 590 | 591 | try { 592 | await(timeout($postPromise, STREAMABLE_HTTP_PROCESS_TIMEOUT - 2)); 593 | } catch (ResponseException $e) { 594 | $httpResponse = $e->getResponse(); 595 | $bodyContent = (string) $httpResponse->getBody(); 596 | $decodedBody = json_decode($bodyContent, true); 597 | 598 | expect($httpResponse->getStatusCode())->toBe(400); 599 | expect($decodedBody['jsonrpc'])->toBe('2.0'); 600 | expect($decodedBody['id'])->toBe(''); 601 | expect($decodedBody['error']['code'])->toBe(-32700); 602 | expect($decodedBody['error']['message'])->toContain('Invalid JSON'); 603 | } 604 | })->group('integration', 'streamable_http_stateless'); 605 | 606 | it('returns JSON-RPC error result for request for non-existent method', function () { 607 | $errorResult = await($this->statelessClient->sendRequest('non/existentToolViaStateless', [], 'err-meth-stateless-1')); 608 | 609 | expect($errorResult['statusCode'])->toBe(200); 610 | expect($errorResult['body']['id'])->toBe('err-meth-stateless-1'); 611 | expect($errorResult['body']['error']['code'])->toBe(-32601); 612 | expect($errorResult['body']['error']['message'])->toContain("Method 'non/existentToolViaStateless' not found"); 613 | })->group('integration', 'streamable_http_stateless'); 614 | 615 | it('can handle batch requests correctly', function () { 616 | $batchRequests = [ 617 | ['jsonrpc' => '2.0', 'id' => 'batch-req-1', 'method' => 'tools/call', 'params' => ['name' => 'greet_streamable_tool', 'arguments' => ['name' => 'Batch Item 1']]], 618 | ['jsonrpc' => '2.0', 'method' => 'notifications/something'], 619 | ['jsonrpc' => '2.0', 'id' => 'batch-req-2', 'method' => 'tools/call', 'params' => ['name' => 'sum_streamable_tool', 'arguments' => ['a' => 10, 'b' => 20]]], 620 | ['jsonrpc' => '2.0', 'id' => 'batch-req-3', 'method' => 'nonexistent/method'] 621 | ]; 622 | 623 | $batchResponse = await($this->statelessClient->sendBatchRequest($batchRequests)); 624 | 625 | $findResponseById = function (array $batch, $id) { 626 | foreach ($batch as $item) { 627 | if (isset($item['id']) && $item['id'] === $id) { 628 | return $item; 629 | } 630 | } 631 | return null; 632 | }; 633 | 634 | expect($batchResponse['statusCode'])->toBe(200); 635 | expect($batchResponse['body'])->toBeArray()->toHaveCount(3); 636 | 637 | $response1 = $findResponseById($batchResponse['body'], 'batch-req-1'); 638 | $response2 = $findResponseById($batchResponse['body'], 'batch-req-2'); 639 | $response3 = $findResponseById($batchResponse['body'], 'batch-req-3'); 640 | 641 | expect($response1['result']['content'][0]['text'])->toBe('Hello, Batch Item 1!'); 642 | expect($response2['result']['content'][0]['text'])->toBe('30'); 643 | expect($response3['error']['code'])->toBe(-32601); 644 | expect($response3['error']['message'])->toContain("Method 'nonexistent/method' not found"); 645 | })->group('integration', 'streamable_http_stateless'); 646 | 647 | it('passes request in Context', function () { 648 | $toolResult = await($this->statelessClient->sendRequest('tools/call', [ 649 | 'name' => 'tool_reads_context', 650 | 'arguments' => [] 651 | ], 'tool-stateless-context-1', ['X-Test-Header' => 'TestValue'])); 652 | 653 | expect($toolResult['statusCode'])->toBe(200); 654 | expect($toolResult['body']['id'])->toBe('tool-stateless-context-1'); 655 | expect($toolResult['body'])->not->toHaveKey('error'); 656 | expect($toolResult['body']['result']['content'][0]['text'])->toBe('TestValue'); 657 | })->group('integration', 'streamable_http_stateless'); 658 | 659 | it('can handle tool list request', function () { 660 | $toolListResult = await($this->statelessClient->sendRequest('tools/list', [], 'tool-list-stateless-1')); 661 | 662 | expect($toolListResult['statusCode'])->toBe(200); 663 | expect($toolListResult['body']['id'])->toBe('tool-list-stateless-1'); 664 | expect($toolListResult['body'])->not->toHaveKey('error'); 665 | expect($toolListResult['body']['result']['tools'])->toBeArray(); 666 | expect(count($toolListResult['body']['result']['tools']))->toBe(4); 667 | expect($toolListResult['body']['result']['tools'][0]['name'])->toBe('greet_streamable_tool'); 668 | expect($toolListResult['body']['result']['tools'][1]['name'])->toBe('sum_streamable_tool'); 669 | expect($toolListResult['body']['result']['tools'][2]['name'])->toBe('tool_reads_context'); 670 | })->group('integration', 'streamable_http_stateless'); 671 | 672 | it('can read a registered resource', function () { 673 | $resourceResult = await($this->statelessClient->sendRequest('resources/read', ['uri' => 'test://streamable/static'], 'res-read-stateless-1')); 674 | 675 | expect($resourceResult['statusCode'])->toBe(200); 676 | expect($resourceResult['body']['id'])->toBe('res-read-stateless-1'); 677 | $contents = $resourceResult['body']['result']['contents']; 678 | expect($contents[0]['uri'])->toBe('test://streamable/static'); 679 | expect($contents[0]['text'])->toBe(\PhpMcp\Server\Tests\Fixtures\General\ResourceHandlerFixture::$staticTextContent); 680 | })->group('integration', 'streamable_http_stateless'); 681 | 682 | it('can get a registered prompt', function () { 683 | $promptResult = await($this->statelessClient->sendRequest('prompts/get', [ 684 | 'name' => 'simple_streamable_prompt', 685 | 'arguments' => ['name' => 'StatelessPromptUser', 'style' => 'formal'] 686 | ], 'prompt-get-stateless-1')); 687 | 688 | expect($promptResult['statusCode'])->toBe(200); 689 | expect($promptResult['body']['id'])->toBe('prompt-get-stateless-1'); 690 | $messages = $promptResult['body']['result']['messages']; 691 | expect($messages[0]['content']['text'])->toBe('Craft a formal greeting for StatelessPromptUser.'); 692 | })->group('integration', 'streamable_http_stateless'); 693 | 694 | it('does not return session ID in response headers in stateless mode', function () { 695 | $promise = $this->statelessClient->browser->post( 696 | $this->statelessClient->baseUrl, 697 | ['Content-Type' => 'application/json', 'Accept' => 'application/json'], 698 | json_encode([ 699 | 'jsonrpc' => '2.0', 700 | 'id' => 'init-header-test', 701 | 'method' => 'initialize', 702 | 'params' => [ 703 | 'protocolVersion' => Protocol::LATEST_PROTOCOL_VERSION, 704 | 'clientInfo' => ['name' => 'StatelessHeaderTest', 'version' => '1.0'], 705 | 'capabilities' => [] 706 | ] 707 | ]) 708 | ); 709 | 710 | $response = await(timeout($promise, STREAMABLE_HTTP_PROCESS_TIMEOUT - 2)); 711 | 712 | expect($response->getStatusCode())->toBe(200); 713 | expect($response->hasHeader('Mcp-Session-Id'))->toBeFalse(); 714 | 715 | $body = json_decode((string) $response->getBody(), true); 716 | expect($body['id'])->toBe('init-header-test'); 717 | expect($body)->not->toHaveKey('error'); 718 | })->group('integration', 'streamable_http_stateless'); 719 | 720 | it('returns HTTP 405 for GET requests (SSE disabled) in stateless mode', function () { 721 | try { 722 | $getPromise = $this->statelessClient->browser->get( 723 | $this->statelessClient->baseUrl, 724 | ['Accept' => 'text/event-stream'] 725 | ); 726 | await(timeout($getPromise, STREAMABLE_HTTP_PROCESS_TIMEOUT - 2)); 727 | $this->fail("Expected GET request to fail with 405, but it succeeded."); 728 | } catch (ResponseException $e) { 729 | expect($e->getResponse()->getStatusCode())->toBe(405); 730 | $bodyContent = (string) $e->getResponse()->getBody(); 731 | $decodedBody = json_decode($bodyContent, true); 732 | expect($decodedBody['error']['message'])->toContain('GET requests (SSE streaming) are not supported in stateless mode'); 733 | } 734 | })->group('integration', 'streamable_http_stateless'); 735 | 736 | it('returns 204 for DELETE requests in stateless mode (but they are meaningless)', function () { 737 | $deletePromise = $this->statelessClient->browser->delete($this->statelessClient->baseUrl); 738 | $response = await(timeout($deletePromise, STREAMABLE_HTTP_PROCESS_TIMEOUT - 2)); 739 | 740 | expect($response->getStatusCode())->toBe(204); 741 | expect((string) $response->getBody())->toBeEmpty(); 742 | })->group('integration', 'streamable_http_stateless'); 743 | 744 | it('handles multiple independent tool calls in stateless mode', function () { 745 | $toolResult1 = await($this->statelessClient->sendRequest('tools/call', [ 746 | 'name' => 'greet_streamable_tool', 747 | 'arguments' => ['name' => 'User 1'] 748 | ], 'tool-multi-1')); 749 | 750 | $toolResult2 = await($this->statelessClient->sendRequest('tools/call', [ 751 | 'name' => 'sum_streamable_tool', 752 | 'arguments' => ['a' => 5, 'b' => 10] 753 | ], 'tool-multi-2')); 754 | 755 | $toolResult3 = await($this->statelessClient->sendRequest('tools/call', [ 756 | 'name' => 'greet_streamable_tool', 757 | 'arguments' => ['name' => 'User 3'] 758 | ], 'tool-multi-3')); 759 | 760 | expect($toolResult1['statusCode'])->toBe(200); 761 | expect($toolResult1['body']['id'])->toBe('tool-multi-1'); 762 | expect($toolResult1['body']['result']['content'][0]['text'])->toBe('Hello, User 1!'); 763 | 764 | expect($toolResult2['statusCode'])->toBe(200); 765 | expect($toolResult2['body']['id'])->toBe('tool-multi-2'); 766 | expect($toolResult2['body']['result']['content'][0]['text'])->toBe('15'); 767 | 768 | expect($toolResult3['statusCode'])->toBe(200); 769 | expect($toolResult3['body']['id'])->toBe('tool-multi-3'); 770 | expect($toolResult3['body']['result']['content'][0]['text'])->toBe('Hello, User 3!'); 771 | })->group('integration', 'streamable_http_stateless'); 772 | }); 773 | 774 | it('responds to OPTIONS request with CORS headers', function () { 775 | $this->process = new Process($this->jsonModeCommand, getcwd() ?: null, null, []); 776 | $this->process->start(); 777 | $this->jsonClient = new MockJsonHttpClient(STREAMABLE_HTTP_HOST, $this->port, STREAMABLE_MCP_PATH); 778 | await(delay(0.1)); 779 | 780 | $browser = new Browser(); 781 | $optionsUrl = $this->jsonClient->baseUrl; 782 | 783 | $promise = $browser->request('OPTIONS', $optionsUrl); 784 | $response = await(timeout($promise, STREAMABLE_HTTP_PROCESS_TIMEOUT - 2)); 785 | 786 | expect($response->getStatusCode())->toBe(204); 787 | expect($response->getHeaderLine('Access-Control-Allow-Origin'))->toBe('*'); 788 | expect($response->getHeaderLine('Access-Control-Allow-Methods'))->toContain('POST'); 789 | expect($response->getHeaderLine('Access-Control-Allow-Methods'))->toContain('GET'); 790 | expect($response->getHeaderLine('Access-Control-Allow-Headers'))->toContain('Mcp-Session-Id'); 791 | })->group('integration', 'streamable_http'); 792 | 793 | it('returns 404 for unknown paths', function () { 794 | $this->process = new Process($this->jsonModeCommand, getcwd() ?: null, null, []); 795 | $this->process->start(); 796 | $this->jsonClient = new MockJsonHttpClient(STREAMABLE_HTTP_HOST, $this->port, STREAMABLE_MCP_PATH); 797 | await(delay(0.1)); 798 | 799 | $browser = new Browser(); 800 | $unknownUrl = "http://" . STREAMABLE_HTTP_HOST . ":" . $this->port . "/completely/unknown/path"; 801 | 802 | $promise = $browser->get($unknownUrl); 803 | 804 | try { 805 | await(timeout($promise, STREAMABLE_HTTP_PROCESS_TIMEOUT - 2)); 806 | $this->fail("Request to unknown path should have failed with 404."); 807 | } catch (ResponseException $e) { 808 | expect($e->getResponse()->getStatusCode())->toBe(404); 809 | $decodedBody = json_decode((string)$e->getResponse()->getBody(), true); 810 | expect($decodedBody['error']['message'])->toContain('Not found'); 811 | } 812 | })->group('integration', 'streamable_http'); 813 | 814 | it('can delete client session with DELETE request', function () { 815 | $this->process = new Process($this->jsonModeCommand, getcwd() ?: null, null, []); 816 | $this->process->start(); 817 | $this->jsonClient = new MockJsonHttpClient(STREAMABLE_HTTP_HOST, $this->port, STREAMABLE_MCP_PATH); 818 | await(delay(0.1)); 819 | 820 | // 1. Initialize 821 | await($this->jsonClient->sendRequest('initialize', ['protocolVersion' => Protocol::LATEST_PROTOCOL_VERSION, 'clientInfo' => ['name' => 'JsonModeClient', 'version' => '1.0'], 'capabilities' => []], 'init-delete-test')); 822 | $sessionIdForDelete = $this->jsonClient->sessionId; 823 | expect($sessionIdForDelete)->toBeString(); 824 | await($this->jsonClient->sendNotification('notifications/initialized')); 825 | 826 | // 2. Establish a GET SSE connection 827 | $sseUrl = $this->jsonClient->baseUrl; 828 | $browserForSse = (new Browser())->withTimeout(3); 829 | $ssePromise = $browserForSse->requestStreaming('GET', $sseUrl, [ 830 | 'Accept' => 'text/event-stream', 831 | 'Mcp-Session-Id' => $sessionIdForDelete 832 | ]); 833 | $ssePsrResponse = await(timeout($ssePromise, 3)); 834 | expect($ssePsrResponse->getStatusCode())->toBe(200); 835 | expect($ssePsrResponse->getHeaderLine('Content-Type'))->toBe('text/event-stream'); 836 | 837 | $sseStream = $ssePsrResponse->getBody(); 838 | assert($sseStream instanceof ReadableStreamInterface); 839 | 840 | $isSseStreamClosed = false; 841 | $sseStream->on('close', function () use (&$isSseStreamClosed) { 842 | $isSseStreamClosed = true; 843 | }); 844 | 845 | // 3. Send DELETE request 846 | $deleteResponse = await($this->jsonClient->sendDeleteRequest()); 847 | expect($deleteResponse['statusCode'])->toBe(204); 848 | 849 | // 4. Assert that the GET SSE stream was closed 850 | await(delay(0.1)); 851 | expect($isSseStreamClosed)->toBeTrue("The GET SSE stream for session {$sessionIdForDelete} was not closed after DELETE request."); 852 | 853 | // 5. Assert that the client session was deleted 854 | try { 855 | await($this->jsonClient->sendRequest('tools/list', [], 'tool-list-json-1')); 856 | $this->fail("Expected request to tools/list to fail with 400, but it succeeded."); 857 | } catch (ResponseException $e) { 858 | expect($e->getResponse()->getStatusCode())->toBe(404); 859 | $bodyContent = (string) $e->getResponse()->getBody(); 860 | $decodedBody = json_decode($bodyContent, true); 861 | expect($decodedBody['error']['code'])->toBe(-32600); 862 | expect($decodedBody['error']['message'])->toContain('Invalid or expired session'); 863 | } 864 | })->group('integration', 'streamable_http_json'); 865 | 866 | it('executes middleware that adds headers to response', function () { 867 | $this->process = new Process($this->jsonModeCommand, getcwd() ?: null, null, []); 868 | $this->process->start(); 869 | $this->jsonClient = new MockJsonHttpClient(STREAMABLE_HTTP_HOST, $this->port, STREAMABLE_MCP_PATH); 870 | await(delay(0.1)); 871 | 872 | // 1. Send a request and check that middleware-added header is present 873 | $response = await($this->jsonClient->sendRequest('initialize', [ 874 | 'protocolVersion' => Protocol::LATEST_PROTOCOL_VERSION, 875 | 'clientInfo' => ['name' => 'MiddlewareTestClient'], 876 | 'capabilities' => [] 877 | ], 'init-middleware-headers')); 878 | 879 | // Check that the response has the header added by middleware 880 | expect($this->jsonClient->lastResponseHeaders)->toContain('X-Test-Middleware: header-added'); 881 | })->group('integration', 'streamable_http', 'middleware'); 882 | 883 | it('executes middleware that modifies request attributes', function () { 884 | $this->process = new Process($this->jsonModeCommand, getcwd() ?: null, null, []); 885 | $this->process->start(); 886 | $this->jsonClient = new MockJsonHttpClient(STREAMABLE_HTTP_HOST, $this->port, STREAMABLE_MCP_PATH); 887 | await(delay(0.1)); 888 | 889 | // 1. Initialize 890 | await($this->jsonClient->sendRequest('initialize', [ 891 | 'protocolVersion' => Protocol::LATEST_PROTOCOL_VERSION, 892 | 'clientInfo' => ['name' => 'MiddlewareAttrTestClient', 'version' => '1.0'], 893 | 'capabilities' => [] 894 | ], 'init-middleware-attr')); 895 | await($this->jsonClient->sendNotification('notifications/initialized')); 896 | 897 | // 2. Call tool that checks for middleware-added attribute 898 | $toolResponse = await($this->jsonClient->sendRequest('tools/call', [ 899 | 'name' => 'check_request_attribute_tool', 900 | 'arguments' => [] 901 | ], 'tool-attr-check')); 902 | 903 | expect($toolResponse['body']['result']['content'][0]['text'])->toBe('middleware-value-found: middleware-value'); 904 | })->group('integration', 'streamable_http', 'middleware'); 905 | 906 | it('executes middleware that can short-circuit request processing', function () { 907 | $this->process = new Process($this->jsonModeCommand, getcwd() ?: null, null, []); 908 | $this->process->start(); 909 | await(delay(0.1)); 910 | 911 | $browser = new Browser(); 912 | $shortCircuitUrl = "http://" . STREAMABLE_HTTP_HOST . ":" . $this->port . "/" . STREAMABLE_MCP_PATH . "/short-circuit"; 913 | 914 | $promise = $browser->get($shortCircuitUrl); 915 | 916 | try { 917 | $response = await(timeout($promise, STREAMABLE_HTTP_PROCESS_TIMEOUT - 2)); 918 | $this->fail("Expected a 418 status code response, but request succeeded"); 919 | } catch (ResponseException $e) { 920 | expect($e->getResponse()->getStatusCode())->toBe(418); 921 | $body = (string) $e->getResponse()->getBody(); 922 | expect($body)->toBe('Short-circuited by middleware'); 923 | } catch (\Throwable $e) { 924 | $this->fail("Short-circuit middleware test failed: " . $e->getMessage()); 925 | } 926 | })->group('integration', 'streamable_http', 'middleware'); 927 | 928 | it('executes multiple middlewares in correct order', function () { 929 | $this->process = new Process($this->jsonModeCommand, getcwd() ?: null, null, []); 930 | $this->process->start(); 931 | $this->jsonClient = new MockJsonHttpClient(STREAMABLE_HTTP_HOST, $this->port, STREAMABLE_MCP_PATH); 932 | await(delay(0.1)); 933 | 934 | // 1. Send a request and check middleware order 935 | await($this->jsonClient->sendRequest('initialize', [ 936 | 'protocolVersion' => Protocol::LATEST_PROTOCOL_VERSION, 937 | 'clientInfo' => ['name' => 'MiddlewareOrderTestClient'], 938 | 'capabilities' => [] 939 | ], 'init-middleware-order')); 940 | 941 | // Check that headers from multiple middlewares are present in correct order 942 | expect($this->jsonClient->lastResponseHeaders)->toContain('X-Middleware-Order: third,second,first'); 943 | })->group('integration', 'streamable_http', 'middleware'); 944 | 945 | it('handles middleware that throws exceptions gracefully', function () { 946 | $this->process = new Process($this->jsonModeCommand, getcwd() ?: null, null, []); 947 | $this->process->start(); 948 | await(delay(0.1)); 949 | 950 | $browser = new Browser(); 951 | $errorUrl = "http://" . STREAMABLE_HTTP_HOST . ":" . $this->port . "/" . STREAMABLE_MCP_PATH . "/error-middleware"; 952 | 953 | $promise = $browser->get($errorUrl); 954 | 955 | try { 956 | await(timeout($promise, STREAMABLE_HTTP_PROCESS_TIMEOUT - 2)); 957 | $this->fail("Error middleware should have thrown an exception."); 958 | } catch (ResponseException $e) { 959 | expect($e->getResponse()->getStatusCode())->toBe(500); 960 | $body = (string) $e->getResponse()->getBody(); 961 | // ReactPHP handles exceptions and returns a generic error message 962 | expect($body)->toContain('Internal Server Error'); 963 | } 964 | })->group('integration', 'streamable_http', 'middleware'); 965 | ```