Skip to content
35 changes: 32 additions & 3 deletions docs/advanced/events.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,11 +28,13 @@ $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:
`RequestEvent`, `ResponseEvent` and `ErrorEvent` fire on both the handshake era and the modern (`2026-07-28`) era.

On the modern era the session carried by these events is per-request and discarded after the response.

### RequestEvent

**Dispatched**: When any request is received from the client, before it's processed by handlers.
**Dispatched**: When any request is received from the client, before it's processed by handlers. On the modern era this includes a multi round-trip retry MRTR : if the client sent `inputResponses`, they are already lifted onto the session as `InputContext` (`$event->getSession()->get(InputContext::class)`). They are not on the typed `Request` (for example `CallToolRequest` only carries `name` and `arguments`).

**Properties**:
- `getRequest(): Request` - The incoming request
Expand All @@ -43,6 +45,9 @@ 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.
Also dispatched when a suspended Fiber completes (e.g. after elicitation or sampling on a handshake connection).

On the modern era an elicitation is a successful result of the original method (`resultType: input_required`). Listen for `ResponseEvent` whose `$event->getResponse()->result` is an `InputRequiredResult`.

**Properties**:
- `getResponse(): Response` - The response being sent
Expand All @@ -53,7 +58,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**:
- `getError(): Error` - The error being sent
Expand All @@ -62,6 +67,10 @@ The SDK dispatches 4 broad event types at the protocol level, allowing you to ob
- `getThrowable(): ?\Throwable` - The exception that caused the error (if any)
- `getSession(): SessionInterface` - The current session

## Handshake era only, before MCP 2026-07-28

These events are dispatched only on handshake-era connections (protocol revisions before `2026-07-28`). The modern revision has no client-to-server notification handlers over HTTP, and no server-initiated JSON-RPC requests.

### NotificationEvent

**Dispatched**: When a notification is received from the client, before it's processed by handlers.
Expand All @@ -72,6 +81,26 @@ 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/create`).
Comment thread
omouren marked this conversation as resolved.
Outdated

**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` - The JSON-RPC message ID
Comment thread
omouren marked this conversation as resolved.
Outdated
- `isError(): bool` - Whether the client returned a JSON-RPC error

## List Change Events

These events are dispatched when the lists of available capabilities change:
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
Comment thread
omouren marked this conversation as resolved.
Outdated
{
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/create).
*
* @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();
}
}
4 changes: 2 additions & 2 deletions src/JsonRpc/MessageFactory.php
Original file line number Diff line number Diff line change
Expand Up @@ -148,7 +148,7 @@ public function create(string $input): array
throw new InvalidInputMessageException('A JSON-RPC message must be a JSON object.');
}

$messages[] = $this->createMessage($message);
$messages[] = $this->createFromArray($message);
} catch (InvalidInputMessageException $e) {
// Recover the id only when it's a valid JSON-RPC scalar;
// a null or malformed id is left at the exception's null default.
Expand All @@ -169,7 +169,7 @@ public function create(string $input): array
*
* @throws InvalidInputMessageException
*/
private function createMessage(array $data): MessageInterface
public function createFromArray(array $data): MessageInterface
{
try {
if (isset($data['error'])) {
Expand Down
1 change: 1 addition & 0 deletions src/Server/Builder.php
Original file line number Diff line number Diff line change
Expand Up @@ -982,6 +982,7 @@ public function buildStateless(array $supportedVersions = [ProtocolVersion::V202
cachePolicy: $this->cachePolicy,
notificationBus: $this->notificationBus,
extensionMethods: $this->extensionMethods,
eventDispatcher: $parts['eventDispatcher'],
);
}

Expand Down
56 changes: 56 additions & 0 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\JsonRpc\MessageFactory;
use Mcp\Schema\JsonRpc\Error;
Expand Down Expand Up @@ -58,6 +60,9 @@ class Protocol
/** Session key for outgoing message queue */
private const SESSION_OUTGOING_QUEUE = '_mcp.outgoing_queue';

/** Session key for the client request that started a suspended Fiber */
private const SESSION_FIBER_PARENT_REQUEST = '_mcp.fiber_parent_request';

/** Session key for active request meta */
public const SESSION_ACTIVE_REQUEST_META = '_mcp.active_request_meta';

Expand Down Expand Up @@ -106,6 +111,8 @@ public function connect(TransportInterface $transport): void

$transport->setFiberYieldHandler($this->handleFiberYield(...));

$transport->setFiberTerminationHandler($this->handleFiberTermination(...));

$this->logger->info('Protocol connected to transport', ['transport' => $transport::class]);
}

Expand Down Expand Up @@ -298,6 +305,8 @@ private function handleRequest(TransportInterface $transport, Request $request,
$result = $fiber->start();

if ($fiber->isSuspended()) {
$session->set(self::SESSION_FIBER_PARENT_REQUEST, $request->jsonSerialize());

if (\is_array($result) && isset($result['type'])) {
if ('notification' === $result['type']) {
$notification = $result['notification'];
Expand Down Expand Up @@ -361,6 +370,8 @@ private function handleResponse(Response|Error $response, SessionInterface $sess
{
$this->logger->info('Handling response from client.', ['response' => $response]);

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

$messageId = $response->getId();

if (null === $messageId) {
Expand Down Expand Up @@ -408,6 +419,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 @@ -645,6 +658,49 @@ public function handleFiberYield(mixed $yieldedValue, ?Uuid $sessionId): void
}
}

/**
* Handle the final result of a suspended Fiber when it completes.
*
* Dispatches ResponseEvent or ErrorEvent for the original client request that
* started the Fiber, allowing listeners to observe deferred responses.
*
* @phpstan-param Response<mixed>|Error $finalResult
*
* @phpstan-return Response<mixed>|Error
*/
public function handleFiberTermination(Response|Error $finalResult, Uuid $sessionId): Response|Error
{
$session = $this->sessionManager->createWithId($sessionId);
$parentRequest = $this->resolveFiberParentRequest(
$session->pull(self::SESSION_FIBER_PARENT_REQUEST)
);

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

$session->save();

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

should be moved into the if (null !== $parentRequest), no?

or maybe just invert that to keep the if small

if (null === $parentRequest) {
    return $finalResult;
}

@omouren omouren Sep 2, 2026

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

@chr-hertel
save() has to stay even when $parentRequest is null. pull() already forgets SESSION_FIBER_PARENT_REQUEST. Without a save that deletion never hits the session store, so the next createWithId() would see the parent request again.


return $finalResult;
}

private function resolveFiberParentRequest(mixed $data): ?Request
{
if (!\is_array($data)) {
return null;
}

$message = $this->messageFactory->createFromArray($data);

return $message instanceof Request ? $message : null;
}

/**
* @param array<int, mixed> $messages
*/
Expand Down
Loading