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 |
```