Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,10 @@ All notable changes to `mcp/sdk` will be documented in this file.
* On a `2026-07-28` connection, `Client::setLoggingLevel()` stamps the level on every following request, `Client::ping()` sends `server/discover` and `Client::sendRootsListChanged()` sends nothing.
* Fail a client request at once when the HTTP server refuses it with an error status or the stdio server process exits, instead of waiting out the timeout.
* [BC Break] Bump `MessageInterface::PROTOCOL_VERSION` to `2026-07-28`. Use `ProtocolVersion::latestHandshake()` where a handshake revision is needed, e.g. in an `initialize` answer.
* Add `ServerRequestEvent` and `ClientResponseEvent` for handshake-era server-initiated requests (`elicitation/create`, `sampling/createMessage`) and the client's replies.
* Dispatch `ResponseEvent` and `ErrorEvent` when a suspended Fiber completes.
* Fix a handler throwing after its fiber resumed (e.g. after elicitation or sampling) escaping to the transport: it is now answered with an error response like one throwing before suspending.
* Drop a client response to a request ID the server is not waiting on, or that timed out, instead of storing it in the session, where nothing would ever consume it.

0.8.0
-----
Expand Down
29 changes: 26 additions & 3 deletions docs/advanced/events.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ $server = Server::builder()

## Protocol Events

The SDK dispatches 4 broad event types at the protocol level, allowing you to observe and modify all server operations:
The SDK dispatches 6 broad event types at the protocol level. `RequestEvent`, `ResponseEvent`, `ErrorEvent` and `NotificationEvent` allow you to modify the message, while `ServerRequestEvent` and `ClientResponseEvent` are read-only and only let you observe it:

### RequestEvent

Expand All @@ -43,7 +43,7 @@ The SDK dispatches 4 broad event types at the protocol level, allowing you to ob

### ResponseEvent

**Dispatched**: When a successful response is ready to be sent to the client, after handler execution.
**Dispatched**: When a successful response is ready to be sent to the client, after handler execution. Also dispatched when a suspended Fiber completes (e.g. after elicitation or sampling).

**Properties**:

Expand All @@ -55,7 +55,7 @@ The SDK dispatches 4 broad event types at the protocol level, allowing you to ob

### ErrorEvent

**Dispatched**: When an error occurs during request processing.
**Dispatched**: When an error occurs during request processing. Also dispatched when a suspended Fiber completes with an error.

**Properties**:

Expand All @@ -76,6 +76,28 @@ The SDK dispatches 4 broad event types at the protocol level, allowing you to ob
- `getSession(): SessionInterface` - The current session
- `getMethod(): string` - Convenience method to get the notification method

### ServerRequestEvent

**Dispatched**: When the server sends a request to the client (e.g. `elicitation/create`, `sampling/createMessage`).

**Properties**:

- `getRequest(): Request` - The outgoing request (with server-assigned ID)
- `getSession(): SessionInterface` - The current session
- `getTimeout(): int` - Maximum time to wait for the client response (seconds)
- `getMethod(): string` - Convenience method to get the request method

### ClientResponseEvent

**Dispatched**: When the server receives a client response to a prior outgoing request.

**Properties**:

- `getResponse(): Response|Error` - The client's reply
- `getSession(): SessionInterface` - The current session
- `getId(): string|int|null` - The JSON-RPC message ID
- `isError(): bool` - Whether the client returned a JSON-RPC error

### Protocol `2026-07-28`

Requests on protocol version `2026-07-28` dispatch the same request, response and error events, with a few differences:
Expand All @@ -84,6 +106,7 @@ Requests on protocol version `2026-07-28` dispatch the same request, response an
- `getSession()` returns a new in-memory session for each request. Anything a listener stores there is gone by the next request.
- `NotificationEvent` is not dispatched, since this protocol version runs no notification handlers.
- `server/discover` and `subscriptions/listen` dispatch no events.
- `ServerRequestEvent` and `ClientResponseEvent` are not dispatched: the server asks for input with an `InputRequiredResult`, which `ResponseEvent` carries, and the client answers with `inputResponses` on the retried request.

## List Change Events

Expand Down
56 changes: 56 additions & 0 deletions src/Event/ClientResponseEvent.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
<?php

/*
* This file is part of the official PHP MCP SDK.
*
* A collaboration between Symfony and the PHP Foundation.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

namespace Mcp\Event;

use Mcp\Schema\JsonRpc\Error;
use Mcp\Schema\JsonRpc\Response;
use Mcp\Server\Session\SessionInterface;

/**
* Event dispatched when the server receives a client response to a prior outgoing request.
*
* @author Olivier Mouren <mouren.olivier@gmail.com>
*/
final class ClientResponseEvent
{
/**
* @param Response<mixed>|Error $response
*/
public function __construct(
private readonly Response|Error $response,
private readonly SessionInterface $session,
) {
}

/**
* @return Response<mixed>|Error
*/
public function getResponse(): Response|Error
{
return $this->response;
}

public function getSession(): SessionInterface
{
return $this->session;
}

public function getId(): string|int|null
{
return $this->response->getId();
}

public function isError(): bool
{
return $this->response instanceof Error;
}
}
50 changes: 50 additions & 0 deletions src/Event/ServerRequestEvent.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
<?php

/*
* This file is part of the official PHP MCP SDK.
*
* A collaboration between Symfony and the PHP Foundation.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

namespace Mcp\Event;

use Mcp\Schema\JsonRpc\Request;
use Mcp\Server\Session\SessionInterface;

/**
* Event dispatched when the server sends a request to the client (e.g. elicitation/create, sampling/createMessage).
*
* @author Olivier Mouren <mouren.olivier@gmail.com>
*/
final class ServerRequestEvent
{
public function __construct(
private readonly Request $request,
private readonly int $timeout,
private readonly SessionInterface $session,
) {
}

public function getRequest(): Request
{
return $this->request;
}

public function getSession(): SessionInterface
{
return $this->session;
}

public function getTimeout(): int
{
return $this->timeout;
}

public function getMethod(): string
{
return $this->request::getMethod();
}
}
102 changes: 74 additions & 28 deletions src/Server/Protocol.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,12 @@

namespace Mcp\Server;

use Mcp\Event\ClientResponseEvent;
use Mcp\Event\ErrorEvent;
use Mcp\Event\NotificationEvent;
use Mcp\Event\RequestEvent;
use Mcp\Event\ResponseEvent;
use Mcp\Event\ServerRequestEvent;
use Mcp\Exception\InvalidInputMessageException;
use Mcp\Exception\RuntimeException;
use Mcp\JsonRpc\MessageFactory;
Expand Down Expand Up @@ -335,14 +337,22 @@ private function handleRequest(TransportInterface $transport, Request $request,
try {
$shim = $this->inputRequiredShim;
$codec = $this->requestStateCodec;
$dispatchResult = $this->dispatchResult(...);
$errorForThrowable = $this->errorForThrowable(...);

// One fiber for the whole exchange: with the shim, the handler
// re-enters inside it each round rather than needing a new one.
// Handled in the fiber, as the transport resumes it outside the catch below.
/** @var McpFiber $fiber */
$fiber = new \Fiber(static function () use ($handler, $request, $session, $shim, $codec): Response|Error {
$result = $handler->handle($request, $session);
$fiber = new \Fiber(static function () use ($handler, $request, $session, $shim, $codec, $dispatchResult, $errorForThrowable): Response|Error {
try {
$result = $handler->handle($request, $session);
$result = $shim?->fulfill($result, $handler, $request, $session, $codec) ?? $result;
} catch (\Throwable $e) {
return $errorForThrowable($e, $request, $session);
}

return $shim?->fulfill($result, $handler, $request, $session, $codec) ?? $result;
return $dispatchResult($result, $request, $session);
Comment thread
chr-hertel marked this conversation as resolved.
});

$result = $fiber->start();
Expand Down Expand Up @@ -379,33 +389,10 @@ private function handleRequest(TransportInterface $transport, Request $request,

return;
}
$finalResult = $fiber->getReturn();

if ($finalResult instanceof Response) {
$responseEvent = $this->dispatchEvent(new ResponseEvent($finalResult, $request, $session));
$finalResult = $responseEvent->getResponse();
} elseif ($finalResult instanceof Error) {
$errorEvent = $this->dispatchEvent(new ErrorEvent($finalResult, $request, $session, null));
$finalResult = $errorEvent->getError();
}

$this->sendResponse($transport, $finalResult, $session);
} catch (\InvalidArgumentException $e) {
$this->logger->warning(\sprintf('Invalid argument: %s', $e->getMessage()), ['exception' => $e]);

$error = Error::forInvalidParams($e->getMessage(), $request->getId());
$errorEvent = $this->dispatchEvent(new ErrorEvent($error, $request, $session, $e));
$error = $errorEvent->getError();

$this->sendResponse($transport, $error, $session);
$this->sendResponse($transport, $fiber->getReturn(), $session);
} catch (\Throwable $e) {
$this->logger->error(\sprintf('Uncaught exception: %s', $e->getMessage()), ['exception' => $e]);

$error = Error::forInternalError(self::INTERNAL_ERROR_MESSAGE, $request->getId());
$errorEvent = $this->dispatchEvent(new ErrorEvent($error, $request, $session, $e));
$error = $errorEvent->getError();

$this->sendResponse($transport, $error, $session);
$this->sendResponse($transport, $this->errorForThrowable($e, $request, $session), $session);
}

break;
Expand Down Expand Up @@ -435,6 +422,16 @@ private function handleResponse(Response|Error $response, SessionInterface $sess
return;
}

// Request IDs are ints: a string ID like "1000" would otherwise match through PHP's key coercion.
$pending = \is_int($messageId) ? $session->get(self::SESSION_PENDING_REQUESTS, [])[$messageId] ?? null : null;
if (!\is_array($pending) || $this->hasTimedOut($pending)) {
$this->logger->warning('Received a client response for an unknown or timed out request ID.', ['message_id' => $messageId]);

return;
Comment on lines +427 to +430

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Filed #578 for this

}

$this->dispatchEvent(new ClientResponseEvent($response, $session));

$session->set(self::SESSION_RESPONSES.".{$messageId}", $response->jsonSerialize());
$session->forget(self::SESSION_ACTIVE_REQUEST_META);

Expand Down Expand Up @@ -474,6 +471,8 @@ public function sendRequest(Request $request, int $timeout, SessionInterface $se

$requestWithId = $request->withId($requestId);

$this->dispatchEvent(new ServerRequestEvent($requestWithId, $timeout, $session));

$this->logger->info('Queueing server request to client', [
'request_id' => $requestId,
'method' => $request::getMethod(),
Expand Down Expand Up @@ -636,6 +635,14 @@ public function checkResponse(int $requestId, Uuid $sessionId): Response|Error|n
}
}

/**
* @param array<mixed> $pending
*/
private function hasTimedOut(array $pending): bool
{
return time() - (int) ($pending['timestamp'] ?? 0) >= (int) ($pending['timeout'] ?? 120);
}

/**
* Get pending requests for a session.
*
Expand Down Expand Up @@ -723,6 +730,45 @@ private function getAwaitedPendingRequests(?TransportInterface $transport, Uuid
return array_intersect_key($this->getPendingRequests($sessionId), [$requestId => true]);
}

/**
* @param Response<mixed>|Error $result
*
* @return Response<mixed>|Error
*/
private function dispatchResult(Response|Error $result, Request $request, SessionInterface $session): Response|Error
{
// A resumed fiber runs this outside handleRequest()'s catch.
try {
if ($result instanceof Response) {
return $this->dispatchEvent(new ResponseEvent($result, $request, $session))->getResponse();
}

return $this->dispatchEvent(new ErrorEvent($result, $request, $session, null))->getError();
} catch (\Throwable $e) {
return $this->errorForThrowable($e, $request, $session);
}
}

private function errorForThrowable(\Throwable $e, Request $request, SessionInterface $session): Error
{
if ($e instanceof \InvalidArgumentException) {
$this->logger->warning(\sprintf('Invalid argument: %s', $e->getMessage()), ['exception' => $e]);
$error = Error::forInvalidParams($e->getMessage(), $request->getId());
} else {
$this->logger->error(\sprintf('Uncaught exception: %s', $e->getMessage()), ['exception' => $e]);
$error = Error::forInternalError(self::INTERNAL_ERROR_MESSAGE, $request->getId());
}

try {
return $this->dispatchEvent(new ErrorEvent($error, $request, $session, $e))->getError();
} catch (\Throwable $listenerError) {
// Last resort: the error still has to reach the client.
$this->logger->error('Error event listener failed.', ['exception' => $listenerError]);

return $error;
}
}

/**
* @param array<int, mixed> $messages
*/
Expand Down
31 changes: 30 additions & 1 deletion tests/Unit/Server/ProtocolSessionRaceTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,9 @@ public function testPollingDoesNotOverwriteAConcurrentResponse(): void
$store = new InterleavingSessionStore();
$sessions = new SessionManager($store, gcProbability: 0);
$sessionId = Uuid::v4();
$sessions->createWithId($sessionId)->save();
$session = $sessions->createWithId($sessionId);
$session->set('_mcp.pending_requests', [7 => ['request_id' => 7, 'timeout' => 120, 'timestamp' => time()]]);
$session->save();

$waiting = new Protocol([], [], MessageFactory::make(), $sessions);
$answering = new Protocol([], [], MessageFactory::make(), $sessions);
Expand All @@ -51,4 +53,31 @@ public function testPollingDoesNotOverwriteAConcurrentResponse(): void

$this->assertInstanceOf(Response::class, $waiting->checkResponse(7, $sessionId));
}

#[TestDox('polling a timed out request does not overwrite a client response stored meanwhile')]
public function testPollingATimedOutRequestDoesNotOverwriteAConcurrentResponse(): void
{
$store = new InterleavingSessionStore();
$sessions = new SessionManager($store, gcProbability: 0);
$sessionId = Uuid::v4();
$session = $sessions->createWithId($sessionId);
$session->set('_mcp.pending_requests', [
7 => ['request_id' => 7, 'timeout' => 5, 'timestamp' => time() - 10],
8 => ['request_id' => 8, 'timeout' => 120, 'timestamp' => time()],
]);
$session->save();

$waiting = new Protocol([], [], MessageFactory::make(), $sessions);
$answering = new Protocol([], [], MessageFactory::make(), $sessions);
$transport = $this->createMock(TransportInterface::class);

// Another stream's answer lands while this stream polls its timed out request.
$store->interleaveAfterNextRead(static function () use ($answering, $transport, $sessionId): void {
$answering->processInput($transport, '{"jsonrpc": "2.0", "id": 8, "result": {"ok": true}}', $sessionId);
});

$this->assertNull($waiting->checkResponse(7, $sessionId));

$this->assertInstanceOf(Response::class, $waiting->checkResponse(8, $sessionId));
}
}
Loading
Loading