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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ All notable changes to `mcp/sdk` will be documented in this file.
* Fix stateless SSE streams holding back frames until close when PHP output buffering is enabled.
* Reject a recognized `Mcp-Param-*` header whose mirrored argument is absent from the body with `-32020`, instead of accepting the request (SEP-2243).
* Fix `JwtTokenValidator` with several issuers always fetching the keys of the first one: keys now come from the issuer the token claims, which must be configured.
* Fix `RequestEvent`, `ResponseEvent` and `ErrorEvent` not being dispatched for `2026-07-28` requests.

0.8.0
-----
Expand Down
9 changes: 9 additions & 0 deletions docs/advanced/events.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,15 @@ 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

### Protocol `2026-07-28`

Requests on protocol version `2026-07-28` dispatch the same request, response and error events, with a few differences:

- `ResponseEvent` fires on every `InputRequiredResult` round, not only on the final result. Listeners that only care about completed calls need to check the result type.
- `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.

## List Change Events

These events are dispatched when the lists of available capabilities change:
Expand Down
1 change: 1 addition & 0 deletions src/Server/Builder.php
Original file line number Diff line number Diff line change
Expand Up @@ -992,6 +992,7 @@ public function buildStateless(array $supportedVersions = [ProtocolVersion::V202
cachePolicy: $this->cachePolicy,
notificationBus: $this->notificationBus,
extensionMethods: $this->extensionMethods,
eventDispatcher: $parts['eventDispatcher'],
);
}

Expand Down
90 changes: 64 additions & 26 deletions src/Server/Stateless/StatelessProtocol.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,9 @@

namespace Mcp\Server\Stateless;

use Mcp\Event\ErrorEvent;
use Mcp\Event\RequestEvent;
use Mcp\Event\ResponseEvent;
use Mcp\Exception\InvalidInputMessageException;
use Mcp\Exception\LogicException;
use Mcp\Exception\MissingRequestMetaException;
Expand All @@ -37,6 +40,7 @@
use Mcp\Server\Wire\InboundClassifier;
use Mcp\Server\Wire\Rev2026Codec;
use Mcp\Server\Wire\WireCodecInterface;
use Psr\EventDispatcher\EventDispatcherInterface;
use Psr\Log\LoggerInterface;
use Psr\Log\NullLogger;

Expand Down Expand Up @@ -106,6 +110,7 @@ public function __construct(
?CachePolicy $cachePolicy = null,
private readonly ?NotificationBusInterface $notificationBus = null,
private readonly array $extensionMethods = [],
private readonly ?EventDispatcherInterface $eventDispatcher = null,
) {
$this->codec = $codec ?? new Rev2026Codec($configuration->serverInfo, $cachePolicy);

Expand Down Expand Up @@ -470,6 +475,8 @@ private function dispatch(string $method, array $decoded, RequestMeta $meta, str
// the handshake era sets under the same key.
$session->set(Protocol::SESSION_ACTIVE_REQUEST_META, $request->getMeta());

$request = $this->dispatchEvent(new RequestEvent($request, $session))->getRequest();

foreach ($this->requestHandlers as $handler) {
if (!$handler->supports($request)) {
continue;
Expand All @@ -485,11 +492,11 @@ private function dispatch(string $method, array $decoded, RequestMeta $meta, str
// with 400 rather than an error frame under a 200.
$run->rewind();
} catch (\Throwable $e) {
return $this->toErrorResult($method, $id, $e);
return $this->toErrorResult($request, $session, $method, $id, $e);
}

if ($run->valid() && $wantsStream) {
return StatelessResult::stream(fn (): \Generator => $this->streamFrames($run, $meta, $method, $id, null === $input));
return StatelessResult::stream(fn (): \Generator => $this->streamFrames($run, $request, $session, $meta, $method, $id, null === $input));
}

try {
Expand All @@ -506,21 +513,51 @@ private function dispatch(string $method, array $decoded, RequestMeta $meta, str

$result = $run->getReturn();
} catch (\Throwable $e) {
return $this->toErrorResult($method, $id, $e);
return $this->toErrorResult($request, $session, $method, $id, $e);
}

if ($result instanceof Error) {
return StatelessResult::error($result, 400);
return StatelessResult::error($this->dispatchError($result, $request, $session), 400);
}

if (null !== $capabilityError = $this->checkInputRequests($result->result, $meta, $method, $id)) {
return $capabilityError;
return StatelessResult::error($this->dispatchError($capabilityError, $request, $session), 400);
}

return $this->encode($method, $id, $result->result, null === $input);
return $this->encode($method, $id, $this->dispatchResponse($result, $request, $session)->result, null === $input);
}

return StatelessResult::error($this->unknownMethod($method, $id), 404);
return StatelessResult::error($this->dispatchError($this->unknownMethod($method, $id), $request, $session), 404);
}

/**
* @template T of object
*
* @param T $event
*
* @return T
*/
private function dispatchEvent(object $event): object
{
return $this->eventDispatcher?->dispatch($event) ?? $event;
}

/**
* @param Response<ResultInterface> $response
*
* @return Response<ResultInterface>
*/
private function dispatchResponse(Response $response, Request $request, Session $session): Response
{
/** @var Response<ResultInterface> $response */
$response = $this->dispatchEvent(new ResponseEvent($response, $request, $session))->getResponse();

return $response;
}

private function dispatchError(Error $error, Request $request, Session $session, ?\Throwable $throwable = null): Error
{
return $this->dispatchEvent(new ErrorEvent($error, $request, $session, $throwable))->getError();
}

/**
Expand Down Expand Up @@ -679,7 +716,7 @@ private static function readElicitation(mixed $suspended): ?array
*
* @return \Generator<mixed>
*/
private function streamFrames(\Generator $run, RequestMeta $meta, string $method, string|int $id, bool $cacheable): \Generator
private function streamFrames(\Generator $run, Request $request, Session $session, RequestMeta $meta, string $method, string|int $id, bool $cacheable): \Generator
{
try {
while ($run->valid()) {
Expand All @@ -692,20 +729,24 @@ private function streamFrames(\Generator $run, RequestMeta $meta, string $method
} catch (\Throwable $e) {
// Headers left long ago, so the status is already 200 and the only
// way left to report this is a frame.
yield $this->toErrorResult($method, $id, $e)->message?->jsonSerialize();
yield $this->toErrorResult($request, $session, $method, $id, $e)->message?->jsonSerialize();

return;
}

if (!$result instanceof Error && null !== $capabilityError = $this->checkInputRequests($result->result, $meta, $method, $id)) {
yield $capabilityError->message?->jsonSerialize();
$result = $capabilityError;
}

if ($result instanceof Error) {
yield $this->dispatchError($result, $request, $session)->jsonSerialize();

return;
}

yield $result instanceof Error
? $result->jsonSerialize()
: ['jsonrpc' => '2.0', 'id' => $id, 'result' => $this->codec->encodeResult($method, (array) $result->result->jsonSerialize(), $cacheable)];
$response = $this->dispatchResponse($result, $request, $session);

yield ['jsonrpc' => '2.0', 'id' => $id, 'result' => $this->codec->encodeResult($method, (array) $response->result->jsonSerialize(), $cacheable)];
}

/**
Expand All @@ -716,7 +757,7 @@ private function streamFrames(\Generator $run, RequestMeta $meta, string $method
* "processing this needs a capability you did not declare" — so it is
* reported as that, and logged as the server-side bug it is.
*/
private function checkInputRequests(ResultInterface $result, RequestMeta $meta, string $method, string|int $id): ?StatelessResult
private function checkInputRequests(ResultInterface $result, RequestMeta $meta, string $method, string|int $id): ?Error
{
if (!$result instanceof InputRequiredResult) {
return null;
Expand All @@ -733,13 +774,10 @@ private function checkInputRequests(ResultInterface $result, RequestMeta $meta,
'required' => $missing->jsonSerialize(),
]);

return StatelessResult::error(
Error::forMissingRequiredClientCapability(
'The server needs input this client did not declare it can provide.',
$missing,
$id,
),
400,
return Error::forMissingRequiredClientCapability(
'The server needs input this client did not declare it can provide.',
$missing,
$id,
);
}

Expand Down Expand Up @@ -772,28 +810,28 @@ private static function withTraceContext(array $frame, array $traceContext): arr
* The one place a handler's exception becomes an answer, so the streaming
* and non-streaming paths cannot disagree about which code it earns.
*/
private function toErrorResult(string $method, string|int $id, \Throwable $e): StatelessResult
private function toErrorResult(Request $request, Session $session, string $method, string|int $id, \Throwable $e): StatelessResult
{
if ($e instanceof MissingRequiredClientCapabilityException) {
return StatelessResult::error(
Error::forMissingRequiredClientCapability($e->getMessage(), $e->requiredCapabilities, $id),
$this->dispatchError(Error::forMissingRequiredClientCapability($e->getMessage(), $e->requiredCapabilities, $id), $request, $session, $e),
400,
);
}

if ($e instanceof \InvalidArgumentException) {
return StatelessResult::error(Error::forInvalidParams($e->getMessage(), $id), 400);
return StatelessResult::error($this->dispatchError(Error::forInvalidParams($e->getMessage(), $id), $request, $session, $e), 400);
}

if ($e instanceof LogicException) {
// Guidance for the tool author, not a detail leaked from their
// code or a dependency's — safe to echo back verbatim.
return StatelessResult::error(Error::forInternalError($e->getMessage(), $id), 500);
return StatelessResult::error($this->dispatchError(Error::forInternalError($e->getMessage(), $id), $request, $session, $e), 500);
}

$this->logger->error('Uncaught exception handling a modern-era request.', ['method' => $method, 'exception' => $e]);

return StatelessResult::error(Error::forInternalError(self::INTERNAL_ERROR_MESSAGE, $id), 500);
return StatelessResult::error($this->dispatchError(Error::forInternalError(self::INTERNAL_ERROR_MESSAGE, $id), $request, $session, $e), 500);
}

/**
Expand Down
115 changes: 111 additions & 4 deletions tests/Unit/Server/Stateless/StatelessProtocolTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,9 @@

namespace Mcp\Tests\Unit\Server\Stateless;

use Mcp\Event\ErrorEvent;
use Mcp\Event\RequestEvent;
use Mcp\Event\ResponseEvent;
use Mcp\Exception\MissingRequiredClientCapabilityException;
use Mcp\Schema\ClientCapabilities;
use Mcp\Schema\Content\TextResourceContents;
Expand All @@ -23,6 +26,7 @@
use Mcp\Schema\Notification\PromptListChangedNotification;
use Mcp\Schema\Notification\ResourceUpdatedNotification;
use Mcp\Schema\Notification\ToolListChangedNotification;
use Mcp\Schema\Request\CallToolRequest;
use Mcp\Schema\Request\ElicitRequest;
use Mcp\Schema\Request\ListRootsRequest;
use Mcp\Schema\Result\InputRequiredResult;
Expand All @@ -40,15 +44,16 @@
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\Attributes\TestDox;
use PHPUnit\Framework\TestCase;
use Psr\EventDispatcher\EventDispatcherInterface;

class StatelessProtocolTest extends TestCase
{
/**
* @param array<string, mixed> $capabilities
*/
private static function protocol(array $capabilities = []): StatelessProtocol
private static function protocol(array $capabilities = [], ?EventDispatcherInterface $eventDispatcher = null): StatelessProtocol
{
return Server::builder()
$builder = Server::builder()
->setServerInfo('test-server', '1.0.0')
->addTool(static fn (): string => 'ok', name: 'plain_tool', description: 'Returns a fixed string')
->addTool(
Expand Down Expand Up @@ -174,8 +179,13 @@ static function (RequestContext $context): string|InputRequiredResult {
'test://gated',
'gated',
'A resource that asks who is reading before it answers',
)
->buildStateless([ProtocolVersion::V2026_07_28]);
);

if (null !== $eventDispatcher) {
$builder->setEventDispatcher($eventDispatcher);
}

return $builder->buildStateless([ProtocolVersion::V2026_07_28]);
}

/**
Expand Down Expand Up @@ -1203,4 +1213,101 @@ public function testElicitationDefaultsToFormMode(): void

$this->assertSame('elicitation', $answer['body']['result']['content'][0]['text']);
}

/**
* @param list<object> $events
* @param (\Closure(object): void)|null $listener
*/
private function eventDispatcher(array &$events, ?\Closure $listener = null): EventDispatcherInterface
{
$eventDispatcher = $this->createStub(EventDispatcherInterface::class);
$eventDispatcher->method('dispatch')->willReturnCallback(static function (object $event) use (&$events, $listener): object {
$events[] = $event;

if (null !== $listener) {
$listener($event);
}

return $event;
});

return $eventDispatcher;
}

#[TestDox('request and response events are dispatched for a modern-era request')]
public function testRequestAndResponseEventsAreDispatched(): void
{
$events = [];

$answer = self::call(
self::protocol(eventDispatcher: $this->eventDispatcher($events)),
'tools/call',
['name' => 'plain_tool', 'arguments' => []],
['Mcp-Name' => 'plain_tool'],
);

$this->assertSame(200, $answer['status']);
$this->assertCount(2, $events);
$this->assertInstanceOf(RequestEvent::class, $events[0]);
$this->assertSame('tools/call', $events[0]->getMethod());
$this->assertInstanceOf(ResponseEvent::class, $events[1]);
}

#[TestDox('a request replaced by a RequestEvent listener is the one handled')]
public function testRequestEventCanReplaceTheRequest(): void
{
$events = [];
$listener = static function (object $event): void {
if ($event instanceof RequestEvent) {
$request = $event->getRequest();
$event->setRequest((new CallToolRequest('plain_tool', []))->withId($request->getId())->withMeta($request->getMeta()));
}
};

$answer = self::call(
self::protocol(eventDispatcher: $this->eventDispatcher($events, $listener)),
'tools/call',
['name' => 'probe_trace', 'arguments' => []],
['Mcp-Name' => 'probe_trace'],
);

$this->assertSame('ok', $answer['body']['result']['content'][0]['text']);
}

#[TestDox('an error event is dispatched and can replace the error of a modern-era request')]
public function testErrorEventIsDispatched(): void
{
$events = [];
$listener = static function (object $event): void {
if ($event instanceof ErrorEvent) {
$event->setError(Error::forInvalidParams('replaced', $event->getError()->id));
}
};

$answer = self::call(
self::protocol(eventDispatcher: $this->eventDispatcher($events, $listener)),
'tools/call',
['name' => 'capability_tool', 'arguments' => []],
['Mcp-Name' => 'capability_tool'],
);

$this->assertCount(2, $events);
$this->assertInstanceOf(RequestEvent::class, $events[0]);
$this->assertInstanceOf(ErrorEvent::class, $events[1]);
$this->assertInstanceOf(MissingRequiredClientCapabilityException::class, $events[1]->getThrowable());
$this->assertSame('replaced', $answer['body']['error']['message']);
}

#[TestDox('a response event is dispatched for a streamed response too')]
public function testResponseEventIsDispatchedWhenStreamed(): void
{
$events = [];

$frames = self::frames(self::callStreaming(self::protocol(eventDispatcher: $this->eventDispatcher($events)), 'progress_tool', ['progressToken' => 'tok-1']));

$this->assertCount(3, $frames);
$this->assertCount(2, $events);
$this->assertInstanceOf(RequestEvent::class, $events[0]);
$this->assertInstanceOf(ResponseEvent::class, $events[1]);
}
}
Loading