diff --git a/CHANGELOG.md b/CHANGELOG.md index 4a7349a12..33dcb40a6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -51,6 +51,8 @@ All notable changes to `mcp/sdk` will be documented in this file. * Fix the client's `HttpTransport` waiting out the request timeout when a non-streamed response to a request ends without its answer (truncated, empty, unreadable or not JSON): the call now fails at once with a `RequestException`, also when the body of an error status fails to read. * Fix an unreachable `Psr16SessionStore` cache being answered as "session not found" (`404`): the failure is logged and answered with `503`, so clients retry instead of re-initializing. `SessionStoreInterface::exists()` may throw the new `SessionStoreException`. * Serve both protocol eras over stdio: `StdioTransport` settles the era on the client's first request and serves `2026-07-28` requests, `subscriptions/listen` and `notifications/cancelled` on the one channel. +* Fix `ClientGateway::log()` ignoring the client's log level: like `ClientLogger`, it drops messages below the level set with `logging/setLevel`, and below `warning` while the client has set none. +* [BC Break] `ClientLogger` takes only the `ClientGateway`, which now applies the client-set level itself. * [BC Break] `StatelessAwareTransportInterface` declares `setHandshakeVersions()`, so a server without the modern era names only the revisions it negotiates when refusing a `2026-07-28` request, e.g. the one set with `Builder::setProtocolVersion()`. * Answer a bare `initialize` on a `2026-07-28`-only endpoint with `-32022` naming the served revisions, and a request without a session on the handshake leg with its id. * Speak both protocol eras from a client configured with `2026-07-28`: `connect()` probes with `server/discover` and falls back to the `initialize` handshake on `2025-11-25` when the server does not speak the modern era. `Builder::setFallbackProtocolVersion()` picks the fallback revision, or `null` for a modern-only client. diff --git a/docs/handlers/client-communication.md b/docs/handlers/client-communication.md index 7b9cc5cec..678d55701 100644 --- a/docs/handlers/client-communication.md +++ b/docs/handlers/client-communication.md @@ -128,6 +128,9 @@ use Mcp\Schema\Enum\LoggingLevel; $clientGateway->log(LoggingLevel::Warning, 'The end is near.'); ``` +Messages below the client's log level are dropped, the same way as with the +[client logger](logging.md). Until the client sets a level with `logging/setLevel`, the level is `warning`. + ## Progress With a [Progress](https://modelcontextprotocol.io/specification/2025-06-18/basic/utilities/progress#progress) diff --git a/src/Capability/Logger/ClientLogger.php b/src/Capability/Logger/ClientLogger.php index f94deb67e..2cd6b9894 100644 --- a/src/Capability/Logger/ClientLogger.php +++ b/src/Capability/Logger/ClientLogger.php @@ -13,8 +13,6 @@ use Mcp\Schema\Enum\LoggingLevel; use Mcp\Server\ClientGateway; -use Mcp\Server\Protocol; -use Mcp\Server\Session\SessionInterface; use Psr\Log\AbstractLogger; /** @@ -30,7 +28,6 @@ final class ClientLogger extends AbstractLogger { public function __construct( private ClientGateway $client, - private SessionInterface $session, ) { } @@ -48,13 +45,6 @@ public function log($level, $message, array $context = []): void return; // Unknown level, skip MCP notification } - $minimumLevel = $this->session->get(Protocol::SESSION_LOGGING_LEVEL, ''); - $minimumLevel = LoggingLevel::tryFrom($minimumLevel) ?? LoggingLevel::Warning; - - if (!$mcpLevel->isAtLeast($minimumLevel)) { - return; - } - $this->client->log($mcpLevel, $message); } diff --git a/src/Server/ClientGateway.php b/src/Server/ClientGateway.php index da2a38b7f..4d8a144c2 100644 --- a/src/Server/ClientGateway.php +++ b/src/Server/ClientGateway.php @@ -99,6 +99,9 @@ public function notify(Notification $notification): void /** * Convenience method to send a logging notification to the client. * + * Messages below the level the client set are dropped; without one, the + * level defaults to warning. + * * @deprecated since protocol revision 2026-07-28 (SEP-2577), earliest removal 2027-07-28. * Log to stderr (stdio) or use OpenTelemetry instead. */ @@ -106,6 +109,11 @@ public function log(LoggingLevel $level, mixed $data, ?string $logger = null): v { trigger_deprecation('mcp/sdk', '0.8', 'MCP logging is deprecated since protocol revision 2026-07-28 (SEP-2577); log to stderr or use OpenTelemetry instead.'); + $minimumLevel = LoggingLevel::tryFrom($this->session->get(Protocol::SESSION_LOGGING_LEVEL, '')) ?? LoggingLevel::Warning; + if (!$level->isAtLeast($minimumLevel)) { + return; + } + $this->notify(new LoggingMessageNotification($level, $data, $logger)); } diff --git a/src/Server/RequestContext.php b/src/Server/RequestContext.php index 0f1bcaa48..9b9337f3d 100644 --- a/src/Server/RequestContext.php +++ b/src/Server/RequestContext.php @@ -162,7 +162,7 @@ public function mintRequestState(array $payload): string public function getClientLogger(): ClientLogger { if (null === $this->clientLogger) { - $this->clientLogger = new ClientLogger($this->getClientGateway(), $this->session); + $this->clientLogger = new ClientLogger($this->getClientGateway()); } return $this->clientLogger; diff --git a/src/Server/Stateless/StatelessProtocol.php b/src/Server/Stateless/StatelessProtocol.php index 24d83ef10..a8c49683b 100644 --- a/src/Server/Stateless/StatelessProtocol.php +++ b/src/Server/Stateless/StatelessProtocol.php @@ -496,7 +496,7 @@ private function dispatch(string $method, array $decoded, RequestMeta $meta, str $session->set('protocol_version', $meta->protocolVersion); // The requested log level is what the handshake era's logging/setLevel - // would have stored, so ClientLogger applies it instead of its default. + // would have stored, so ClientGateway::log() applies it instead of its default. if (null !== $meta->logLevel) { $session->set(Protocol::SESSION_LOGGING_LEVEL, $meta->logLevel->value); } diff --git a/tests/Unit/Capability/Logger/ClientLoggerTest.php b/tests/Unit/Capability/Logger/ClientLoggerTest.php index 6a443b718..13010ce8e 100644 --- a/tests/Unit/Capability/Logger/ClientLoggerTest.php +++ b/tests/Unit/Capability/Logger/ClientLoggerTest.php @@ -14,7 +14,6 @@ use Mcp\Capability\Logger\ClientLogger; use Mcp\Schema\Enum\LoggingLevel; use Mcp\Server\ClientGateway; -use Mcp\Server\Session\Session; use PHPUnit\Framework\TestCase; /** @@ -24,69 +23,25 @@ final class ClientLoggerTest extends TestCase { public function testLog(): void { - $session = $this->getMockBuilder(Session::class) - ->disableOriginalConstructor() - ->onlyMethods(['get']) - ->getMock(); - $session->expects($this->once())->method('get')->willReturn('info'); $clientGateway = $this->getMockBuilder(ClientGateway::class) ->disableOriginalConstructor() ->onlyMethods(['log']) ->getMock(); $clientGateway->expects($this->once())->method('log')->with(LoggingLevel::Notice, 'test'); - $logger = new ClientLogger($clientGateway, $session); + $logger = new ClientLogger($clientGateway); $logger->notice('test'); } - public function testLogFilter(): void - { - $session = $this->getMockBuilder(Session::class) - ->disableOriginalConstructor() - ->onlyMethods(['get']) - ->getMock(); - $session->expects($this->once())->method('get')->willReturn('info'); - $clientGateway = $this->getMockBuilder(ClientGateway::class) - ->disableOriginalConstructor() - ->onlyMethods(['log']) - ->getMock(); - $clientGateway->expects($this->never())->method('log'); - - $logger = new ClientLogger($clientGateway, $session); - $logger->debug('test'); - } - - public function testLogFilterSameLevel(): void - { - $session = $this->getMockBuilder(Session::class) - ->disableOriginalConstructor() - ->onlyMethods(['get']) - ->getMock(); - $session->expects($this->once())->method('get')->willReturn('info'); - $clientGateway = $this->getMockBuilder(ClientGateway::class) - ->disableOriginalConstructor() - ->onlyMethods(['log']) - ->getMock(); - $clientGateway->expects($this->once())->method('log'); - - $logger = new ClientLogger($clientGateway, $session); - $logger->info('test'); - } - public function testLogWithInvalidLevel(): void { - $session = $this->getMockBuilder(Session::class) - ->disableOriginalConstructor() - ->onlyMethods(['get']) - ->getMock(); - $session->expects($this->any())->method('get')->willReturn('info'); $clientGateway = $this->getMockBuilder(ClientGateway::class) ->disableOriginalConstructor() ->onlyMethods(['log']) ->getMock(); $clientGateway->expects($this->never())->method('log'); - $logger = new ClientLogger($clientGateway, $session); + $logger = new ClientLogger($clientGateway); $logger->log('foo', 'test'); } } diff --git a/tests/Unit/Server/ClientGatewayTest.php b/tests/Unit/Server/ClientGatewayTest.php index 21073116a..ed9a5e8b6 100644 --- a/tests/Unit/Server/ClientGatewayTest.php +++ b/tests/Unit/Server/ClientGatewayTest.php @@ -18,16 +18,20 @@ use Mcp\Schema\Elicitation\StringSchemaDefinition; use Mcp\Schema\Enum\ElicitAction; use Mcp\Schema\Enum\ElicitationMode; +use Mcp\Schema\Enum\LoggingLevel; use Mcp\Schema\Extension\Apps\McpApps; use Mcp\Schema\JsonRpc\Error; use Mcp\Schema\JsonRpc\Request; use Mcp\Schema\JsonRpc\Response; +use Mcp\Schema\Notification\LoggingMessageNotification; use Mcp\Schema\Request\ElicitRequest; use Mcp\Schema\Request\ListRootsRequest; use Mcp\Schema\Result\ElicitResult; use Mcp\Schema\Result\ListRootsResult; use Mcp\Server\ClientGateway; +use Mcp\Server\Protocol; use Mcp\Server\Session\SessionInterface; +use Mcp\Server\Suspension\NotificationSuspension; use Mcp\Server\Suspension\RequestSuspension; use PHPUnit\Framework\TestCase; use Symfony\Component\Uid\Uuid; @@ -189,6 +193,51 @@ public function testListRootsThrowsClientExceptionOnError(): void $this->runInFiber(static fn (): ListRootsResult => $gateway->listRoots(), $error); } + public function testLogDropsMessagesBelowTheClientSetLevel(): void + { + $this->assertNull($this->logInFiber('error', LoggingLevel::Warning)); + } + + public function testLogSendsMessagesAtOrAboveTheClientSetLevel(): void + { + $notification = $this->logInFiber('error', LoggingLevel::Error); + + $this->assertInstanceOf(LoggingMessageNotification::class, $notification); + $this->assertSame(LoggingLevel::Error, $notification->level); + $this->assertSame('message', $notification->data); + } + + public function testLogDefaultsToWarningWhenTheClientSetNoLevel(): void + { + $this->assertNull($this->logInFiber(null, LoggingLevel::Info)); + $this->assertInstanceOf(LoggingMessageNotification::class, $this->logInFiber(null, LoggingLevel::Warning)); + } + + /** + * Logs inside a Fiber and returns the notification the gateway suspended with, if any. + */ + private function logInFiber(?string $minimumLevel, LoggingLevel $level): ?LoggingMessageNotification + { + $session = $this->createMock(SessionInterface::class); + $session->method('getId')->willReturn(Uuid::v4()); + $session->method('get')->willReturnCallback( + static fn (string $key, mixed $default = null): mixed => Protocol::SESSION_LOGGING_LEVEL === $key ? ($minimumLevel ?? $default) : $default, + ); + + $gateway = new ClientGateway($session); + $fiber = new \Fiber(static fn () => $gateway->log($level, 'message')); + $suspend = @$fiber->start(); + + if (null === $suspend) { + return null; + } + + $this->assertInstanceOf(NotificationSuspension::class, $suspend); + $this->assertInstanceOf(LoggingMessageNotification::class, $suspend->notification); + + return $suspend->notification; + } + /** * @param array $capabilities the client capabilities the session reports */