Skip to content

Commit d1fc63e

Browse files
feat: add stateless mode to StreamableHttpServerTransport
OpenAI's MCP implementation terminates sessions after tool discovery in "never require approval" mode, causing subsequent tool calls to fail. This adds a stateless mode where each request is independent and sessions are auto-initialized when needed.
1 parent 6dd0e82 commit d1fc63e

File tree

4 files changed

+296
-20
lines changed

4 files changed

+296
-20
lines changed

src/Protocol.php

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -134,6 +134,12 @@ public function processMessage(Request|Notification|BatchRequest $message, strin
134134
return;
135135
}
136136

137+
if ($context['stateless'] ?? false) {
138+
$session->set('initialized', true);
139+
$session->set('protocol_version', self::LATEST_PROTOCOL_VERSION);
140+
$session->set('client_info', ['name' => 'stateless-client', 'version' => '1.0.0']);
141+
}
142+
137143
$response = null;
138144

139145
if ($message instanceof BatchRequest) {

src/Transports/StreamableHttpServerTransport.php

Lines changed: 49 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,7 @@ public function __construct(
7575
private string $mcpPath = '/mcp',
7676
private ?array $sslContext = null,
7777
private readonly bool $enableJsonResponse = true,
78+
private readonly bool $stateless = false,
7879
?EventStoreInterface $eventStore = null
7980
) {
8081
$this->logger = new NullLogger();
@@ -171,9 +172,9 @@ private function createRequestHandler(): callable
171172

172173
try {
173174
return match ($method) {
174-
'GET' => $this->handleGetRequest($request)->then($addCors, fn ($e) => $addCors($this->handleRequestError($e, $request))),
175-
'POST' => $this->handlePostRequest($request)->then($addCors, fn ($e) => $addCors($this->handleRequestError($e, $request))),
176-
'DELETE' => $this->handleDeleteRequest($request)->then($addCors, fn ($e) => $addCors($this->handleRequestError($e, $request))),
175+
'GET' => $this->handleGetRequest($request)->then($addCors, fn($e) => $addCors($this->handleRequestError($e, $request))),
176+
'POST' => $this->handlePostRequest($request)->then($addCors, fn($e) => $addCors($this->handleRequestError($e, $request))),
177+
'DELETE' => $this->handleDeleteRequest($request)->then($addCors, fn($e) => $addCors($this->handleRequestError($e, $request))),
177178
default => $addCors($this->handleUnsupportedRequest($request)),
178179
};
179180
} catch (Throwable $e) {
@@ -184,6 +185,11 @@ private function createRequestHandler(): callable
184185

185186
private function handleGetRequest(ServerRequestInterface $request): PromiseInterface
186187
{
188+
if ($this->stateless) {
189+
$error = Error::forInvalidRequest("GET requests (SSE streaming) are not supported in stateless mode.");
190+
return resolve(new HttpResponse(405, ['Content-Type' => 'application/json'], json_encode($error)));
191+
}
192+
187193
$acceptHeader = $request->getHeaderLine('Accept');
188194
if (!str_contains($acceptHeader, 'text/event-stream')) {
189195
$error = Error::forInvalidRequest("Not Acceptable: Client must accept text/event-stream for GET requests.");
@@ -264,24 +270,29 @@ private function handlePostRequest(ServerRequestInterface $request): PromiseInte
264270
$isInitializeRequest = ($message instanceof Request && $message->method === 'initialize');
265271
$sessionId = null;
266272

267-
if ($isInitializeRequest) {
268-
if ($request->hasHeader('Mcp-Session-Id')) {
269-
$this->logger->warning("Client sent Mcp-Session-Id with InitializeRequest. Ignoring.", ['clientSentId' => $request->getHeaderLine('Mcp-Session-Id')]);
270-
$error = Error::forInvalidRequest("Invalid request: Session already initialized. Mcp-Session-Id header not allowed with InitializeRequest.", $message->getId());
271-
$deferred->resolve(new HttpResponse(400, ['Content-Type' => 'application/json'], json_encode($error)));
272-
return $deferred->promise();
273-
}
274-
273+
if ($this->stateless) {
275274
$sessionId = $this->generateId();
276275
$this->emit('client_connected', [$sessionId]);
277276
} else {
278-
$sessionId = $request->getHeaderLine('Mcp-Session-Id');
277+
if ($isInitializeRequest) {
278+
if ($request->hasHeader('Mcp-Session-Id')) {
279+
$this->logger->warning("Client sent Mcp-Session-Id with InitializeRequest. Ignoring.", ['clientSentId' => $request->getHeaderLine('Mcp-Session-Id')]);
280+
$error = Error::forInvalidRequest("Invalid request: Session already initialized. Mcp-Session-Id header not allowed with InitializeRequest.", $message->getId());
281+
$deferred->resolve(new HttpResponse(400, ['Content-Type' => 'application/json'], json_encode($error)));
282+
return $deferred->promise();
283+
}
284+
285+
$sessionId = $this->generateId();
286+
$this->emit('client_connected', [$sessionId]);
287+
} else {
288+
$sessionId = $request->getHeaderLine('Mcp-Session-Id');
279289

280-
if (empty($sessionId)) {
281-
$this->logger->warning("POST request without Mcp-Session-Id.");
282-
$error = Error::forInvalidRequest("Mcp-Session-Id header required for POST requests.", $message->getId());
283-
$deferred->resolve(new HttpResponse(400, ['Content-Type' => 'application/json'], json_encode($error)));
284-
return $deferred->promise();
290+
if (empty($sessionId)) {
291+
$this->logger->warning("POST request without Mcp-Session-Id.");
292+
$error = Error::forInvalidRequest("Mcp-Session-Id header required for POST requests.", $message->getId());
293+
$deferred->resolve(new HttpResponse(400, ['Content-Type' => 'application/json'], json_encode($error)));
294+
return $deferred->promise();
295+
}
285296
}
286297
}
287298

@@ -344,7 +355,7 @@ private function handlePostRequest(ServerRequestInterface $request): PromiseInte
344355
'X-Accel-Buffering' => 'no',
345356
];
346357

347-
if (!empty($sessionId)) {
358+
if (!empty($sessionId) && !$this->stateless) {
348359
$headers['Mcp-Session-Id'] = $sessionId;
349360
}
350361

@@ -355,6 +366,8 @@ private function handlePostRequest(ServerRequestInterface $request): PromiseInte
355366
}
356367
}
357368

369+
$context['stateless'] = $this->stateless;
370+
358371
$this->loop->futureTick(function () use ($message, $sessionId, $context) {
359372
$this->emit('message', [$message, $sessionId, $context]);
360373
});
@@ -364,6 +377,10 @@ private function handlePostRequest(ServerRequestInterface $request): PromiseInte
364377

365378
private function handleDeleteRequest(ServerRequestInterface $request): PromiseInterface
366379
{
380+
if ($this->stateless) {
381+
return resolve(new HttpResponse(204));
382+
}
383+
367384
$sessionId = $request->getHeaderLine('Mcp-Session-Id');
368385
if (empty($sessionId)) {
369386
$this->logger->warning("DELETE request without Mcp-Session-Id.");
@@ -466,6 +483,12 @@ public function sendMessage(Message $message, string $sessionId, array $context
466483
if ($this->activeSseStreams[$streamId]['context']['nResponses'] >= $this->activeSseStreams[$streamId]['context']['nRequests']) {
467484
$this->logger->info("All expected responses sent for POST SSE stream. Closing.", ['streamId' => $streamId, 'sessionId' => $sessionId]);
468485
$stream->end(); // Will trigger 'close' event.
486+
487+
if ($context['stateless'] ?? false) {
488+
$this->loop->futureTick(function () use ($sessionId) {
489+
$this->emit('client_disconnected', [$sessionId, 'Stateless request completed']);
490+
});
491+
}
469492
}
470493
}
471494

@@ -483,12 +506,19 @@ public function sendMessage(Message $message, string $sessionId, array $context
483506

484507
$responseBody = json_encode($message, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
485508
$headers = ['Content-Type' => 'application/json'];
486-
if ($isInitializeResponse) {
509+
if ($isInitializeResponse && !$this->stateless) {
487510
$headers['Mcp-Session-Id'] = $sessionId;
488511
}
489512

490513
$statusCode = $context['status_code'] ?? 200;
491514
$deferred->resolve(new HttpResponse($statusCode, $headers, $responseBody . "\n"));
515+
516+
if ($context['stateless'] ?? false) {
517+
$this->loop->futureTick(function () use ($sessionId) {
518+
$this->emit('client_disconnected', [$sessionId, 'Stateless request completed']);
519+
});
520+
}
521+
492522
return resolve(null);
493523

494524
default:

tests/Fixtures/ServerScripts/StreamableHttpTestServer.php

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,10 +27,11 @@ public function log($level, \Stringable|string $message, array $context = []): v
2727
$mcpPath = $argv[3] ?? 'mcp_streamable_test';
2828
$enableJsonResponse = filter_var($argv[4] ?? 'true', FILTER_VALIDATE_BOOLEAN);
2929
$useEventStore = filter_var($argv[5] ?? 'false', FILTER_VALIDATE_BOOLEAN);
30+
$stateless = filter_var($argv[6] ?? 'false', FILTER_VALIDATE_BOOLEAN);
3031

3132
try {
3233
$logger = new NullLogger();
33-
$logger->info("Starting StreamableHttpTestServer on {$host}:{$port}/{$mcpPath}, JSON Mode: " . ($enableJsonResponse ? 'ON' : 'OFF'));
34+
$logger->info("Starting StreamableHttpTestServer on {$host}:{$port}/{$mcpPath}, JSON Mode: " . ($enableJsonResponse ? 'ON' : 'OFF') . ", Stateless: " . ($stateless ? 'ON' : 'OFF'));
3435

3536
$eventStore = $useEventStore ? new InMemoryEventStore() : null;
3637

@@ -48,6 +49,7 @@ public function log($level, \Stringable|string $message, array $context = []): v
4849
port: $port,
4950
mcpPath: $mcpPath,
5051
enableJsonResponse: $enableJsonResponse,
52+
stateless: $stateless,
5153
eventStore: $eventStore
5254
);
5355

0 commit comments

Comments
 (0)