Skip to content
Open
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
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`).

**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
- `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
{
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();

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