-
Notifications
You must be signed in to change notification settings - Fork 167
[Server] Add outgoing (elicitation, sampling) request and client response events #386
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
f221c38
6c0f6fb
25f3deb
1c07c98
a626b04
40286f3
96cfe12
7aa5c7c
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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 | ||
|
|
@@ -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 | ||
|
|
@@ -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 | ||
|
|
@@ -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. | ||
|
|
@@ -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 | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. same here |
||
| - `isError(): bool` - Whether the client returned a JSON-RPC error | ||
|
|
||
| ## List Change Events | ||
|
|
||
| These events are dispatched when the lists of available capabilities change: | ||
|
|
||
| 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 | ||||||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. in case
Suggested change
unfortunately our PHPStan setup is not on the proper level to catch that. |
||||||
| { | ||||||
| return $this->response->getId(); | ||||||
| } | ||||||
|
|
||||||
| public function isError(): bool | ||||||
| { | ||||||
| return $this->response instanceof Error; | ||||||
| } | ||||||
| } | ||||||
| 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(); | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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; | ||
|
|
@@ -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'; | ||
|
|
||
|
|
@@ -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]); | ||
| } | ||
|
|
||
|
|
@@ -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']; | ||
|
|
@@ -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) { | ||
|
|
@@ -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(), | ||
|
|
@@ -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(); | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. should be moved into the or maybe just invert that to keep the if small if (null === $parentRequest) {
return $finalResult;
} |
||
|
|
||
| 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 | ||
| */ | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.