Skip to content

Commit dd3783a

Browse files
authored
[Server] Stream a modern-era response when there is something to stream (#448)
tools/call answered with one JSON object, so a handler's progress and log notifications had nowhere to go — and reached for a fiber that was not there, coming back as -32603. Handlers now run in a fiber and the stream opens after the first suspension, late enough that an error still gets the status the spec fixes for it. logLevel finally gates the log messages it was already being parsed for, and a server-initiated request is refused by name rather than as a FiberError.
1 parent 5e73683 commit dd3783a

6 files changed

Lines changed: 425 additions & 43 deletions

File tree

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ All notable changes to `mcp/sdk` will be documented in this file.
55
0.8.0
66
-----
77

8+
* Answer a request over a response stream under the 2026-07-28 lifecycle: `StatelessProtocol` runs handlers in a fiber, so `$gateway->progress()` and `$gateway->log()` work there as they do in the handshake era. The stream opens only if the handler actually emits something *and* the client's `Accept` admits `text/event-stream`, and the choice is made after the handler's first suspension, so a request that turns out to need `-32021` or `-32602` is still answered with the status the spec fixes for it.
9+
* Honour `io.modelcontextprotocol/logLevel` (SEP-2575), which replaced the `logging/setLevel` RPC: a request naming no level receives no `notifications/message` at all, one naming a level receives the messages at or above it. Adds `LoggingLevel::severity()` and `LoggingLevel::isAtLeast()`.
810
* [BC Break] Answer a not-found subject with `-32602` (Invalid params) instead of `-32002`, which the 2026-07-28 revision reserves and forbids emitting (SEP-2164). `resources/read` picks the code from the revision serving the request — `-32602` with the uri in `error.data` from `2026-07-28` on, `-32002` below. `prompts/get` for an unknown prompt, `completion/complete` for an unknown reference and `tools/call` for an unknown tool switch to `-32602` in *every* revision: `-32002` was never the code for those. Adds `ProtocolVersion::usesInvalidParamsForResourceNotFound()`.
911
* Add the multi round-trip requests pattern for the 2026-07-28 lifecycle (SEP-2322): a `tools/call` or `prompts/get` handler returning `Mcp\Schema\Result\InputRequiredResult` comes back as `resultType: "input_required"` carrying the `inputRequests` it needs answered and an opaque `requestState`; the client retries the same request with `inputResponses`, which the handler reads through `RequestContext::getInputContext()`. `Mcp\Server\Stateless\RequestStateCodec` signs and time-bounds the state — set the key with `Builder::setRequestStateKey()`.
1012
* Validate the standard MCP request headers under the 2026-07-28 lifecycle (SEP-2243): `Mcp\Server\Stateless\StandardHeaderValidator`, set with `Builder::setHeaderValidator()`, checks that `Mcp-Method` and `Mcp-Name` agree with the body they travel with and that a `Mcp-Param-*` mirrors the argument its tool marked `x-mcp-header`, answering `-32020` when they disagree. Intermediaries route on these headers, so a value contradicting the body has to be refused rather than ignored.

src/Capability/Logger/ClientLogger.php

Lines changed: 1 addition & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@ public function log($level, $message, array $context = []): void
5151
$minimumLevel = $this->session->get(Protocol::SESSION_LOGGING_LEVEL, '');
5252
$minimumLevel = LoggingLevel::tryFrom($minimumLevel) ?? LoggingLevel::Warning;
5353

54-
if ($this->getSeverityIndex($minimumLevel) > $this->getSeverityIndex($mcpLevel)) {
54+
if (!$mcpLevel->isAtLeast($minimumLevel)) {
5555
return;
5656
}
5757

@@ -79,24 +79,4 @@ private function convertToMcpLevel($level): ?LoggingLevel
7979
default => null,
8080
};
8181
}
82-
83-
/**
84-
* Gets the severity index for this log level.
85-
* Higher values indicate more severe log levels.
86-
*
87-
* @return int Severity index (0-7, where 7 is most severe)
88-
*/
89-
private function getSeverityIndex(LoggingLevel $level): int
90-
{
91-
return match ($level) {
92-
LoggingLevel::Debug => 0,
93-
LoggingLevel::Info => 1,
94-
LoggingLevel::Notice => 2,
95-
LoggingLevel::Warning => 3,
96-
LoggingLevel::Error => 4,
97-
LoggingLevel::Critical => 5,
98-
LoggingLevel::Alert => 6,
99-
LoggingLevel::Emergency => 7,
100-
};
101-
}
10282
}

src/Schema/Enum/LoggingLevel.php

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,4 +32,31 @@ enum LoggingLevel: string
3232
case Critical = 'critical';
3333
case Alert = 'alert';
3434
case Emergency = 'emergency';
35+
36+
/**
37+
* RFC 5424 ordering, inverted so a larger number is more severe — which is
38+
* the direction a minimum-level comparison reads in.
39+
*/
40+
public function severity(): int
41+
{
42+
return match ($this) {
43+
self::Debug => 0,
44+
self::Info => 1,
45+
self::Notice => 2,
46+
self::Warning => 3,
47+
self::Error => 4,
48+
self::Critical => 5,
49+
self::Alert => 6,
50+
self::Emergency => 7,
51+
};
52+
}
53+
54+
/**
55+
* Whether a message at this level should be emitted when $minimum was
56+
* requested.
57+
*/
58+
public function isAtLeast(self $minimum): bool
59+
{
60+
return $this->severity() >= $minimum->severity();
61+
}
3562
}

src/Server/Stateless/StatelessProtocol.php

Lines changed: 189 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -12,17 +12,22 @@
1212
namespace Mcp\Server\Stateless;
1313

1414
use Mcp\Exception\InvalidInputMessageException;
15+
use Mcp\Exception\LogicException;
1516
use Mcp\Exception\MissingRequestMetaException;
1617
use Mcp\Exception\MissingRequiredClientCapabilityException;
1718
use Mcp\Exception\RequestStateException;
1819
use Mcp\JsonRpc\MessageFactory;
1920
use Mcp\Schema\Enum\ProtocolVersion;
2021
use Mcp\Schema\JsonRpc\Error;
22+
use Mcp\Schema\JsonRpc\Notification;
2123
use Mcp\Schema\JsonRpc\Request;
24+
use Mcp\Schema\JsonRpc\Response;
2225
use Mcp\Schema\JsonRpc\ResultInterface;
26+
use Mcp\Schema\Notification\LoggingMessageNotification;
2327
use Mcp\Schema\Result\DiscoverResult;
2428
use Mcp\Server\Configuration;
2529
use Mcp\Server\Handler\Request\RequestHandlerInterface;
30+
use Mcp\Server\Protocol;
2631
use Mcp\Server\Session\InMemorySessionStore;
2732
use Mcp\Server\Session\Session;
2833
use Mcp\Server\Wire\Rev2026Codec;
@@ -33,7 +38,7 @@
3338
/**
3439
* Dispatches a single modern-era (SEP-2575) request.
3540
*
36-
* Separate from {@see \Mcp\Server\Protocol} because the modern era has no
41+
* Separate from {@see Protocol} because the modern era has no
3742
* session to resolve, replay or keep a fiber against; the two eras share
3843
* request handlers, not control flow.
3944
*
@@ -203,7 +208,7 @@ public function handle(string $body, array $headers = []): StatelessResult
203208
);
204209
}
205210

206-
return $this->dispatch($method, $decoded, $meta, $id);
211+
return $this->dispatch($method, $decoded, $meta, $id, self::acceptsEventStream($headers));
207212
}
208213

209214
/**
@@ -338,7 +343,7 @@ private function discover(): DiscoverResult
338343
/**
339344
* @param array<string, mixed> $decoded
340345
*/
341-
private function dispatch(string $method, array $decoded, RequestMeta $meta, string|int|null $id): StatelessResult
346+
private function dispatch(string $method, array $decoded, RequestMeta $meta, string|int|null $id, bool $wantsStream = false): StatelessResult
342347
{
343348
try {
344349
$messages = $this->messageFactory->create(json_encode($decoded, \JSON_THROW_ON_ERROR));
@@ -405,24 +410,47 @@ private function dispatch(string $method, array $decoded, RequestMeta $meta, str
405410
$session->set(RequestStateCodec::class, $this->requestStateCodec);
406411
}
407412

413+
// What ClientGateway::progress() reads to find the progress token, and
414+
// the handshake era sets under the same key.
415+
$session->set(Protocol::SESSION_ACTIVE_REQUEST_META, $request->getMeta());
416+
408417
foreach ($this->requestHandlers as $handler) {
409418
if (!$handler->supports($request)) {
410419
continue;
411420
}
412421

422+
$run = $this->run($handler, $request, $session, $meta);
423+
413424
try {
414-
$result = $handler->handle($request, $session);
415-
} catch (MissingRequiredClientCapabilityException $e) {
416-
return StatelessResult::error(
417-
Error::forMissingRequiredClientCapability($e->getMessage(), $e->requiredCapabilities, $id),
418-
400,
419-
);
420-
} catch (\InvalidArgumentException $e) {
421-
return StatelessResult::error(Error::forInvalidParams($e->getMessage(), $id), 400);
425+
// Runs the handler up to its first notification, or to the end
426+
// if it emits none. Deciding here and not earlier is what keeps
427+
// the status codes honest: a request that turns out to need
428+
// -32021 has said nothing yet, so it can still be answered
429+
// with 400 rather than an error frame under a 200.
430+
$run->rewind();
422431
} catch (\Throwable $e) {
423-
$this->logger->error('Uncaught exception handling a modern-era request.', ['method' => $method, 'exception' => $e]);
432+
return $this->toErrorResult($method, $id, $e);
433+
}
424434

425-
return StatelessResult::error(Error::forInternalError(self::INTERNAL_ERROR_MESSAGE, $id), 500);
435+
if ($run->valid() && $wantsStream) {
436+
return StatelessResult::stream(fn (): \Generator => $this->streamFrames($run, $method, $id));
437+
}
438+
439+
try {
440+
// Stepped rather than foreach()ed: rewind() already advanced it,
441+
// and a generator will not be traversed a second time.
442+
while ($run->valid()) {
443+
$this->logger->debug('Dropped a notification: the client did not accept a response stream.', [
444+
'method' => $method,
445+
'notification' => $run->current()::getMethod(),
446+
]);
447+
448+
$run->next();
449+
}
450+
451+
$result = $run->getReturn();
452+
} catch (\Throwable $e) {
453+
return $this->toErrorResult($method, $id, $e);
426454
}
427455

428456
if ($result instanceof Error) {
@@ -435,6 +463,134 @@ private function dispatch(string $method, array $decoded, RequestMeta $meta, str
435463
return StatelessResult::error(Error::forMethodNotFound(\sprintf('No handler found for method "%s".', $method), $id), 404);
436464
}
437465

466+
/**
467+
* Runs a handler, yielding the notifications it emits as it emits them and
468+
* returning its result.
469+
*
470+
* The fiber is what makes a handler's `$gateway->progress(...)` look
471+
* synchronous while the caller decides where the notification goes. Server
472+
* -to-client *requests* are refused rather than forwarded: this revision
473+
* carries those in the result (MRTR), and putting one on a response stream
474+
* is something the transport binding forbids outright.
475+
*
476+
* @param RequestHandlerInterface<ResultInterface> $handler
477+
*
478+
* @return \Generator<int, Notification, null, Response<ResultInterface>|Error>
479+
*/
480+
private function run(RequestHandlerInterface $handler, Request $request, Session $session, RequestMeta $meta): \Generator
481+
{
482+
$fiber = new \Fiber(static fn (): mixed => $handler->handle($request, $session));
483+
484+
$suspended = $fiber->start();
485+
486+
while (!$fiber->isTerminated()) {
487+
$notification = $this->readNotification($suspended, $meta);
488+
489+
if (null !== $notification) {
490+
yield $notification;
491+
}
492+
493+
$suspended = $fiber->resume(null);
494+
}
495+
496+
/** @var Response<ResultInterface>|Error $return */
497+
$return = $fiber->getReturn();
498+
499+
return $return;
500+
}
501+
502+
/**
503+
* Reads one fiber suspension, or null when it carries nothing to send.
504+
*
505+
* @param mixed $suspended the payload {@see \Mcp\Server\ClientGateway} suspended with
506+
*/
507+
private function readNotification(mixed $suspended, RequestMeta $meta): ?Notification
508+
{
509+
if (!\is_array($suspended) || 'notification' !== ($suspended['type'] ?? null)) {
510+
if (\is_array($suspended) && 'request' === ($suspended['type'] ?? null)) {
511+
throw new LogicException('This protocol revision has no server-initiated requests: return an InputRequiredResult naming what you need instead, and read the answers back through RequestContext::getInputContext(). See the multi round-trip requests pattern.');
512+
}
513+
514+
return null;
515+
}
516+
517+
$notification = $suspended['notification'] ?? null;
518+
519+
if (!$notification instanceof Notification) {
520+
return null;
521+
}
522+
523+
// The client opts into logs per request; with no level named the server
524+
// MUST NOT send any, which is why an absent level drops rather than
525+
// defaults.
526+
if ($notification instanceof LoggingMessageNotification) {
527+
if (null === $meta->logLevel || !$notification->level->isAtLeast($meta->logLevel)) {
528+
return null;
529+
}
530+
}
531+
532+
return $notification;
533+
}
534+
535+
/**
536+
* The frames of a request-scoped response stream: the notifications the
537+
* handler emits, then the response that ends it.
538+
*
539+
* @param \Generator<int, Notification, null, Response<ResultInterface>|Error> $run
540+
*
541+
* @return \Generator<mixed>
542+
*/
543+
private function streamFrames(\Generator $run, string $method, string|int $id): \Generator
544+
{
545+
try {
546+
while ($run->valid()) {
547+
yield $run->current()->jsonSerialize();
548+
549+
$run->next();
550+
}
551+
552+
$result = $run->getReturn();
553+
} catch (\Throwable $e) {
554+
// Headers left long ago, so the status is already 200 and the only
555+
// way left to report this is a frame.
556+
yield $this->toErrorResult($method, $id, $e)->message?->jsonSerialize();
557+
558+
return;
559+
}
560+
561+
yield $result instanceof Error
562+
? $result->jsonSerialize()
563+
: ['jsonrpc' => '2.0', 'id' => $id, 'result' => $this->codec->encodeResult($method, (array) $result->result->jsonSerialize())];
564+
}
565+
566+
/**
567+
* The one place a handler's exception becomes an answer, so the streaming
568+
* and non-streaming paths cannot disagree about which code it earns.
569+
*/
570+
private function toErrorResult(string $method, string|int $id, \Throwable $e): StatelessResult
571+
{
572+
if ($e instanceof MissingRequiredClientCapabilityException) {
573+
return StatelessResult::error(
574+
Error::forMissingRequiredClientCapability($e->getMessage(), $e->requiredCapabilities, $id),
575+
400,
576+
);
577+
}
578+
579+
if ($e instanceof \InvalidArgumentException) {
580+
return StatelessResult::error(Error::forInvalidParams($e->getMessage(), $id), 400);
581+
}
582+
583+
if ($e instanceof LogicException) {
584+
// Guidance for the tool author, not a detail leaked from their
585+
// code or a dependency's — safe to echo back verbatim.
586+
return StatelessResult::error(Error::forInternalError($e->getMessage(), $id), 500);
587+
}
588+
589+
$this->logger->error('Uncaught exception handling a modern-era request.', ['method' => $method, 'exception' => $e]);
590+
591+
return StatelessResult::error(Error::forInternalError(self::INTERNAL_ERROR_MESSAGE, $id), 500);
592+
}
593+
438594
/**
439595
* Reads the multi round-trip material off a retry, verifying the state
440596
* before any of it reaches a handler. Neither member means a first call,
@@ -483,6 +639,26 @@ private function encode(string $method, string|int $id, ResultInterface $result)
483639
return StatelessResult::ok($id, $this->codec->encodeResult($method, (array) $result->jsonSerialize()));
484640
}
485641

642+
/**
643+
* Whether the client will read a response stream.
644+
*
645+
* Clients MUST offer both content types, so this is normally true; a client
646+
* that does not gets its notifications dropped rather than a stream it
647+
* cannot parse.
648+
*
649+
* @param array<string, string> $headers
650+
*/
651+
private static function acceptsEventStream(array $headers): bool
652+
{
653+
foreach ($headers as $key => $value) {
654+
if (0 === strcasecmp($key, 'Accept')) {
655+
return str_contains(strtolower($value), 'text/event-stream');
656+
}
657+
}
658+
659+
return false;
660+
}
661+
486662
/**
487663
* @param array<string, string> $headers
488664
*/

tests/Conformance/conformance-baseline-2026-07-28.yml

Lines changed: 0 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -13,15 +13,6 @@
1313
# make conformance-draft-server
1414

1515
server:
16-
# --- Progress notifications on a response stream ---------------------------
17-
# The one place the modern lifecycle is behind the handshake one, and a real
18-
# gap rather than a fixture difference: this scenario passes at / and fails
19-
# at /stateless. A tools/call answer is a single JSON response here, so
20-
# notifications a handler emits mid-call have nowhere to go. Closing it means
21-
# answering tools/call over a stream — the same seam MRTR's IncompleteResult
22-
# chunks will need — rather than anything specific to progress.
23-
- tools-call-with-progress:tools-call-with-progress
24-
2516
# --- Pre-existing, not lifecycle-specific ----------------------------------
2617
# Neither is caused by (or fixable in) the modern lifecycle; both reproduce
2718
# on the handshake endpoint. Listed so the draft run is honest.

0 commit comments

Comments
 (0)