[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.
This commit is contained in:
parent
5e73683079
commit
dd3783a720
6 changed files with 425 additions and 43 deletions
|
|
@ -5,6 +5,8 @@ All notable changes to `mcp/sdk` will be documented in this file.
|
|||
0.8.0
|
||||
-----
|
||||
|
||||
* 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.
|
||||
* 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()`.
|
||||
* [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()`.
|
||||
* 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()`.
|
||||
* 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.
|
||||
|
|
|
|||
|
|
@ -51,7 +51,7 @@ final class ClientLogger extends AbstractLogger
|
|||
$minimumLevel = $this->session->get(Protocol::SESSION_LOGGING_LEVEL, '');
|
||||
$minimumLevel = LoggingLevel::tryFrom($minimumLevel) ?? LoggingLevel::Warning;
|
||||
|
||||
if ($this->getSeverityIndex($minimumLevel) > $this->getSeverityIndex($mcpLevel)) {
|
||||
if (!$mcpLevel->isAtLeast($minimumLevel)) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -79,24 +79,4 @@ final class ClientLogger extends AbstractLogger
|
|||
default => null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the severity index for this log level.
|
||||
* Higher values indicate more severe log levels.
|
||||
*
|
||||
* @return int Severity index (0-7, where 7 is most severe)
|
||||
*/
|
||||
private function getSeverityIndex(LoggingLevel $level): int
|
||||
{
|
||||
return match ($level) {
|
||||
LoggingLevel::Debug => 0,
|
||||
LoggingLevel::Info => 1,
|
||||
LoggingLevel::Notice => 2,
|
||||
LoggingLevel::Warning => 3,
|
||||
LoggingLevel::Error => 4,
|
||||
LoggingLevel::Critical => 5,
|
||||
LoggingLevel::Alert => 6,
|
||||
LoggingLevel::Emergency => 7,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -32,4 +32,31 @@ enum LoggingLevel: string
|
|||
case Critical = 'critical';
|
||||
case Alert = 'alert';
|
||||
case Emergency = 'emergency';
|
||||
|
||||
/**
|
||||
* RFC 5424 ordering, inverted so a larger number is more severe — which is
|
||||
* the direction a minimum-level comparison reads in.
|
||||
*/
|
||||
public function severity(): int
|
||||
{
|
||||
return match ($this) {
|
||||
self::Debug => 0,
|
||||
self::Info => 1,
|
||||
self::Notice => 2,
|
||||
self::Warning => 3,
|
||||
self::Error => 4,
|
||||
self::Critical => 5,
|
||||
self::Alert => 6,
|
||||
self::Emergency => 7,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a message at this level should be emitted when $minimum was
|
||||
* requested.
|
||||
*/
|
||||
public function isAtLeast(self $minimum): bool
|
||||
{
|
||||
return $this->severity() >= $minimum->severity();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,17 +12,22 @@
|
|||
namespace Mcp\Server\Stateless;
|
||||
|
||||
use Mcp\Exception\InvalidInputMessageException;
|
||||
use Mcp\Exception\LogicException;
|
||||
use Mcp\Exception\MissingRequestMetaException;
|
||||
use Mcp\Exception\MissingRequiredClientCapabilityException;
|
||||
use Mcp\Exception\RequestStateException;
|
||||
use Mcp\JsonRpc\MessageFactory;
|
||||
use Mcp\Schema\Enum\ProtocolVersion;
|
||||
use Mcp\Schema\JsonRpc\Error;
|
||||
use Mcp\Schema\JsonRpc\Notification;
|
||||
use Mcp\Schema\JsonRpc\Request;
|
||||
use Mcp\Schema\JsonRpc\Response;
|
||||
use Mcp\Schema\JsonRpc\ResultInterface;
|
||||
use Mcp\Schema\Notification\LoggingMessageNotification;
|
||||
use Mcp\Schema\Result\DiscoverResult;
|
||||
use Mcp\Server\Configuration;
|
||||
use Mcp\Server\Handler\Request\RequestHandlerInterface;
|
||||
use Mcp\Server\Protocol;
|
||||
use Mcp\Server\Session\InMemorySessionStore;
|
||||
use Mcp\Server\Session\Session;
|
||||
use Mcp\Server\Wire\Rev2026Codec;
|
||||
|
|
@ -33,7 +38,7 @@ use Psr\Log\NullLogger;
|
|||
/**
|
||||
* Dispatches a single modern-era (SEP-2575) request.
|
||||
*
|
||||
* Separate from {@see \Mcp\Server\Protocol} because the modern era has no
|
||||
* Separate from {@see Protocol} because the modern era has no
|
||||
* session to resolve, replay or keep a fiber against; the two eras share
|
||||
* request handlers, not control flow.
|
||||
*
|
||||
|
|
@ -203,7 +208,7 @@ final class StatelessProtocol
|
|||
);
|
||||
}
|
||||
|
||||
return $this->dispatch($method, $decoded, $meta, $id);
|
||||
return $this->dispatch($method, $decoded, $meta, $id, self::acceptsEventStream($headers));
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -338,7 +343,7 @@ final class StatelessProtocol
|
|||
/**
|
||||
* @param array<string, mixed> $decoded
|
||||
*/
|
||||
private function dispatch(string $method, array $decoded, RequestMeta $meta, string|int|null $id): StatelessResult
|
||||
private function dispatch(string $method, array $decoded, RequestMeta $meta, string|int|null $id, bool $wantsStream = false): StatelessResult
|
||||
{
|
||||
try {
|
||||
$messages = $this->messageFactory->create(json_encode($decoded, \JSON_THROW_ON_ERROR));
|
||||
|
|
@ -405,24 +410,47 @@ final class StatelessProtocol
|
|||
$session->set(RequestStateCodec::class, $this->requestStateCodec);
|
||||
}
|
||||
|
||||
// What ClientGateway::progress() reads to find the progress token, and
|
||||
// the handshake era sets under the same key.
|
||||
$session->set(Protocol::SESSION_ACTIVE_REQUEST_META, $request->getMeta());
|
||||
|
||||
foreach ($this->requestHandlers as $handler) {
|
||||
if (!$handler->supports($request)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
$result = $handler->handle($request, $session);
|
||||
} catch (MissingRequiredClientCapabilityException $e) {
|
||||
return StatelessResult::error(
|
||||
Error::forMissingRequiredClientCapability($e->getMessage(), $e->requiredCapabilities, $id),
|
||||
400,
|
||||
);
|
||||
} catch (\InvalidArgumentException $e) {
|
||||
return StatelessResult::error(Error::forInvalidParams($e->getMessage(), $id), 400);
|
||||
} catch (\Throwable $e) {
|
||||
$this->logger->error('Uncaught exception handling a modern-era request.', ['method' => $method, 'exception' => $e]);
|
||||
$run = $this->run($handler, $request, $session, $meta);
|
||||
|
||||
return StatelessResult::error(Error::forInternalError(self::INTERNAL_ERROR_MESSAGE, $id), 500);
|
||||
try {
|
||||
// Runs the handler up to its first notification, or to the end
|
||||
// if it emits none. Deciding here and not earlier is what keeps
|
||||
// the status codes honest: a request that turns out to need
|
||||
// -32021 has said nothing yet, so it can still be answered
|
||||
// with 400 rather than an error frame under a 200.
|
||||
$run->rewind();
|
||||
} catch (\Throwable $e) {
|
||||
return $this->toErrorResult($method, $id, $e);
|
||||
}
|
||||
|
||||
if ($run->valid() && $wantsStream) {
|
||||
return StatelessResult::stream(fn (): \Generator => $this->streamFrames($run, $method, $id));
|
||||
}
|
||||
|
||||
try {
|
||||
// Stepped rather than foreach()ed: rewind() already advanced it,
|
||||
// and a generator will not be traversed a second time.
|
||||
while ($run->valid()) {
|
||||
$this->logger->debug('Dropped a notification: the client did not accept a response stream.', [
|
||||
'method' => $method,
|
||||
'notification' => $run->current()::getMethod(),
|
||||
]);
|
||||
|
||||
$run->next();
|
||||
}
|
||||
|
||||
$result = $run->getReturn();
|
||||
} catch (\Throwable $e) {
|
||||
return $this->toErrorResult($method, $id, $e);
|
||||
}
|
||||
|
||||
if ($result instanceof Error) {
|
||||
|
|
@ -435,6 +463,134 @@ final class StatelessProtocol
|
|||
return StatelessResult::error(Error::forMethodNotFound(\sprintf('No handler found for method "%s".', $method), $id), 404);
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs a handler, yielding the notifications it emits as it emits them and
|
||||
* returning its result.
|
||||
*
|
||||
* The fiber is what makes a handler's `$gateway->progress(...)` look
|
||||
* synchronous while the caller decides where the notification goes. Server
|
||||
* -to-client *requests* are refused rather than forwarded: this revision
|
||||
* carries those in the result (MRTR), and putting one on a response stream
|
||||
* is something the transport binding forbids outright.
|
||||
*
|
||||
* @param RequestHandlerInterface<ResultInterface> $handler
|
||||
*
|
||||
* @return \Generator<int, Notification, null, Response<ResultInterface>|Error>
|
||||
*/
|
||||
private function run(RequestHandlerInterface $handler, Request $request, Session $session, RequestMeta $meta): \Generator
|
||||
{
|
||||
$fiber = new \Fiber(static fn (): mixed => $handler->handle($request, $session));
|
||||
|
||||
$suspended = $fiber->start();
|
||||
|
||||
while (!$fiber->isTerminated()) {
|
||||
$notification = $this->readNotification($suspended, $meta);
|
||||
|
||||
if (null !== $notification) {
|
||||
yield $notification;
|
||||
}
|
||||
|
||||
$suspended = $fiber->resume(null);
|
||||
}
|
||||
|
||||
/** @var Response<ResultInterface>|Error $return */
|
||||
$return = $fiber->getReturn();
|
||||
|
||||
return $return;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads one fiber suspension, or null when it carries nothing to send.
|
||||
*
|
||||
* @param mixed $suspended the payload {@see \Mcp\Server\ClientGateway} suspended with
|
||||
*/
|
||||
private function readNotification(mixed $suspended, RequestMeta $meta): ?Notification
|
||||
{
|
||||
if (!\is_array($suspended) || 'notification' !== ($suspended['type'] ?? null)) {
|
||||
if (\is_array($suspended) && 'request' === ($suspended['type'] ?? null)) {
|
||||
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.');
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
$notification = $suspended['notification'] ?? null;
|
||||
|
||||
if (!$notification instanceof Notification) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// The client opts into logs per request; with no level named the server
|
||||
// MUST NOT send any, which is why an absent level drops rather than
|
||||
// defaults.
|
||||
if ($notification instanceof LoggingMessageNotification) {
|
||||
if (null === $meta->logLevel || !$notification->level->isAtLeast($meta->logLevel)) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return $notification;
|
||||
}
|
||||
|
||||
/**
|
||||
* The frames of a request-scoped response stream: the notifications the
|
||||
* handler emits, then the response that ends it.
|
||||
*
|
||||
* @param \Generator<int, Notification, null, Response<ResultInterface>|Error> $run
|
||||
*
|
||||
* @return \Generator<mixed>
|
||||
*/
|
||||
private function streamFrames(\Generator $run, string $method, string|int $id): \Generator
|
||||
{
|
||||
try {
|
||||
while ($run->valid()) {
|
||||
yield $run->current()->jsonSerialize();
|
||||
|
||||
$run->next();
|
||||
}
|
||||
|
||||
$result = $run->getReturn();
|
||||
} 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();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
yield $result instanceof Error
|
||||
? $result->jsonSerialize()
|
||||
: ['jsonrpc' => '2.0', 'id' => $id, 'result' => $this->codec->encodeResult($method, (array) $result->result->jsonSerialize())];
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
{
|
||||
if ($e instanceof MissingRequiredClientCapabilityException) {
|
||||
return StatelessResult::error(
|
||||
Error::forMissingRequiredClientCapability($e->getMessage(), $e->requiredCapabilities, $id),
|
||||
400,
|
||||
);
|
||||
}
|
||||
|
||||
if ($e instanceof \InvalidArgumentException) {
|
||||
return StatelessResult::error(Error::forInvalidParams($e->getMessage(), $id), 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);
|
||||
}
|
||||
|
||||
$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);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads the multi round-trip material off a retry, verifying the state
|
||||
* before any of it reaches a handler. Neither member means a first call,
|
||||
|
|
@ -483,6 +639,26 @@ final class StatelessProtocol
|
|||
return StatelessResult::ok($id, $this->codec->encodeResult($method, (array) $result->jsonSerialize()));
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the client will read a response stream.
|
||||
*
|
||||
* Clients MUST offer both content types, so this is normally true; a client
|
||||
* that does not gets its notifications dropped rather than a stream it
|
||||
* cannot parse.
|
||||
*
|
||||
* @param array<string, string> $headers
|
||||
*/
|
||||
private static function acceptsEventStream(array $headers): bool
|
||||
{
|
||||
foreach ($headers as $key => $value) {
|
||||
if (0 === strcasecmp($key, 'Accept')) {
|
||||
return str_contains(strtolower($value), 'text/event-stream');
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, string> $headers
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -13,15 +13,6 @@
|
|||
# make conformance-draft-server
|
||||
|
||||
server:
|
||||
# --- Progress notifications on a response stream ---------------------------
|
||||
# The one place the modern lifecycle is behind the handshake one, and a real
|
||||
# gap rather than a fixture difference: this scenario passes at / and fails
|
||||
# at /stateless. A tools/call answer is a single JSON response here, so
|
||||
# notifications a handler emits mid-call have nowhere to go. Closing it means
|
||||
# answering tools/call over a stream — the same seam MRTR's IncompleteResult
|
||||
# chunks will need — rather than anything specific to progress.
|
||||
- tools-call-with-progress:tools-call-with-progress
|
||||
|
||||
# --- Pre-existing, not lifecycle-specific ----------------------------------
|
||||
# Neither is caused by (or fixable in) the modern lifecycle; both reproduce
|
||||
# on the handshake endpoint. Listed so the draft run is honest.
|
||||
|
|
|
|||
|
|
@ -11,12 +11,18 @@
|
|||
|
||||
namespace Mcp\Tests\Unit\Server\Stateless;
|
||||
|
||||
use Mcp\Exception\MissingRequiredClientCapabilityException;
|
||||
use Mcp\Schema\ClientCapabilities;
|
||||
use Mcp\Schema\Elicitation\ElicitationSchema;
|
||||
use Mcp\Schema\Elicitation\StringSchemaDefinition;
|
||||
use Mcp\Schema\Enum\LoggingLevel;
|
||||
use Mcp\Schema\Enum\ProtocolVersion;
|
||||
use Mcp\Schema\JsonRpc\Error;
|
||||
use Mcp\Server;
|
||||
use Mcp\Server\RequestContext;
|
||||
use Mcp\Server\Stateless\RequestMeta;
|
||||
use Mcp\Server\Stateless\StatelessProtocol;
|
||||
use Mcp\Server\Stateless\StatelessResult;
|
||||
use PHPUnit\Framework\Attributes\DataProvider;
|
||||
use PHPUnit\Framework\Attributes\TestDox;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
|
@ -46,6 +52,47 @@ class StatelessProtocolTest extends TestCase
|
|||
name: 'probe_capabilities',
|
||||
description: 'Reports which client capabilities the gateway can see',
|
||||
)
|
||||
->addTool(
|
||||
static function (RequestContext $context): string {
|
||||
$gateway = $context->getClientGateway();
|
||||
$gateway->progress(0, 100, 'starting');
|
||||
$gateway->progress(100, 100, 'done');
|
||||
|
||||
return 'progressed';
|
||||
},
|
||||
name: 'progress_tool',
|
||||
description: 'Reports progress while it works',
|
||||
)
|
||||
->addTool(
|
||||
static function (RequestContext $context): string {
|
||||
$gateway = $context->getClientGateway();
|
||||
foreach ([LoggingLevel::Debug, LoggingLevel::Info, LoggingLevel::Warning, LoggingLevel::Error] as $level) {
|
||||
$gateway->log($level, $level->value.' message');
|
||||
}
|
||||
|
||||
return 'logged';
|
||||
},
|
||||
name: 'logging_tool',
|
||||
description: 'Emits one message at each of four levels',
|
||||
)
|
||||
->addTool(
|
||||
static function (): never {
|
||||
throw new MissingRequiredClientCapabilityException(new ClientCapabilities(roots: false, sampling: true), 'needs sampling');
|
||||
},
|
||||
name: 'capability_tool',
|
||||
description: 'Always reports a missing client capability',
|
||||
)
|
||||
->addTool(
|
||||
static function (RequestContext $context): string {
|
||||
// The pattern this revision replaced: kept as a fixture so
|
||||
// the refusal has something to refuse.
|
||||
$context->getClientGateway()->elicit('name?', new ElicitationSchema(['n' => new StringSchemaDefinition('N')], ['n']));
|
||||
|
||||
return 'unreachable';
|
||||
},
|
||||
name: 'elicits_directly',
|
||||
description: 'Asks the client directly, which this revision forbids',
|
||||
)
|
||||
->addResource(static fn (): string => 'body', 'test://static', 'static', 'A static resource')
|
||||
->buildStateless([ProtocolVersion::V2026_07_28]);
|
||||
}
|
||||
|
|
@ -158,6 +205,165 @@ class StatelessProtocolTest extends TestCase
|
|||
$this->assertSame(Error::METHOD_NOT_FOUND, $answer['body']['error']['code']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Drains a streaming result into the frames it would write.
|
||||
*
|
||||
* @return list<array<string, mixed>>
|
||||
*/
|
||||
private static function frames(StatelessResult $result): array
|
||||
{
|
||||
$frames = [];
|
||||
|
||||
foreach (($result->frames)() as $frame) {
|
||||
if (null !== $frame) {
|
||||
$frames[] = $frame;
|
||||
}
|
||||
}
|
||||
|
||||
return $frames;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $meta
|
||||
*/
|
||||
private static function callStreaming(StatelessProtocol $protocol, string $tool, array $meta = []): StatelessResult
|
||||
{
|
||||
return $protocol->handle(json_encode([
|
||||
'jsonrpc' => '2.0',
|
||||
'id' => 7,
|
||||
'method' => 'tools/call',
|
||||
'params' => [
|
||||
'name' => $tool,
|
||||
'arguments' => [],
|
||||
'_meta' => [
|
||||
RequestMeta::PROTOCOL_VERSION => ProtocolVersion::V2026_07_28->value,
|
||||
RequestMeta::CLIENT_CAPABILITIES => new \stdClass(),
|
||||
...$meta,
|
||||
],
|
||||
],
|
||||
], \JSON_THROW_ON_ERROR), [
|
||||
'MCP-Protocol-Version' => ProtocolVersion::V2026_07_28->value,
|
||||
'Mcp-Method' => 'tools/call',
|
||||
'Mcp-Name' => $tool,
|
||||
'Accept' => 'application/json, text/event-stream',
|
||||
]);
|
||||
}
|
||||
|
||||
#[TestDox('progress notifications reach the client on the response stream')]
|
||||
public function testProgressStreamsBeforeTheResponse(): void
|
||||
{
|
||||
$result = self::callStreaming(self::protocol(), 'progress_tool', ['progressToken' => 'tok-1']);
|
||||
|
||||
$this->assertTrue($result->isStream());
|
||||
|
||||
$frames = self::frames($result);
|
||||
|
||||
$this->assertCount(3, $frames);
|
||||
$this->assertSame('notifications/progress', $frames[0]['method']);
|
||||
$this->assertSame('tok-1', $frames[0]['params']['progressToken']);
|
||||
$this->assertSame('notifications/progress', $frames[1]['method']);
|
||||
$this->assertSame(7, $frames[2]['id']);
|
||||
$this->assertSame('complete', $frames[2]['result']['resultType']);
|
||||
}
|
||||
|
||||
#[TestDox('without a progress token the handler emits nothing and gets a plain response')]
|
||||
public function testProgressWithoutATokenIsNotStreamed(): void
|
||||
{
|
||||
$result = self::callStreaming(self::protocol(), 'progress_tool');
|
||||
|
||||
$this->assertFalse($result->isStream());
|
||||
$this->assertSame(200, $result->httpStatus);
|
||||
}
|
||||
|
||||
#[TestDox('a client that will not read a stream gets its notifications dropped, not a stream')]
|
||||
public function testNotificationsAreDroppedWithoutAnAcceptingClient(): void
|
||||
{
|
||||
$result = self::protocol()->handle(json_encode([
|
||||
'jsonrpc' => '2.0',
|
||||
'id' => 7,
|
||||
'method' => 'tools/call',
|
||||
'params' => [
|
||||
'name' => 'progress_tool',
|
||||
'arguments' => [],
|
||||
'_meta' => [
|
||||
RequestMeta::PROTOCOL_VERSION => ProtocolVersion::V2026_07_28->value,
|
||||
RequestMeta::CLIENT_CAPABILITIES => new \stdClass(),
|
||||
'progressToken' => 'tok-1',
|
||||
],
|
||||
],
|
||||
], \JSON_THROW_ON_ERROR), [
|
||||
'MCP-Protocol-Version' => ProtocolVersion::V2026_07_28->value,
|
||||
'Mcp-Method' => 'tools/call',
|
||||
'Mcp-Name' => 'progress_tool',
|
||||
'Accept' => 'application/json',
|
||||
]);
|
||||
|
||||
$this->assertFalse($result->isStream());
|
||||
$this->assertSame(200, $result->httpStatus);
|
||||
}
|
||||
|
||||
#[TestDox('a request naming no log level receives no log notifications')]
|
||||
public function testLoggingIsSilentWithoutARequestedLevel(): void
|
||||
{
|
||||
$result = self::callStreaming(self::protocol(), 'logging_tool');
|
||||
|
||||
$this->assertFalse($result->isStream());
|
||||
}
|
||||
|
||||
#[TestDox('a request naming a log level receives the messages at or above it')]
|
||||
public function testLoggingHonoursTheRequestedLevel(): void
|
||||
{
|
||||
$frames = self::frames(self::callStreaming(self::protocol(), 'logging_tool', [RequestMeta::LOG_LEVEL => 'warning']));
|
||||
|
||||
$levels = [];
|
||||
foreach ($frames as $frame) {
|
||||
if ('notifications/message' === ($frame['method'] ?? null)) {
|
||||
$levels[] = $frame['params']['level'];
|
||||
}
|
||||
}
|
||||
|
||||
// The tool emits debug, info, warning and error.
|
||||
$this->assertSame(['warning', 'error'], $levels);
|
||||
}
|
||||
|
||||
#[TestDox('a lower requested level lets more through')]
|
||||
public function testLoggingAtDebugLetsEverythingThrough(): void
|
||||
{
|
||||
$frames = self::frames(self::callStreaming(self::protocol(), 'logging_tool', [RequestMeta::LOG_LEVEL => 'debug']));
|
||||
|
||||
$levels = [];
|
||||
foreach ($frames as $frame) {
|
||||
if ('notifications/message' === ($frame['method'] ?? null)) {
|
||||
$levels[] = $frame['params']['level'];
|
||||
}
|
||||
}
|
||||
|
||||
$this->assertSame(['debug', 'info', 'warning', 'error'], $levels);
|
||||
}
|
||||
|
||||
#[TestDox('an error raised before any notification keeps its own status')]
|
||||
public function testEarlyFailureIsStillAStatusCode(): void
|
||||
{
|
||||
$result = self::callStreaming(self::protocol(), 'capability_tool');
|
||||
|
||||
$this->assertFalse($result->isStream());
|
||||
$this->assertSame(400, $result->httpStatus);
|
||||
|
||||
$body = json_decode($result->toJson(), true, flags: \JSON_THROW_ON_ERROR);
|
||||
$this->assertSame(Error::MISSING_REQUIRED_CLIENT_CAPABILITY, $body['error']['code']);
|
||||
}
|
||||
|
||||
#[TestDox('a server-initiated request is refused with the pattern that replaced it')]
|
||||
public function testServerInitiatedRequestIsRefused(): void
|
||||
{
|
||||
$result = self::callStreaming(self::protocol(), 'elicits_directly');
|
||||
|
||||
$body = json_decode($result->toJson(), true, flags: \JSON_THROW_ON_ERROR);
|
||||
|
||||
$this->assertSame(500, $result->httpStatus);
|
||||
$this->assertStringContainsString('InputRequiredResult', $body['error']['message']);
|
||||
}
|
||||
|
||||
#[TestDox('a missing resource answers -32602 with the uri, not the retired -32002')]
|
||||
public function testResourceNotFoundUsesInvalidParams(): void
|
||||
{
|
||||
|
|
|
|||
Loading…
Reference in a new issue