#
tokens: 15812/50000 1/154 files (page 7/7)
lines: on (toggle) GitHub
raw markdown copy reset
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 | 
```
Page 7/7FirstPrevNextLast