From c48f9a02492060f35d1c6b94a22e84f1c2a5d4de Mon Sep 17 00:00:00 2001 From: Adam Jamiu Date: Fri, 10 Oct 2025 05:36:22 +0100 Subject: [PATCH 1/5] Implemented MCP logging specification with auto-injection --- composer.json | 1 + docs/mcp-elements.md | 50 +++++ docs/server-builder.md | 1 + .../LoggingShowcaseHandlers.php | 80 +++++++ examples/stdio-logging-showcase/server.php | 34 +++ src/Capability/Logger/ClientLogger.php | 91 ++++++++ src/Capability/Registry.php | 45 ++++ src/Schema/Enum/LoggingLevel.php | 21 ++ src/Server.php | 8 + src/Server/Builder.php | 29 ++- .../LoggingMessageNotificationHandler.php | 109 ++++++++++ src/Server/Handler/NotificationHandler.php | 154 +++++++++++++ .../Handler/NotificationHandlerInterface.php | 35 +++ .../Handler/Request/SetLogLevelHandler.php | 54 +++++ src/Server/NotificationSender.php | 100 +++++++++ .../Discovery/DocBlockTestFixture.php | 4 + .../Discovery/SchemaGeneratorFixture.php | 11 + .../Discovery/SchemaGeneratorTest.php | 16 ++ .../Capability/Logger/ClientLoggerTest.php | 100 +++++++++ tests/Unit/Capability/RegistryLoggingTest.php | 148 +++++++++++++ tests/Unit/Schema/Enum/LoggingLevelTest.php | 101 +++++++++ tests/Unit/Server/BuilderLoggingTest.php | 92 ++++++++ .../LoggingMessageNotificationHandlerTest.php | 203 ++++++++++++++++++ .../Request/SetLogLevelHandlerTest.php | 115 ++++++++++ tests/Unit/Server/NotificationSenderTest.php | 187 ++++++++++++++++ tests/Unit/ServerTest.php | 21 +- 26 files changed, 1804 insertions(+), 6 deletions(-) create mode 100644 examples/stdio-logging-showcase/LoggingShowcaseHandlers.php create mode 100644 examples/stdio-logging-showcase/server.php create mode 100644 src/Capability/Logger/ClientLogger.php create mode 100644 src/Server/Handler/Notification/LoggingMessageNotificationHandler.php create mode 100644 src/Server/Handler/NotificationHandler.php create mode 100644 src/Server/Handler/NotificationHandlerInterface.php create mode 100644 src/Server/Handler/Request/SetLogLevelHandler.php create mode 100644 src/Server/NotificationSender.php create mode 100644 tests/Unit/Capability/Logger/ClientLoggerTest.php create mode 100644 tests/Unit/Capability/RegistryLoggingTest.php create mode 100644 tests/Unit/Schema/Enum/LoggingLevelTest.php create mode 100644 tests/Unit/Server/BuilderLoggingTest.php create mode 100644 tests/Unit/Server/Handler/Notification/LoggingMessageNotificationHandlerTest.php create mode 100644 tests/Unit/Server/Handler/Request/SetLogLevelHandlerTest.php create mode 100644 tests/Unit/Server/NotificationSenderTest.php diff --git a/composer.json b/composer.json index 7f25eee7..4bd2605a 100644 --- a/composer.json +++ b/composer.json @@ -63,6 +63,7 @@ "Mcp\\Example\\EnvVariables\\": "examples/env-variables/", "Mcp\\Example\\ExplicitRegistration\\": "examples/explicit-registration/", "Mcp\\Example\\SchemaShowcase\\": "examples/schema-showcase/", + "Mcp\\Example\\StdioLoggingShowcase\\": "examples/stdio-logging-showcase/",a "Mcp\\Tests\\": "tests/" } }, diff --git a/docs/mcp-elements.md b/docs/mcp-elements.md index 1846a7dd..0f9b74e5 100644 --- a/docs/mcp-elements.md +++ b/docs/mcp-elements.md @@ -11,6 +11,7 @@ discovery and manual registration methods. - [Resources](#resources) - [Resource Templates](#resource-templates) - [Prompts](#prompts) +- [Logging](#logging) - [Completion Providers](#completion-providers) - [Schema Generation and Validation](#schema-generation-and-validation) - [Discovery vs Manual Registration](#discovery-vs-manual-registration) @@ -504,6 +505,55 @@ public function generatePrompt(string $topic, string $style): array **Recommendation**: Use `PromptGetException` when you want to communicate specific errors to clients. Any other exception will still be converted to JSON-RPC compliant errors but with generic error messages. +## Logging + +The SDK provides automatic logging support, handlers can receive logger instances automatically to send structured log messages to clients. + +### Configuration + +Logging is **enabled by default**. Use `disableClientLogging()` to turn it off: + +```php +// Logging enabled (default) +$server = Server::builder()->build(); + +// Disable logging +$server = Server::builder() + ->disableClientLogging() + ->build(); +``` + +### Auto-injection + +The SDK automatically injects logger instances into handlers: + +```php +use Mcp\Capability\Logger\ClientLogger; +use Psr\Log\LoggerInterface; + +#[McpTool] +public function processData(string $input, ClientLogger $logger): array { + $logger->info('Processing started', ['input' => $input]); + $logger->warning('Deprecated API used'); + + // ... processing logic ... + + $logger->info('Processing completed'); + return ['result' => 'processed']; +} + +// Also works with PSR-3 LoggerInterface +#[McpResource(uri: 'data://config')] +public function getConfig(LoggerInterface $logger): array { + $logger->info('Configuration accessed'); + return ['setting' => 'value']; +} +``` + +### Log Levels + +The SDK supports all standard PSR-3 log levels with **warning** as the default level: + ## Completion Providers Completion providers help MCP clients offer auto-completion suggestions for Resource Templates and Prompts. Unlike Tools and static Resources (which can be listed via `tools/list` and `resources/list`), Resource Templates and Prompts have dynamic parameters that benefit from completion hints. diff --git a/docs/server-builder.md b/docs/server-builder.md index 5b00f902..492f448c 100644 --- a/docs/server-builder.md +++ b/docs/server-builder.md @@ -577,6 +577,7 @@ $server = Server::builder() | `addRequestHandlers()` | handlers | Prepend multiple custom request handlers | | `addNotificationHandler()` | handler | Prepend a single custom notification handler | | `addNotificationHandlers()` | handlers | Prepend multiple custom notification handlers | +| `disableClientLogging()` | - | Disable MCP client logging (enabled by default) | | `addTool()` | handler, name?, description?, annotations?, inputSchema? | Register tool | | `addResource()` | handler, uri, name?, description?, mimeType?, size?, annotations? | Register resource | | `addResourceTemplate()` | handler, uriTemplate, name?, description?, mimeType?, annotations? | Register resource template | diff --git a/examples/stdio-logging-showcase/LoggingShowcaseHandlers.php b/examples/stdio-logging-showcase/LoggingShowcaseHandlers.php new file mode 100644 index 00000000..f76bdf41 --- /dev/null +++ b/examples/stdio-logging-showcase/LoggingShowcaseHandlers.php @@ -0,0 +1,80 @@ + + */ + #[McpTool(name: 'log_message', description: 'Demonstrates MCP logging with different levels')] + public function logMessage(string $message, string $level, ClientLogger $logger): array + { + $logger->info('🚀 Starting log_message tool', [ + 'requested_level' => $level, + 'message_length' => \strlen($message), + ]); + + switch (strtolower($level)) { + case 'debug': + $logger->debug("Debug: $message", ['tool' => 'log_message']); + break; + case 'info': + $logger->info("Info: $message", ['tool' => 'log_message']); + break; + case 'notice': + $logger->notice("Notice: $message", ['tool' => 'log_message']); + break; + case 'warning': + $logger->warning("Warning: $message", ['tool' => 'log_message']); + break; + case 'error': + $logger->error("Error: $message", ['tool' => 'log_message']); + break; + case 'critical': + $logger->critical("Critical: $message", ['tool' => 'log_message']); + break; + case 'alert': + $logger->alert("Alert: $message", ['tool' => 'log_message']); + break; + case 'emergency': + $logger->emergency("Emergency: $message", ['tool' => 'log_message']); + break; + default: + $logger->warning("Unknown level '$level', defaulting to info"); + $logger->info("Info: $message", ['tool' => 'log_message']); + } + + $logger->debug('log_message tool completed successfully'); + + return [ + 'message' => "Logged message with level: $level", + 'logged_at' => date('Y-m-d H:i:s'), + 'level_used' => $level, + ]; + } +} diff --git a/examples/stdio-logging-showcase/server.php b/examples/stdio-logging-showcase/server.php new file mode 100644 index 00000000..5de71325 --- /dev/null +++ b/examples/stdio-logging-showcase/server.php @@ -0,0 +1,34 @@ +#!/usr/bin/env php +info('Starting MCP Stdio Logging Showcase Server...'); + +// Create server with auto-discovery of MCP capabilities and ENABLE MCP LOGGING +$server = Server::builder() + ->setServerInfo('Stdio Logging Showcase', '1.0.0', 'Demonstration of auto-injected MCP logging in capability handlers.') + ->setContainer(container()) + ->setLogger(logger()) + ->setDiscovery(__DIR__, ['.']) + ->build(); + +$transport = new StdioTransport(logger: logger()); + +$server->run($transport); + +logger()->info('Logging Showcase Server is ready!'); +logger()->info('This example demonstrates auto-injection of ClientLogger into capability handlers.'); diff --git a/src/Capability/Logger/ClientLogger.php b/src/Capability/Logger/ClientLogger.php new file mode 100644 index 00000000..1fb6d6fb --- /dev/null +++ b/src/Capability/Logger/ClientLogger.php @@ -0,0 +1,91 @@ + + */ +final class ClientLogger extends AbstractLogger +{ + public function __construct( + private readonly NotificationSender $notificationSender, + private readonly ?LoggerInterface $fallbackLogger = null, + ) { + } + + /** + * Logs with an arbitrary level. + * + * @param string|\Stringable $message + * @param array $context + */ + public function log($level, $message, array $context = []): void + { + // Always log to fallback logger if provided (for local debugging) + $this->fallbackLogger?->log($level, $message, $context); + + // Convert PSR-3 level to MCP LoggingLevel + $mcpLevel = $this->convertToMcpLevel($level); + if (null === $mcpLevel) { + return; // Unknown level, skip MCP notification + } + + // Send MCP logging notification - let NotificationHandler decide if it should be sent + try { + $this->notificationSender->send('notifications/message', [ + 'level' => $mcpLevel->value, + 'data' => (string) $message, + 'logger' => $context['logger'] ?? null, + ]); + } catch (\Throwable $e) { + // If MCP notification fails, at least log to fallback + $this->fallbackLogger?->error('Failed to send MCP log notification', [ + 'original_level' => $level, + 'original_message' => $message, + 'error' => $e->getMessage(), + ]); + } + } + + /** + * Converts PSR-3 log level to MCP LoggingLevel. + * + * @param mixed $level PSR-3 level + * + * @return LoggingLevel|null MCP level or null if unknown + */ + private function convertToMcpLevel($level): ?LoggingLevel + { + return match (strtolower((string) $level)) { + 'emergency' => LoggingLevel::Emergency, + 'alert' => LoggingLevel::Alert, + 'critical' => LoggingLevel::Critical, + 'error' => LoggingLevel::Error, + 'warning' => LoggingLevel::Warning, + 'notice' => LoggingLevel::Notice, + 'info' => LoggingLevel::Info, + 'debug' => LoggingLevel::Debug, + default => null, + }; + } +} diff --git a/src/Capability/Registry.php b/src/Capability/Registry.php index d0813412..27d54a46 100644 --- a/src/Capability/Registry.php +++ b/src/Capability/Registry.php @@ -25,6 +25,7 @@ use Mcp\Exception\PromptNotFoundException; use Mcp\Exception\ResourceNotFoundException; use Mcp\Exception\ToolNotFoundException; +use Mcp\Schema\Enum\LoggingLevel; use Mcp\Schema\Page; use Mcp\Schema\Prompt; use Mcp\Schema\Resource; @@ -61,6 +62,10 @@ final class Registry implements RegistryInterface */ private array $resourceTemplates = []; + private bool $logging = true; + + private LoggingLevel $loggingLevel = LoggingLevel::Warning; + public function __construct( private readonly ?EventDispatcherInterface $eventDispatcher = null, private readonly LoggerInterface $logger = new NullLogger(), @@ -391,6 +396,46 @@ public function setDiscoveryState(DiscoveryState $state): void } } + + /** + * Disable logging message notifications for this registry. + */ + public function disableLogging(): void + { + $this->logging = false; + } + + /** + * Checks if logging message notification capability is enabled. + * + * @return bool True if logging capability is enabled, false otherwise + */ + public function isLoggingEnabled(): bool + { + return $this->logging; + } + + /** + * Sets the current logging message notification level for the client. + * + * This determines which log messages should be sent to the client. + * Only messages at this level and higher (more severe) will be sent. + */ + public function setLoggingLevel(LoggingLevel $level): void + { + $this->loggingLevel = $level; + } + + /** + * Gets the current logging message notification level set by the client. + * + * @return LoggingLevel The current log level + */ + public function getLoggingLevel(): LoggingLevel + { + return $this->loggingLevel; + } + /** * Calculate next cursor for pagination. * diff --git a/src/Schema/Enum/LoggingLevel.php b/src/Schema/Enum/LoggingLevel.php index e9aecef8..6da56ee0 100644 --- a/src/Schema/Enum/LoggingLevel.php +++ b/src/Schema/Enum/LoggingLevel.php @@ -18,6 +18,7 @@ * https://datatracker.ietf.org/doc/html/rfc5424#section-6.2.1 * * @author Kyrian Obikwelu + * @author Adam Jamiu */ enum LoggingLevel: string { @@ -29,4 +30,24 @@ enum LoggingLevel: string case Critical = 'critical'; case Alert = 'alert'; case Emergency = 'emergency'; + + /** + * 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) + */ + public function getSeverityIndex(): 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, + }; + } } diff --git a/src/Server.php b/src/Server.php index 8657610a..d154badb 100644 --- a/src/Server.php +++ b/src/Server.php @@ -12,6 +12,7 @@ namespace Mcp; use Mcp\Server\Builder; +use Mcp\Server\NotificationSender; use Mcp\Server\Protocol; use Mcp\Server\Transport\TransportInterface; use Psr\Log\LoggerInterface; @@ -25,6 +26,7 @@ final class Server { public function __construct( private readonly Protocol $protocol, + private readonly NotificationSender $notificationSender, private readonly LoggerInterface $logger = new NullLogger(), ) { } @@ -46,6 +48,12 @@ public function run(TransportInterface $transport): mixed $transport->initialize(); $this->protocol->connect($transport); + $this->logger->info('Transport initialized.', [ + 'transport' => $transport::class, + ]); + + // Configure the NotificationSender with the transport + $this->notificationSender->setTransport($transport); $this->logger->info('Running server...'); diff --git a/src/Server/Builder.php b/src/Server/Builder.php index 4142b97a..6503300a 100644 --- a/src/Server/Builder.php +++ b/src/Server/Builder.php @@ -28,7 +28,9 @@ use Mcp\Schema\ToolAnnotations; use Mcp\Server; use Mcp\Server\Handler\Notification\NotificationHandlerInterface; +use Mcp\Server\Handler\NotificationHandler; use Mcp\Server\Handler\Request\RequestHandlerInterface; +use Mcp\Server\Handler\Request\SetLogLevelHandler; use Mcp\Server\Session\InMemorySessionStore; use Mcp\Server\Session\SessionFactory; use Mcp\Server\Session\SessionFactoryInterface; @@ -80,6 +82,8 @@ final class Builder */ private array $notificationHandlers = []; + private bool $logging = true; + /** * @var array{ * handler: Handler, @@ -258,6 +262,16 @@ public function setRegistry(RegistryInterface $registry): self return $this; } + /** + * Disables Client logging capability for the server. + */ + public function disableClientLogging(): self + { + $this->logging = false; + + return $this; + } + /** * Provides a PSR-3 logger instance. Defaults to NullLogger. */ @@ -452,6 +466,10 @@ public function build(): Server $container = $this->container ?? new Container(); $registry = $this->registry ?? new Registry($this->eventDispatcher, $logger); + if (!$this->logging) { + $registry->disableLogging(); + } + $loaders = [ ...$this->loaders, new ArrayLoader($this->tools, $this->resources, $this->resourceTemplates, $this->prompts, $logger), @@ -482,6 +500,14 @@ public function build(): Server completions: true, ); + // Create notification infrastructure first + $notificationHandler = NotificationHandler::make($registry, $logger); + $notificationSender = new NotificationSender($notificationHandler, null, $logger); + + // Create ClientLogger for components that should send logs via MCP + $clientLogger = new ClientLogger($notificationSender, $logger); + $referenceHandler = new ReferenceHandler($container, $clientLogger); + $serverInfo = $this->serverInfo ?? new Implementation(); $configuration = new Configuration($serverInfo, $capabilities, $this->paginationLimit, $this->instructions, $this->protocolVersion); $referenceHandler = new ReferenceHandler($container); @@ -497,6 +523,7 @@ public function build(): Server new Handler\Request\ListToolsHandler($registry, $this->paginationLimit), new Handler\Request\PingHandler(), new Handler\Request\ReadResourceHandler($registry, $referenceHandler, $logger), + new SetLogLevelHandler($registry, $logger), ]); $notificationHandlers = array_merge($this->notificationHandlers, [ @@ -512,6 +539,6 @@ public function build(): Server logger: $logger, ); - return new Server($protocol, $logger); + return new Server($protocol, $notificationSender, $logger); } } diff --git a/src/Server/Handler/Notification/LoggingMessageNotificationHandler.php b/src/Server/Handler/Notification/LoggingMessageNotificationHandler.php new file mode 100644 index 00000000..58318b2b --- /dev/null +++ b/src/Server/Handler/Notification/LoggingMessageNotificationHandler.php @@ -0,0 +1,109 @@ + + */ +final class LoggingMessageNotificationHandler implements NotificationHandlerInterface +{ + public function __construct( + private readonly ReferenceRegistryInterface $registry, + private readonly LoggerInterface $logger, + ) { + } + + public function handle(string $method, array $params): Notification + { + if (!$this->supports($method)) { + throw new InvalidArgumentException("Handler does not support method: {$method}"); + } + + $this->validateRequiredParameter($params); + + $level = $this->getLoggingLevel($params); + + if (!$this->registry->isLoggingEnabled()) { + $this->logger->debug('Logging is disabled, skipping log message'); + throw new InvalidArgumentException('Logging capability is not enabled'); + } + + $this->validateLogLevelThreshold($level); + + return new LoggingMessageNotification( + level: $level, + data: $params['data'], + logger: $params['logger'] ?? null + ); + } + + private function supports(string $method): bool + { + return $method === LoggingMessageNotification::getMethod(); + } + + /** + * @param array $params + */ + private function validateRequiredParameter(array $params): void + { + if (!isset($params['level'])) { + throw new InvalidArgumentException('Missing required parameter "level" for logging notification'); + } + + if (!isset($params['data'])) { + throw new InvalidArgumentException('Missing required parameter "data" for logging notification'); + } + } + + /** + * @param array $params + */ + private function getLoggingLevel(array $params): LoggingLevel + { + return $params['level'] instanceof LoggingLevel + ? $params['level'] + : LoggingLevel::from($params['level']); + } + + private function validateLogLevelThreshold(LoggingLevel $level): void + { + $currentLogLevel = $this->registry->getLoggingLevel(); + + if ($this->shouldSendLogLevel($level, $currentLogLevel)) { + return; + } + + $this->logger->debug( + "Log level {$level->value} is below current threshold {$currentLogLevel->value}, skipping" + ); + throw new InvalidArgumentException('Log level is below current threshold'); + } + + private function shouldSendLogLevel(LoggingLevel $messageLevel, LoggingLevel $currentLevel): bool + { + return $messageLevel->getSeverityIndex() >= $currentLevel->getSeverityIndex(); + } +} diff --git a/src/Server/Handler/NotificationHandler.php b/src/Server/Handler/NotificationHandler.php new file mode 100644 index 00000000..94a2996d --- /dev/null +++ b/src/Server/Handler/NotificationHandler.php @@ -0,0 +1,154 @@ + + */ +final class NotificationHandler +{ + /** + * @var array + */ + private readonly array $handlers; + + /** + * @param array $handlers Method-to-handler mapping + */ + public function __construct( + array $handlers, + private readonly LoggerInterface $logger = new NullLogger(), + ) { + $this->handlers = $handlers; + } + + /** + * Creates a NotificationHandler with default handlers. + */ + public static function make( + ReferenceRegistryInterface $registry, + LoggerInterface $logger = new NullLogger(), + ): self { + return new self( + handlers: [ + LoggingMessageNotification::getMethod() => new LoggingMessageNotificationHandler($registry, $logger), + ], + logger: $logger, + ); + } + + /** + * Processes a notification creation request. + * + * @param string $method The notification method + * @param array $params Parameters for the notification + * + * @return string|null The serialized JSON notification, or null on failure + * + * @throws HandlerNotFoundException When no handler supports the method + */ + public function process(string $method, array $params): ?string + { + $context = ['method' => $method, 'params' => $params]; + $this->logger->debug("Processing notification for method: {$method}", $context); + + $handler = $this->getHandlerFor($method); + + return $this->createAndEncodeNotification($handler, $method, $params); + } + + /** + * Gets the handler for a specific method. + * + * @throws HandlerNotFoundException When no handler supports the method + */ + private function getHandlerFor(string $method): NotificationHandlerInterface + { + $handler = $this->handlers[$method] ?? null; + + if (!$handler) { + throw new HandlerNotFoundException("No notification handler found for method: {$method}"); + } + + return $handler; + } + + /** + * Creates notification using handler and encodes it to JSON. + * + * @param array $params + */ + private function createAndEncodeNotification( + NotificationHandlerInterface $handler, + string $method, + array $params, + ): ?string { + try { + $notification = $handler->handle($method, $params); + + $this->logger->debug('Notification created successfully', [ + 'method' => $method, + 'handler' => $handler::class, + 'notification_class' => $notification::class, + ]); + + return $this->encodeNotification($notification); + } catch (\Throwable $e) { + $this->logger->error('Failed to create notification', [ + 'method' => $method, + 'handler' => $handler::class, + 'error' => $e->getMessage(), + 'exception' => $e, + ]); + + return null; + } + } + + /** + * Encodes a notification to JSON, handling encoding errors gracefully. + */ + private function encodeNotification(Notification $notification): ?string + { + $method = $notification->getMethod(); + + $this->logger->debug('Encoding notification', [ + 'method' => $method, + 'notification_class' => $notification::class, + ]); + + try { + return json_encode($notification, \JSON_THROW_ON_ERROR); + } catch (\JsonException $e) { + $this->logger->error('JSON encoding failed for notification', [ + 'method' => $method, + 'error' => $e->getMessage(), + 'exception' => $e, + ]); + + return null; + } + } +} diff --git a/src/Server/Handler/NotificationHandlerInterface.php b/src/Server/Handler/NotificationHandlerInterface.php new file mode 100644 index 00000000..f09667d1 --- /dev/null +++ b/src/Server/Handler/NotificationHandlerInterface.php @@ -0,0 +1,35 @@ + + */ +interface NotificationHandlerInterface +{ + /** + * Creates a notification instance from the given parameters. + * + * @param string $method The notification method + * @param array $params Parameters for the notification + * + * @return Notification The created notification instance + */ + public function handle(string $method, array $params): Notification; +} diff --git a/src/Server/Handler/Request/SetLogLevelHandler.php b/src/Server/Handler/Request/SetLogLevelHandler.php new file mode 100644 index 00000000..6e771945 --- /dev/null +++ b/src/Server/Handler/Request/SetLogLevelHandler.php @@ -0,0 +1,54 @@ + + */ +final class SetLogLevelHandler implements RequestHandlerInterface +{ + public function __construct( + private readonly ReferenceRegistryInterface $registry, + private readonly LoggerInterface $logger, + ) { + } + + public function supports(Request $message): bool + { + return $message instanceof SetLogLevelRequest; + } + + public function handle(Request $message, SessionInterface $session): Response + { + \assert($message instanceof SetLogLevelRequest); + + // Update the log level in the registry via the interface + $this->registry->setLoggingLevel($message->level); + + $this->logger->debug("Log level set to: {$message->level->value}"); + + return new Response($message->getId(), new EmptyResult()); + } +} diff --git a/src/Server/NotificationSender.php b/src/Server/NotificationSender.php new file mode 100644 index 00000000..ca63585f --- /dev/null +++ b/src/Server/NotificationSender.php @@ -0,0 +1,100 @@ + + */ +final class NotificationSender +{ + /** + * @param TransportInterface|null $transport + */ + public function __construct( + private readonly NotificationHandler $notificationHandler, + private ?TransportInterface $transport = null, + private readonly LoggerInterface $logger = new NullLogger(), + ) { + } + + /** + * Sets the transport interface for sending notifications. + * + * @param TransportInterface $transport + */ + public function setTransport(TransportInterface $transport): void + { + $this->transport = $transport; + } + + /** + * Sends a notification to the client. + * + * @param string $method The notification method + * @param array $params Parameters for the notification + * + * @throws RuntimeException If no transport is available + */ + public function send(string $method, array $params): void + { + $this->ensureTransportAvailable(); + + try { + $encodedNotification = $this->notificationHandler->process($method, $params); + + if (null !== $encodedNotification) { + $this->transport->send($encodedNotification, []); + $this->logger->debug('Notification sent successfully', [ + 'method' => $method, + 'transport' => $this->transport::class, + ]); + } else { + $this->logger->warning('Failed to create notification', [ + 'method' => $method, + ]); + } + } catch (\Throwable $e) { + $this->logger->error('Failed to send notification', [ + 'method' => $method, + 'error' => $e->getMessage(), + 'exception' => $e, + ]); + + // Re-throw as RuntimeException to maintain API contract + throw new RuntimeException("Failed to send notification: {$e->getMessage()}", 0, $e); + } + } + + /** + * Ensures transport is available before attempting operations. + * + * @throws RuntimeException If no transport is available + */ + private function ensureTransportAvailable(): void + { + if (null === $this->transport) { + throw new RuntimeException('No transport configured for notification sending'); + } + } +} diff --git a/tests/Unit/Capability/Discovery/DocBlockTestFixture.php b/tests/Unit/Capability/Discovery/DocBlockTestFixture.php index a218ad63..97f7161b 100644 --- a/tests/Unit/Capability/Discovery/DocBlockTestFixture.php +++ b/tests/Unit/Capability/Discovery/DocBlockTestFixture.php @@ -77,6 +77,10 @@ public function methodWithReturn(): string */ public function methodWithMultipleTags(float $value): bool { + if ($value < 0) { + throw new \RuntimeException('Processing failed for negative values'); + } + return true; } diff --git a/tests/Unit/Capability/Discovery/SchemaGeneratorFixture.php b/tests/Unit/Capability/Discovery/SchemaGeneratorFixture.php index 5a7fcaeb..9670dcab 100644 --- a/tests/Unit/Capability/Discovery/SchemaGeneratorFixture.php +++ b/tests/Unit/Capability/Discovery/SchemaGeneratorFixture.php @@ -412,4 +412,15 @@ public function parameterSchemaInferredType( $inferredParam, ): void { } + + /** + * Method with ClientLogger that should be excluded from schema. + * + * @param string $message The message to process + * @param \Mcp\Capability\Logger\ClientLogger $logger Auto-injected logger + */ + public function withClientLogger(string $message, \Mcp\Capability\Logger\ClientLogger $logger): string + { + return $message; + } } diff --git a/tests/Unit/Capability/Discovery/SchemaGeneratorTest.php b/tests/Unit/Capability/Discovery/SchemaGeneratorTest.php index 4cbfce52..31f10a3a 100644 --- a/tests/Unit/Capability/Discovery/SchemaGeneratorTest.php +++ b/tests/Unit/Capability/Discovery/SchemaGeneratorTest.php @@ -327,4 +327,20 @@ public function testInfersParameterTypeAsAnyIfOnlyConstraintsAreGiven() $this->assertEquals(['description' => 'Some parameter', 'minLength' => 3], $schema['properties']['inferredParam']); $this->assertEquals(['inferredParam'], $schema['required']); } + + public function testExcludesClientLoggerFromSchema() + { + $method = new \ReflectionMethod(SchemaGeneratorFixture::class, 'withClientLogger'); + $schema = $this->schemaGenerator->generate($method); + + // Should include the message parameter + $this->assertArrayHasKey('message', $schema['properties']); + $this->assertEquals(['type' => 'string', 'description' => 'The message to process'], $schema['properties']['message']); + + // Should NOT include the logger parameter + $this->assertArrayNotHasKey('logger', $schema['properties']); + + // Required array should only contain client parameters + $this->assertEquals(['message'], $schema['required']); + } } diff --git a/tests/Unit/Capability/Logger/ClientLoggerTest.php b/tests/Unit/Capability/Logger/ClientLoggerTest.php new file mode 100644 index 00000000..93affff9 --- /dev/null +++ b/tests/Unit/Capability/Logger/ClientLoggerTest.php @@ -0,0 +1,100 @@ + + */ +final class ClientLoggerTest extends TestCase +{ + private LoggerInterface&MockObject $fallbackLogger; + + protected function setUp(): void + { + $this->fallbackLogger = $this->createMock(LoggerInterface::class); + } + + public function testImplementsPsr3LoggerInterface(): void + { + $logger = $this->createClientLogger(); + $this->assertInstanceOf(LoggerInterface::class, $logger); + } + + public function testAlwaysLogsToFallbackLogger(): void + { + $this->fallbackLogger + ->expects($this->once()) + ->method('log') + ->with('info', 'Test message', ['key' => 'value']); + + $logger = $this->createClientLogger(); + $logger->info('Test message', ['key' => 'value']); + } + + public function testBasicLoggingMethodsWork(): void + { + $logger = $this->createClientLogger(); + + // Test all PSR-3 methods exist and can be called + $this->fallbackLogger->expects($this->exactly(8))->method('log'); + + $logger->emergency('emergency'); + $logger->alert('alert'); + $logger->critical('critical'); + $logger->error('error'); + $logger->warning('warning'); + $logger->notice('notice'); + $logger->info('info'); + $logger->debug('debug'); + } + + public function testHandlesMcpSendGracefully(): void + { + // Expect fallback logger to be called for original message + $this->fallbackLogger + ->expects($this->once()) + ->method('log') + ->with('info', 'Test message', []); + + // May also get error log if MCP send fails (which it likely will without transport) + $this->fallbackLogger + ->expects($this->atMost(1)) + ->method('error'); + + $logger = $this->createClientLogger(); + $logger->info('Test message'); + } + + private function createClientLogger(): ClientLogger + { + // Create minimal working NotificationSender for testing + // Using a minimal ReferenceRegistryInterface mock just to construct NotificationHandler + $registry = $this->createMock(ReferenceRegistryInterface::class); + $notificationHandler = NotificationHandler::make($registry); + $notificationSender = new NotificationSender($notificationHandler, null); + + return new ClientLogger( + $notificationSender, + $this->fallbackLogger + ); + } +} diff --git a/tests/Unit/Capability/RegistryLoggingTest.php b/tests/Unit/Capability/RegistryLoggingTest.php new file mode 100644 index 00000000..f19e28f6 --- /dev/null +++ b/tests/Unit/Capability/RegistryLoggingTest.php @@ -0,0 +1,148 @@ + + */ +class RegistryLoggingTest extends TestCase +{ + private Registry $registry; + private LoggerInterface&MockObject $logger; + + protected function setUp(): void + { + $this->logger = $this->createMock(LoggerInterface::class); + $this->registry = new Registry(null, $this->logger); + } + + public function testLoggingDEnabledByDefault(): void + { + $this->assertTrue($this->registry->isLoggingEnabled()); + } + + public function testLoggingStateEnablement(): void + { + // Logging starts disabled + $this->assertTrue($this->registry->isLoggingEnabled()); + + // Test enabling logging + $this->registry->disableLogging(); + $this->assertFalse($this->registry->isLoggingEnabled()); + + // Enabling again should have no effect + $this->registry->disableLogging(); + $this->assertFalse($this->registry->isLoggingEnabled()); + } + + public function testGetLogLevelReturnsWarningWhenNotSet(): void + { + $this->assertEquals(LoggingLevel::Warning->value, $this->registry->getLoggingLevel()->value); + } + + public function testLogLevelManagement(): void + { + // Initially should be null + $this->assertEquals(LoggingLevel::Warning->value, $this->registry->getLoggingLevel()->value); + + // Test setting and getting each log level + $levels = [ + LoggingLevel::Debug, + LoggingLevel::Info, + LoggingLevel::Notice, + LoggingLevel::Warning, + LoggingLevel::Error, + LoggingLevel::Critical, + LoggingLevel::Alert, + LoggingLevel::Emergency, + ]; + + foreach ($levels as $level) { + $this->registry->setLoggingLevel($level); + $this->assertEquals($level, $this->registry->getLoggingLevel()); + + // Verify enum properties are preserved + $retrievedLevel = $this->registry->getLoggingLevel(); + $this->assertEquals($level->value, $retrievedLevel->value); + $this->assertEquals($level->getSeverityIndex(), $retrievedLevel->getSeverityIndex()); + } + + // Final state should be the last level + $this->assertEquals(LoggingLevel::Emergency, $this->registry->getLoggingLevel()); + } + + public function testLoggingCapabilities(): void + { + // Test capabilities with logging disabled (default state) + $this->logger + ->expects($this->exactly(3)) + ->method('info') + ->with('No capabilities registered on server.'); + + $capabilities = $this->registry->getCapabilities(); + $this->assertInstanceOf(ServerCapabilities::class, $capabilities); + $this->assertTrue($capabilities->logging); + + // Enable logging and test capabilities + $this->registry->disableLogging(); + $capabilities = $this->registry->getCapabilities(); + $this->assertFalse($capabilities->logging); + + // Test with event dispatcher + /** @var EventDispatcherInterface&MockObject $eventDispatcher */ + $eventDispatcher = $this->createMock(EventDispatcherInterface::class); + $registryWithDispatcher = new Registry($eventDispatcher, $this->logger); + $registryWithDispatcher->disableLogging(); + + $capabilities = $registryWithDispatcher->getCapabilities(); + $this->assertFalse($capabilities->logging); + $this->assertTrue($capabilities->toolsListChanged); + $this->assertTrue($capabilities->resourcesListChanged); + $this->assertTrue($capabilities->promptsListChanged); + } + + public function testLoggingStateIndependentOfLevel(): void + { + // Logging can be disabled - level should remain but logging should be disabled + $this->registry->disableLogging(); + $this->assertFalse($this->registry->isLoggingEnabled()); + $this->assertEquals(LoggingLevel::Warning, $this->registry->getLoggingLevel()); // Default level + + // Level can be set after disabling logging + $this->registry->setLoggingLevel(LoggingLevel::Info); + $this->assertFalse($this->registry->isLoggingEnabled()); + $this->assertEquals(LoggingLevel::Info, $this->registry->getLoggingLevel()); + + // Level can be set on a new registry without disabling logging + $newRegistry = new Registry(null, $this->logger); + $newRegistry->setLoggingLevel(LoggingLevel::Info); + $this->assertTrue($newRegistry->isLoggingEnabled()); + $this->assertEquals(LoggingLevel::Info, $newRegistry->getLoggingLevel()); + + // Test persistence: Set level then disable logging - level should persist + $persistRegistry = new Registry(null, $this->logger); + $persistRegistry->setLoggingLevel(LoggingLevel::Critical); + $this->assertEquals(LoggingLevel::Critical, $persistRegistry->getLoggingLevel()); + + $persistRegistry->disableLogging(); + $this->assertFalse($persistRegistry->isLoggingEnabled()); + $this->assertEquals(LoggingLevel::Critical, $persistRegistry->getLoggingLevel()); + } +} diff --git a/tests/Unit/Schema/Enum/LoggingLevelTest.php b/tests/Unit/Schema/Enum/LoggingLevelTest.php new file mode 100644 index 00000000..877d38ac --- /dev/null +++ b/tests/Unit/Schema/Enum/LoggingLevelTest.php @@ -0,0 +1,101 @@ + + */ +class LoggingLevelTest extends TestCase +{ + public function testEnumValuesAndSeverityIndexes(): void + { + $expectedLevelsWithIndexes = [ + ['level' => LoggingLevel::Debug, 'value' => 'debug', 'index' => 0], + ['level' => LoggingLevel::Info, 'value' => 'info', 'index' => 1], + ['level' => LoggingLevel::Notice, 'value' => 'notice', 'index' => 2], + ['level' => LoggingLevel::Warning, 'value' => 'warning', 'index' => 3], + ['level' => LoggingLevel::Error, 'value' => 'error', 'index' => 4], + ['level' => LoggingLevel::Critical, 'value' => 'critical', 'index' => 5], + ['level' => LoggingLevel::Alert, 'value' => 'alert', 'index' => 6], + ['level' => LoggingLevel::Emergency, 'value' => 'emergency', 'index' => 7], + ]; + + foreach ($expectedLevelsWithIndexes as $data) { + $level = $data['level']; + + // Test enum value + $this->assertEquals($data['value'], $level->value); + + // Test severity index + $this->assertEquals($data['index'], $level->getSeverityIndex()); + + // Test severity index consistency (multiple calls return same result) + $this->assertEquals($data['index'], $level->getSeverityIndex()); + + // Test from string conversion + $fromString = LoggingLevel::from($data['value']); + $this->assertEquals($level, $fromString); + $this->assertEquals($data['value'], $fromString->value); + } + + // Test severity comparisons - each level should have higher index than previous + for ($i = 1; $i < \count($expectedLevelsWithIndexes); ++$i) { + $previous = $expectedLevelsWithIndexes[$i - 1]['level']; + $current = $expectedLevelsWithIndexes[$i]['level']; + $this->assertTrue( + $previous->getSeverityIndex() < $current->getSeverityIndex(), + "Expected {$previous->value} index to be less than {$current->value} index" + ); + } + } + + public function testInvalidLogLevelHandling(): void + { + // Test invalid level string + $this->expectException(\ValueError::class); + $this->expectExceptionMessage('"invalid_level" is not a valid backing value for enum'); + LoggingLevel::from('invalid_level'); + } + + public function testCaseSensitiveLogLevels(): void + { + // Should be case sensitive - 'DEBUG' is not 'debug' + $this->expectException(\ValueError::class); + LoggingLevel::from('DEBUG'); + } + + public function testEnumUniquenessAndCoverage(): void + { + $indexes = []; + $allCases = LoggingLevel::cases(); + + foreach ($allCases as $level) { + $index = $level->getSeverityIndex(); + + // Check that this index hasn't been used before + $this->assertNotContains($index, $indexes, "Severity index {$index} is duplicated for level {$level->value}"); + + $indexes[] = $index; + } + + // Verify we have exactly 8 unique indexes + $this->assertCount(8, $indexes); + $this->assertCount(8, $allCases); + + // Verify indexes are sequential from 0 to 7 + sort($indexes); + $this->assertEquals([0, 1, 2, 3, 4, 5, 6, 7], $indexes); + } +} diff --git a/tests/Unit/Server/BuilderLoggingTest.php b/tests/Unit/Server/BuilderLoggingTest.php new file mode 100644 index 00000000..f2a265b8 --- /dev/null +++ b/tests/Unit/Server/BuilderLoggingTest.php @@ -0,0 +1,92 @@ + + */ +class BuilderLoggingTest extends TestCase +{ + public function testLoggingEnabledByDefault(): void + { + $builder = new Builder(); + + $this->assertTrue($this->getBuilderLoggingState($builder), 'Builder should start with logging enabled'); + + $server = $builder->setServerInfo('Test Server', '1.0.0')->build(); + $this->assertInstanceOf(Server::class, $server); + } + + public function testDisableClientLoggingConfiguresBuilder(): void + { + $builder = new Builder(); + + $result = $builder->disableClientLogging(); + + // Test method chaining + $this->assertSame($builder, $result, 'disableClientLogging should return builder for chaining'); + + // Test internal state + $this->assertFalse($this->getBuilderLoggingState($builder), 'disableClientLogging should set internal flag'); + + // Test server builds successfully + $server = $builder->setServerInfo('Test Server', '1.0.0')->build(); + $this->assertInstanceOf(Server::class, $server); + } + + public function testMultipleDisableCallsAreIdempotent(): void + { + $builder = new Builder(); + + $builder->disableClientLogging() + ->disableClientLogging() + ->disableClientLogging(); + + $this->assertFalse($this->getBuilderLoggingState($builder), 'Multiple disable calls should maintain disabled state'); + } + + public function testLoggingStatePreservedAcrossBuilds(): void + { + $builder = new Builder(); + $builder->setServerInfo('Test Server', '1.0.0')->disableClientLogging(); + + $server1 = $builder->build(); + $server2 = $builder->build(); + + // State should persist after building + $this->assertFalse($this->getBuilderLoggingState($builder), 'Builder state should persist after builds'); + $this->assertInstanceOf(Server::class, $server1); + $this->assertInstanceOf(Server::class, $server2); + } + + /** + * Get the internal logging state of the builder using reflection. + * This directly tests the builder's internal configuration. + */ + private function getBuilderLoggingState(Builder $builder): bool + { + $reflection = new \ReflectionClass($builder); + $property = $reflection->getProperty('logging'); + $property->setAccessible(true); + + return $property->getValue($builder); + } +} diff --git a/tests/Unit/Server/Handler/Notification/LoggingMessageNotificationHandlerTest.php b/tests/Unit/Server/Handler/Notification/LoggingMessageNotificationHandlerTest.php new file mode 100644 index 00000000..7d0d196c --- /dev/null +++ b/tests/Unit/Server/Handler/Notification/LoggingMessageNotificationHandlerTest.php @@ -0,0 +1,203 @@ + + */ +class LoggingMessageNotificationHandlerTest extends TestCase +{ + private LoggingMessageNotificationHandler $handler; + private ReferenceRegistryInterface&MockObject $registry; + private LoggerInterface&MockObject $logger; + + protected function setUp(): void + { + $this->registry = $this->createMock(ReferenceRegistryInterface::class); + $this->logger = $this->createMock(LoggerInterface::class); + + $this->handler = new LoggingMessageNotificationHandler( + $this->registry, + $this->logger + ); + } + + public function testHandleNotificationCreation(): void + { + $this->registry + ->expects($this->exactly(2)) + ->method('isLoggingEnabled') + ->willReturn(true); + + $this->registry + ->expects($this->exactly(2)) + ->method('getLoggingLevel') + ->willReturnOnConsecutiveCalls(LoggingLevel::Info, LoggingLevel::Debug); + + // Test with LoggingLevel enum + $params1 = [ + 'level' => LoggingLevel::Error, + 'data' => 'Test error message', + 'logger' => 'TestLogger', + ]; + $notification1 = $this->handler->handle(LoggingMessageNotification::getMethod(), $params1); + $this->assertInstanceOf(LoggingMessageNotification::class, $notification1); + /* @var LoggingMessageNotification $notification1 */ + $this->assertEquals(LoggingLevel::Error, $notification1->level); + $this->assertEquals($params1['data'], $notification1->data); + $this->assertEquals($params1['logger'], $notification1->logger); + + // Test with string level conversion + $params2 = [ + 'level' => 'warning', + 'data' => 'String level test', + ]; + $notification2 = $this->handler->handle(LoggingMessageNotification::getMethod(), $params2); + $this->assertInstanceOf(LoggingMessageNotification::class, $notification2); + /* @var LoggingMessageNotification $notification2 */ + $this->assertEquals(LoggingLevel::Warning, $notification2->level); + $this->assertEquals($params2['data'], $notification2->data); + $this->assertNull($notification2->logger); + } + + public function testValidationAndErrors(): void + { + // Test missing level parameter + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Missing required parameter "level" for logging notification'); + $this->handler->handle(LoggingMessageNotification::getMethod(), ['data' => 'Missing level parameter']); + } + + public function testValidateRequiredParameterData(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Missing required parameter "data" for logging notification'); + $this->handler->handle(LoggingMessageNotification::getMethod(), ['level' => LoggingLevel::Info]); + } + + public function testLoggingDisabledRejectsMessages(): void + { + $this->registry + ->expects($this->once()) + ->method('isLoggingEnabled') + ->willReturn(false); + + $this->logger + ->expects($this->once()) + ->method('debug') + ->with('Logging is disabled, skipping log message'); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Logging capability is not enabled'); + + $params = [ + 'level' => LoggingLevel::Error, + 'data' => 'This should be rejected', + ]; + + $this->handler->handle(LoggingMessageNotification::getMethod(), $params); + } + + public function testLogLevelFiltering(): void + { + // Test equal level is allowed + $this->registry + ->expects($this->exactly(3)) + ->method('isLoggingEnabled') + ->willReturn(true); + + $this->registry + ->expects($this->exactly(3)) + ->method('getLoggingLevel') + ->willReturnOnConsecutiveCalls(LoggingLevel::Warning, LoggingLevel::Warning, LoggingLevel::Error); + + // Equal level should be allowed + $params1 = ['level' => LoggingLevel::Warning, 'data' => 'Warning message at threshold']; + $notification1 = $this->handler->handle(LoggingMessageNotification::getMethod(), $params1); + $this->assertInstanceOf(LoggingMessageNotification::class, $notification1); + /* @var LoggingMessageNotification $notification1 */ + $this->assertEquals(LoggingLevel::Warning, $notification1->level); + + // Higher severity should be allowed + $params2 = ['level' => LoggingLevel::Critical, 'data' => 'Critical message above threshold']; + $notification2 = $this->handler->handle(LoggingMessageNotification::getMethod(), $params2); + $this->assertInstanceOf(LoggingMessageNotification::class, $notification2); + /* @var LoggingMessageNotification $notification2 */ + $this->assertEquals(LoggingLevel::Critical, $notification2->level); + + // Lower severity should be rejected + $this->logger + ->expects($this->once()) + ->method('debug') + ->with('Log level warning is below current threshold error, skipping'); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Log level is below current threshold'); + + $params3 = ['level' => LoggingLevel::Warning, 'data' => 'Warning message below threshold']; + $this->handler->handle(LoggingMessageNotification::getMethod(), $params3); + } + + public function testErrorHandling(): void + { + // Test invalid log level + $this->expectException(\ValueError::class); + $this->handler->handle(LoggingMessageNotification::getMethod(), [ + 'level' => 'invalid_level', + 'data' => 'Test data', + ]); + } + + public function testUnsupportedMethodThrowsException(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Handler does not support method: unsupported/method'); + $this->handler->handle('unsupported/method', ['level' => LoggingLevel::Info, 'data' => 'Test data']); + } + + public function testNotificationSerialization(): void + { + $this->registry + ->expects($this->once()) + ->method('isLoggingEnabled') + ->willReturn(true); + + $this->registry + ->expects($this->once()) + ->method('getLoggingLevel') + ->willReturn(LoggingLevel::Debug); + + $params = [ + 'level' => LoggingLevel::Info, + 'data' => 'Serialization test', + 'logger' => 'TestLogger', + ]; + + $notification = $this->handler->handle(LoggingMessageNotification::getMethod(), $params); + $serialized = $notification->jsonSerialize(); + + $this->assertEquals('2.0', $serialized['jsonrpc']); + $this->assertEquals('notifications/message', $serialized['method']); + $this->assertEquals('info', $serialized['params']['level']); + $this->assertEquals('Serialization test', $serialized['params']['data']); + $this->assertEquals('TestLogger', $serialized['params']['logger']); + } +} diff --git a/tests/Unit/Server/Handler/Request/SetLogLevelHandlerTest.php b/tests/Unit/Server/Handler/Request/SetLogLevelHandlerTest.php new file mode 100644 index 00000000..fde07398 --- /dev/null +++ b/tests/Unit/Server/Handler/Request/SetLogLevelHandlerTest.php @@ -0,0 +1,115 @@ + + */ +class SetLogLevelHandlerTest extends TestCase +{ + private SetLogLevelHandler $handler; + private ReferenceRegistryInterface&MockObject $registry; + private LoggerInterface&MockObject $logger; + private SessionInterface&MockObject $session; + + protected function setUp(): void + { + $this->registry = $this->createMock(ReferenceRegistryInterface::class); + $this->logger = $this->createMock(LoggerInterface::class); + $this->session = $this->createMock(SessionInterface::class); + + $this->handler = new SetLogLevelHandler( + $this->registry, + $this->logger + ); + } + + public function testSupportsSetLogLevelRequest(): void + { + $request = $this->createSetLogLevelRequest(LoggingLevel::Info); + + $this->assertTrue($this->handler->supports($request)); + } + + public function testDoesNotSupportOtherRequests(): void + { + $otherRequest = $this->createMock(Request::class); + + $this->assertFalse($this->handler->supports($otherRequest)); + } + + public function testHandleAllLogLevelsAndSupport(): void + { + $logLevels = [ + LoggingLevel::Debug, + LoggingLevel::Info, + LoggingLevel::Notice, + LoggingLevel::Warning, + LoggingLevel::Error, + LoggingLevel::Critical, + LoggingLevel::Alert, + LoggingLevel::Emergency, + ]; + + // Test handling all log levels + foreach ($logLevels as $level) { + $request = $this->createSetLogLevelRequest($level); + + $this->registry + ->expects($this->once()) + ->method('setLoggingLevel') + ->with($level); + + $this->logger + ->expects($this->once()) + ->method('debug') + ->with("Log level set to: {$level->value}"); + + $response = $this->handler->handle($request, $this->session); + + $this->assertInstanceOf(Response::class, $response); + $this->assertEquals($request->getId(), $response->id); + $this->assertInstanceOf(EmptyResult::class, $response->result); + + // Verify EmptyResult serializes correctly + $serialized = json_encode($response->result); + $this->assertEquals('{}', $serialized); + + // Reset mocks for next iteration + $this->setUp(); + } + } + + private function createSetLogLevelRequest(LoggingLevel $level): SetLogLevelRequest + { + return SetLogLevelRequest::fromArray([ + 'jsonrpc' => '2.0', + 'method' => SetLogLevelRequest::getMethod(), + 'id' => 'test-request-'.uniqid(), + 'params' => [ + 'level' => $level->value, + ], + ]); + } +} diff --git a/tests/Unit/Server/NotificationSenderTest.php b/tests/Unit/Server/NotificationSenderTest.php new file mode 100644 index 00000000..2330c823 --- /dev/null +++ b/tests/Unit/Server/NotificationSenderTest.php @@ -0,0 +1,187 @@ + + */ +final class NotificationSenderTest extends TestCase +{ + private NotificationHandler $notificationHandler; + /** @var TransportInterface&MockObject */ + private TransportInterface&MockObject $transport; + private LoggerInterface&MockObject $logger; + private ReferenceRegistryInterface&MockObject $registry; + private NotificationSender $sender; + + protected function setUp(): void + { + $this->registry = $this->createMock(ReferenceRegistryInterface::class); + $this->transport = $this->createMock(TransportInterface::class); + $this->logger = $this->createMock(LoggerInterface::class); + + // Create real NotificationHandler with mocked dependencies + $this->notificationHandler = NotificationHandler::make($this->registry); + + $this->sender = new NotificationSender( + $this->notificationHandler, + null, + $this->logger + ); + } + + public function testSetTransport(): void + { + // Configure logging to be enabled + $this->registry + ->method('isLoggingEnabled') + ->willReturn(true); + + $this->registry + ->method('getLoggingLevel') + ->willReturn(LoggingLevel::Info); + + // Setting transport should not throw any exceptions + $this->sender->setTransport($this->transport); + + // Verify we can send after setting transport (integration test) + $this->transport + ->expects($this->once()) + ->method('send') + ->with($this->isType('string'), []); + + $this->sender->send('notifications/message', ['level' => 'info', 'data' => 'test']); + } + + public function testSendWithoutTransportThrowsException(): void + { + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('No transport configured for notification sending'); + + $this->sender->send('notifications/message', ['level' => 'info', 'data' => 'test']); + } + + public function testSendSuccessfulNotification(): void + { + // Configure logging to be enabled + $this->registry + ->method('isLoggingEnabled') + ->willReturn(true); + + $this->registry + ->method('getLoggingLevel') + ->willReturn(LoggingLevel::Info); + + $this->sender->setTransport($this->transport); + + // Verify that transport send is called when we have a valid setup + $this->transport + ->expects($this->once()) + ->method('send') + ->with($this->isType('string'), []); + + $this->sender->send('notifications/message', ['level' => 'info', 'data' => 'test']); + } + + public function testSendHandlerFailureGracefullyHandled(): void + { + $this->sender->setTransport($this->transport); + + // Make logging disabled so handler fails gracefully (returns null) + $this->registry + ->expects($this->once()) + ->method('isLoggingEnabled') + ->willReturn(false); + + // Transport should never be called when notification creation fails + $this->transport + ->expects($this->never()) + ->method('send'); + + // Expect a warning to be logged about failed notification creation + $this->logger + ->expects($this->once()) + ->method('warning') + ->with('Failed to create notification', ['method' => 'notifications/message']); + + // This should not throw an exception - it should fail gracefully + $this->sender->send('notifications/message', ['level' => 'info', 'data' => 'test']); + } + + public function testSendTransportExceptionThrowsRuntimeException(): void + { + $exception = new \Exception('Transport error'); + + $this->sender->setTransport($this->transport); + + // Configure successful logging + $this->registry + ->expects($this->once()) + ->method('isLoggingEnabled') + ->willReturn(true); + + $this->registry + ->expects($this->once()) + ->method('getLoggingLevel') + ->willReturn(LoggingLevel::Info); + + $this->transport + ->expects($this->once()) + ->method('send') + ->with($this->isType('string'), []) + ->willThrowException($exception); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Failed to send notification: Transport error'); + + $this->sender->send('notifications/message', [ + 'level' => 'info', + 'data' => 'test message', + ]); + } + + public function testConstructorWithTransport(): void + { + // Configure logging to be enabled + $this->registry + ->method('isLoggingEnabled') + ->willReturn(true); + + $this->registry + ->method('getLoggingLevel') + ->willReturn(LoggingLevel::Info); + + $sender = new NotificationSender( + $this->notificationHandler, + $this->transport, + $this->logger + ); + + // Verify the sender can send notifications when constructed with transport + $this->transport + ->expects($this->once()) + ->method('send') + ->with($this->isType('string'), []); + + $sender->send('notifications/message', ['level' => 'info', 'data' => 'test']); + } +} diff --git a/tests/Unit/ServerTest.php b/tests/Unit/ServerTest.php index f7a8a370..cbb0eba2 100644 --- a/tests/Unit/ServerTest.php +++ b/tests/Unit/ServerTest.php @@ -11,8 +11,11 @@ namespace Mcp\Tests\Unit; +use Mcp\Capability\Registry\ReferenceRegistryInterface; use Mcp\Server; use Mcp\Server\Builder; +use Mcp\Server\Handler\NotificationHandler; +use Mcp\Server\NotificationSender; use Mcp\Server\Protocol; use Mcp\Server\Transport\TransportInterface; use PHPUnit\Framework\Attributes\TestDox; @@ -27,10 +30,18 @@ final class ServerTest extends TestCase /** @var MockObject&TransportInterface */ private $transport; + private NotificationSender $notificationSender; + protected function setUp(): void { $this->protocol = $this->createMock(Protocol::class); $this->transport = $this->createMock(TransportInterface::class); + + // Create real NotificationSender with mocked dependencies + /** @var ReferenceRegistryInterface&MockObject $registry */ + $registry = $this->createMock(ReferenceRegistryInterface::class); + $notificationHandler = NotificationHandler::make($registry); + $this->notificationSender = new NotificationSender($notificationHandler, null); } #[TestDox('builder() returns a Builder instance')] @@ -73,7 +84,7 @@ public function testRunOrchestatesTransportLifecycle(): void $callOrder[] = 'close'; }); - $server = new Server($this->protocol); + $server = new Server($this->protocol, $this->notificationSender); $result = $server->run($this->transport); $this->assertEquals([ @@ -99,7 +110,7 @@ public function testRunClosesTransportEvenOnException(): void // close() should still be called even though listen() threw $this->transport->expects($this->once())->method('close'); - $server = new Server($this->protocol); + $server = new Server($this->protocol, $this->notificationSender); $this->expectException(\RuntimeException::class); $this->expectExceptionMessage('Transport error'); @@ -114,7 +125,7 @@ public function testRunPropagatesInitializeException(): void ->method('initialize') ->willThrowException(new \RuntimeException('Initialize error')); - $server = new Server($this->protocol); + $server = new Server($this->protocol, $this->notificationSender); $this->expectException(\RuntimeException::class); $this->expectExceptionMessage('Initialize error'); @@ -134,7 +145,7 @@ public function testRunReturnsTransportListenValue(): void ->method('listen') ->willReturn($expectedReturn); - $server = new Server($this->protocol); + $server = new Server($this->protocol, $this->notificationSender); $result = $server->run($this->transport); $this->assertEquals($expectedReturn, $result); @@ -151,7 +162,7 @@ public function testRunConnectsProtocolToTransport(): void ->method('connect') ->with($this->identicalTo($this->transport)); - $server = new Server($this->protocol); + $server = new Server($this->protocol, $this->notificationSender); $server->run($this->transport); } } From e3960443b008bfffba88631e1047ae1e5a337d17 Mon Sep 17 00:00:00 2001 From: Tobias Nyholm Date: Sun, 30 Nov 2025 16:24:22 +0100 Subject: [PATCH 2/5] Add RequestContext --- composer.json | 2 +- docs/mcp-elements.md | 36 +--- docs/server-builder.md | 1 - .../LoggingShowcaseHandlers.php | 13 +- examples/client-logging/server.php | 29 +++ examples/stdio-logging-showcase/server.php | 34 --- src/Capability/Discovery/SchemaGenerator.php | 3 +- src/Capability/Logger/ClientLogger.php | 58 ++--- src/Capability/Registry.php | 45 ---- src/Capability/Registry/ReferenceHandler.php | 7 + src/Schema/Enum/LoggingLevel.php | 21 -- src/Schema/Request/SetLogLevelRequest.php | 2 +- src/Server.php | 8 - src/Server/Builder.php | 32 +-- src/Server/ClientAwareInterface.php | 3 + src/Server/ClientGateway.php | 3 +- .../Notification/InitializedHandler.php | 4 +- .../LoggingMessageNotificationHandler.php | 109 ---------- src/Server/Handler/NotificationHandler.php | 154 ------------- .../Handler/NotificationHandlerInterface.php | 35 --- .../Handler/Request/CallToolHandler.php | 1 + .../Handler/Request/GetPromptHandler.php | 1 + .../Handler/Request/ReadResourceHandler.php | 1 + .../Handler/Request/SetLogLevelHandler.php | 24 +-- src/Server/NotificationSender.php | 100 --------- src/Server/Protocol.php | 2 + src/Server/RequestContext.php | 64 ++++++ .../Discovery/SchemaGeneratorFixture.php | 11 - .../Discovery/SchemaGeneratorTest.php | 16 -- .../Capability/Logger/ClientLoggerTest.php | 128 ++++++----- tests/Unit/Capability/RegistryLoggingTest.php | 148 ------------- tests/Unit/Schema/Enum/LoggingLevelTest.php | 101 --------- tests/Unit/Server/BuilderLoggingTest.php | 92 -------- .../LoggingMessageNotificationHandlerTest.php | 203 ------------------ .../Request/SetLogLevelHandlerTest.php | 75 ++----- tests/Unit/Server/NotificationSenderTest.php | 187 ---------------- tests/Unit/ServerTest.php | 21 +- 37 files changed, 255 insertions(+), 1519 deletions(-) rename examples/{stdio-logging-showcase => client-logging}/LoggingShowcaseHandlers.php (85%) create mode 100644 examples/client-logging/server.php delete mode 100644 examples/stdio-logging-showcase/server.php delete mode 100644 src/Server/Handler/Notification/LoggingMessageNotificationHandler.php delete mode 100644 src/Server/Handler/NotificationHandler.php delete mode 100644 src/Server/Handler/NotificationHandlerInterface.php delete mode 100644 src/Server/NotificationSender.php create mode 100644 src/Server/RequestContext.php delete mode 100644 tests/Unit/Capability/RegistryLoggingTest.php delete mode 100644 tests/Unit/Schema/Enum/LoggingLevelTest.php delete mode 100644 tests/Unit/Server/BuilderLoggingTest.php delete mode 100644 tests/Unit/Server/Handler/Notification/LoggingMessageNotificationHandlerTest.php delete mode 100644 tests/Unit/Server/NotificationSenderTest.php diff --git a/composer.json b/composer.json index 4bd2605a..85f4f6c0 100644 --- a/composer.json +++ b/composer.json @@ -63,7 +63,7 @@ "Mcp\\Example\\EnvVariables\\": "examples/env-variables/", "Mcp\\Example\\ExplicitRegistration\\": "examples/explicit-registration/", "Mcp\\Example\\SchemaShowcase\\": "examples/schema-showcase/", - "Mcp\\Example\\StdioLoggingShowcase\\": "examples/stdio-logging-showcase/",a + "Mcp\\Example\\ClientLogging\\": "examples/client-logging/", "Mcp\\Tests\\": "tests/" } }, diff --git a/docs/mcp-elements.md b/docs/mcp-elements.md index 0f9b74e5..eaa3db89 100644 --- a/docs/mcp-elements.md +++ b/docs/mcp-elements.md @@ -507,32 +507,21 @@ public function generatePrompt(string $topic, string $style): array ## Logging -The SDK provides automatic logging support, handlers can receive logger instances automatically to send structured log messages to clients. +The SDK provides support to send structured log messages to clients. All standard PSR-3 log levels are supported. +Level **warning** as the default level. -### Configuration +### Usage -Logging is **enabled by default**. Use `disableClientLogging()` to turn it off: - -```php -// Logging enabled (default) -$server = Server::builder()->build(); - -// Disable logging -$server = Server::builder() - ->disableClientLogging() - ->build(); -``` - -### Auto-injection - -The SDK automatically injects logger instances into handlers: +The SDK automatically injects a `RequestContext` instance into handlers. This can be used to create a `ClientLogger`. ```php use Mcp\Capability\Logger\ClientLogger; use Psr\Log\LoggerInterface; #[McpTool] -public function processData(string $input, ClientLogger $logger): array { +public function processData(string $input, RequestContext $context): array { + $logger = $context->getClientLogger(); + $logger->info('Processing started', ['input' => $input]); $logger->warning('Deprecated API used'); @@ -541,19 +530,8 @@ public function processData(string $input, ClientLogger $logger): array { $logger->info('Processing completed'); return ['result' => 'processed']; } - -// Also works with PSR-3 LoggerInterface -#[McpResource(uri: 'data://config')] -public function getConfig(LoggerInterface $logger): array { - $logger->info('Configuration accessed'); - return ['setting' => 'value']; -} ``` -### Log Levels - -The SDK supports all standard PSR-3 log levels with **warning** as the default level: - ## Completion Providers Completion providers help MCP clients offer auto-completion suggestions for Resource Templates and Prompts. Unlike Tools and static Resources (which can be listed via `tools/list` and `resources/list`), Resource Templates and Prompts have dynamic parameters that benefit from completion hints. diff --git a/docs/server-builder.md b/docs/server-builder.md index 492f448c..5b00f902 100644 --- a/docs/server-builder.md +++ b/docs/server-builder.md @@ -577,7 +577,6 @@ $server = Server::builder() | `addRequestHandlers()` | handlers | Prepend multiple custom request handlers | | `addNotificationHandler()` | handler | Prepend a single custom notification handler | | `addNotificationHandlers()` | handlers | Prepend multiple custom notification handlers | -| `disableClientLogging()` | - | Disable MCP client logging (enabled by default) | | `addTool()` | handler, name?, description?, annotations?, inputSchema? | Register tool | | `addResource()` | handler, uri, name?, description?, mimeType?, size?, annotations? | Register resource | | `addResourceTemplate()` | handler, uriTemplate, name?, description?, mimeType?, annotations? | Register resource template | diff --git a/examples/stdio-logging-showcase/LoggingShowcaseHandlers.php b/examples/client-logging/LoggingShowcaseHandlers.php similarity index 85% rename from examples/stdio-logging-showcase/LoggingShowcaseHandlers.php rename to examples/client-logging/LoggingShowcaseHandlers.php index f76bdf41..35dfa232 100644 --- a/examples/stdio-logging-showcase/LoggingShowcaseHandlers.php +++ b/examples/client-logging/LoggingShowcaseHandlers.php @@ -9,10 +9,11 @@ * file that was distributed with this source code. */ -namespace Mcp\Example\StdioLoggingShowcase; +namespace Mcp\Example\ClientLogging; use Mcp\Capability\Attribute\McpTool; use Mcp\Capability\Logger\ClientLogger; +use Mcp\Server\RequestContext; /** * Example handlers showcasing auto-injected MCP logging capabilities. @@ -23,17 +24,17 @@ final class LoggingShowcaseHandlers { /** - * Tool that demonstrates different logging levels with auto-injected ClientLogger. + * Tool that demonstrates different logging levels. * - * @param string $message The message to log - * @param string $level The logging level (debug, info, warning, error) - * @param ClientLogger $logger Auto-injected MCP logger + * @param string $message The message to log + * @param string $level The logging level (debug, info, warning, error) * * @return array */ #[McpTool(name: 'log_message', description: 'Demonstrates MCP logging with different levels')] - public function logMessage(string $message, string $level, ClientLogger $logger): array + public function logMessage(RequestContext $context, string $message, string $level): array { + $logger = $context->getClientLogger(); $logger->info('🚀 Starting log_message tool', [ 'requested_level' => $level, 'message_length' => \strlen($message), diff --git a/examples/client-logging/server.php b/examples/client-logging/server.php new file mode 100644 index 00000000..3ca523f2 --- /dev/null +++ b/examples/client-logging/server.php @@ -0,0 +1,29 @@ +#!/usr/bin/env php +setServerInfo('Client Logging', '1.0.0', 'Demonstration of MCP logging in capability handlers.') + ->setContainer(container()) + ->setLogger(logger()) + ->setDiscovery(__DIR__) + ->build(); + +$result = $server->run(transport()); + +logger()->info('Server listener stopped gracefully.', ['result' => $result]); + +shutdown($result); diff --git a/examples/stdio-logging-showcase/server.php b/examples/stdio-logging-showcase/server.php deleted file mode 100644 index 5de71325..00000000 --- a/examples/stdio-logging-showcase/server.php +++ /dev/null @@ -1,34 +0,0 @@ -#!/usr/bin/env php -info('Starting MCP Stdio Logging Showcase Server...'); - -// Create server with auto-discovery of MCP capabilities and ENABLE MCP LOGGING -$server = Server::builder() - ->setServerInfo('Stdio Logging Showcase', '1.0.0', 'Demonstration of auto-injected MCP logging in capability handlers.') - ->setContainer(container()) - ->setLogger(logger()) - ->setDiscovery(__DIR__, ['.']) - ->build(); - -$transport = new StdioTransport(logger: logger()); - -$server->run($transport); - -logger()->info('Logging Showcase Server is ready!'); -logger()->info('This example demonstrates auto-injection of ClientLogger into capability handlers.'); diff --git a/src/Capability/Discovery/SchemaGenerator.php b/src/Capability/Discovery/SchemaGenerator.php index 2557f559..2431c510 100644 --- a/src/Capability/Discovery/SchemaGenerator.php +++ b/src/Capability/Discovery/SchemaGenerator.php @@ -13,6 +13,7 @@ use Mcp\Capability\Attribute\Schema; use Mcp\Server\ClientGateway; +use Mcp\Server\RequestContext; use phpDocumentor\Reflection\DocBlock\Tags\Param; /** @@ -415,7 +416,7 @@ private function parseParametersInfo(\ReflectionMethod|\ReflectionFunction $refl if ($reflectionType instanceof \ReflectionNamedType && !$reflectionType->isBuiltin()) { $typeName = $reflectionType->getName(); - if (is_a($typeName, ClientGateway::class, true)) { + if (is_a($typeName, ClientGateway::class, true) || is_a($typeName, RequestContext::class, true)) { continue; } } diff --git a/src/Capability/Logger/ClientLogger.php b/src/Capability/Logger/ClientLogger.php index 1fb6d6fb..1e0a959a 100644 --- a/src/Capability/Logger/ClientLogger.php +++ b/src/Capability/Logger/ClientLogger.php @@ -12,24 +12,22 @@ namespace Mcp\Capability\Logger; use Mcp\Schema\Enum\LoggingLevel; -use Mcp\Server\NotificationSender; +use Mcp\Server\ClientGateway; +use Mcp\Server\Protocol; +use Mcp\Server\Session\SessionInterface; use Psr\Log\AbstractLogger; -use Psr\Log\LoggerInterface; /** * MCP-aware PSR-3 logger that sends log messages as MCP notifications. * - * This logger implements the standard PSR-3 LoggerInterface and forwards - * log messages to the NotificationSender. The NotificationHandler will - * decide whether to actually send the notification based on capabilities. - * * @author Adam Jamiu + * @author Tobias Nyholm */ final class ClientLogger extends AbstractLogger { public function __construct( - private readonly NotificationSender $notificationSender, - private readonly ?LoggerInterface $fallbackLogger = null, + private ClientGateway $client, + private SessionInterface $session, ) { } @@ -41,30 +39,20 @@ public function __construct( */ public function log($level, $message, array $context = []): void { - // Always log to fallback logger if provided (for local debugging) - $this->fallbackLogger?->log($level, $message, $context); - // Convert PSR-3 level to MCP LoggingLevel $mcpLevel = $this->convertToMcpLevel($level); if (null === $mcpLevel) { return; // Unknown level, skip MCP notification } - // Send MCP logging notification - let NotificationHandler decide if it should be sent - try { - $this->notificationSender->send('notifications/message', [ - 'level' => $mcpLevel->value, - 'data' => (string) $message, - 'logger' => $context['logger'] ?? null, - ]); - } catch (\Throwable $e) { - // If MCP notification fails, at least log to fallback - $this->fallbackLogger?->error('Failed to send MCP log notification', [ - 'original_level' => $level, - 'original_message' => $message, - 'error' => $e->getMessage(), - ]); + $minimumLevel = $this->session->get(Protocol::SESSION_LOGGING_LEVEL, ''); + $minimumLevel = LoggingLevel::tryFrom($minimumLevel) ?? LoggingLevel::Warning; + + if ($this->getSeverityIndex($minimumLevel) > $this->getSeverityIndex($mcpLevel)) { + return; } + + $this->client->log($mcpLevel, $message); } /** @@ -88,4 +76,24 @@ private function convertToMcpLevel($level): ?LoggingLevel 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, + }; + } } diff --git a/src/Capability/Registry.php b/src/Capability/Registry.php index 27d54a46..d0813412 100644 --- a/src/Capability/Registry.php +++ b/src/Capability/Registry.php @@ -25,7 +25,6 @@ use Mcp\Exception\PromptNotFoundException; use Mcp\Exception\ResourceNotFoundException; use Mcp\Exception\ToolNotFoundException; -use Mcp\Schema\Enum\LoggingLevel; use Mcp\Schema\Page; use Mcp\Schema\Prompt; use Mcp\Schema\Resource; @@ -62,10 +61,6 @@ final class Registry implements RegistryInterface */ private array $resourceTemplates = []; - private bool $logging = true; - - private LoggingLevel $loggingLevel = LoggingLevel::Warning; - public function __construct( private readonly ?EventDispatcherInterface $eventDispatcher = null, private readonly LoggerInterface $logger = new NullLogger(), @@ -396,46 +391,6 @@ public function setDiscoveryState(DiscoveryState $state): void } } - - /** - * Disable logging message notifications for this registry. - */ - public function disableLogging(): void - { - $this->logging = false; - } - - /** - * Checks if logging message notification capability is enabled. - * - * @return bool True if logging capability is enabled, false otherwise - */ - public function isLoggingEnabled(): bool - { - return $this->logging; - } - - /** - * Sets the current logging message notification level for the client. - * - * This determines which log messages should be sent to the client. - * Only messages at this level and higher (more severe) will be sent. - */ - public function setLoggingLevel(LoggingLevel $level): void - { - $this->loggingLevel = $level; - } - - /** - * Gets the current logging message notification level set by the client. - * - * @return LoggingLevel The current log level - */ - public function getLoggingLevel(): LoggingLevel - { - return $this->loggingLevel; - } - /** * Calculate next cursor for pagination. * diff --git a/src/Capability/Registry/ReferenceHandler.php b/src/Capability/Registry/ReferenceHandler.php index 7ce8c737..b0170630 100644 --- a/src/Capability/Registry/ReferenceHandler.php +++ b/src/Capability/Registry/ReferenceHandler.php @@ -15,6 +15,7 @@ use Mcp\Exception\RegistryException; use Mcp\Server\ClientAwareInterface; use Mcp\Server\ClientGateway; +use Mcp\Server\RequestContext; use Mcp\Server\Session\SessionInterface; use Psr\Container\ContainerInterface; @@ -109,9 +110,15 @@ private function prepareArguments(\ReflectionFunctionAbstract $reflection, array $typeName = $type->getName(); if (ClientGateway::class === $typeName && isset($arguments['_session'])) { + // Deprecated, use RequestContext instead $finalArgs[$paramPosition] = new ClientGateway($arguments['_session']); continue; } + + if (RequestContext::class === $typeName && isset($arguments['_session'], $arguments['_request'])) { + $finalArgs[$paramPosition] = new RequestContext($arguments['_session'], $arguments['_request']); + continue; + } } if (isset($arguments[$paramName])) { diff --git a/src/Schema/Enum/LoggingLevel.php b/src/Schema/Enum/LoggingLevel.php index 6da56ee0..e9aecef8 100644 --- a/src/Schema/Enum/LoggingLevel.php +++ b/src/Schema/Enum/LoggingLevel.php @@ -18,7 +18,6 @@ * https://datatracker.ietf.org/doc/html/rfc5424#section-6.2.1 * * @author Kyrian Obikwelu - * @author Adam Jamiu */ enum LoggingLevel: string { @@ -30,24 +29,4 @@ enum LoggingLevel: string case Critical = 'critical'; case Alert = 'alert'; case Emergency = 'emergency'; - - /** - * 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) - */ - public function getSeverityIndex(): 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, - }; - } } diff --git a/src/Schema/Request/SetLogLevelRequest.php b/src/Schema/Request/SetLogLevelRequest.php index ad7bee68..eb83e1c8 100644 --- a/src/Schema/Request/SetLogLevelRequest.php +++ b/src/Schema/Request/SetLogLevelRequest.php @@ -39,7 +39,7 @@ public static function getMethod(): string protected static function fromParams(?array $params): static { - if (!isset($params['level']) || !\is_string($params['level']) || empty($params['level'])) { + if (!isset($params['level']) || !\is_string($params['level']) || '' === $params['level']) { throw new InvalidArgumentException('Missing or invalid "level" parameter for "logging/setLevel".'); } diff --git a/src/Server.php b/src/Server.php index d154badb..8657610a 100644 --- a/src/Server.php +++ b/src/Server.php @@ -12,7 +12,6 @@ namespace Mcp; use Mcp\Server\Builder; -use Mcp\Server\NotificationSender; use Mcp\Server\Protocol; use Mcp\Server\Transport\TransportInterface; use Psr\Log\LoggerInterface; @@ -26,7 +25,6 @@ final class Server { public function __construct( private readonly Protocol $protocol, - private readonly NotificationSender $notificationSender, private readonly LoggerInterface $logger = new NullLogger(), ) { } @@ -48,12 +46,6 @@ public function run(TransportInterface $transport): mixed $transport->initialize(); $this->protocol->connect($transport); - $this->logger->info('Transport initialized.', [ - 'transport' => $transport::class, - ]); - - // Configure the NotificationSender with the transport - $this->notificationSender->setTransport($transport); $this->logger->info('Running server...'); diff --git a/src/Server/Builder.php b/src/Server/Builder.php index 6503300a..053c4f43 100644 --- a/src/Server/Builder.php +++ b/src/Server/Builder.php @@ -28,9 +28,7 @@ use Mcp\Schema\ToolAnnotations; use Mcp\Server; use Mcp\Server\Handler\Notification\NotificationHandlerInterface; -use Mcp\Server\Handler\NotificationHandler; use Mcp\Server\Handler\Request\RequestHandlerInterface; -use Mcp\Server\Handler\Request\SetLogLevelHandler; use Mcp\Server\Session\InMemorySessionStore; use Mcp\Server\Session\SessionFactory; use Mcp\Server\Session\SessionFactoryInterface; @@ -82,8 +80,6 @@ final class Builder */ private array $notificationHandlers = []; - private bool $logging = true; - /** * @var array{ * handler: Handler, @@ -262,16 +258,6 @@ public function setRegistry(RegistryInterface $registry): self return $this; } - /** - * Disables Client logging capability for the server. - */ - public function disableClientLogging(): self - { - $this->logging = false; - - return $this; - } - /** * Provides a PSR-3 logger instance. Defaults to NullLogger. */ @@ -466,10 +452,6 @@ public function build(): Server $container = $this->container ?? new Container(); $registry = $this->registry ?? new Registry($this->eventDispatcher, $logger); - if (!$this->logging) { - $registry->disableLogging(); - } - $loaders = [ ...$this->loaders, new ArrayLoader($this->tools, $this->resources, $this->resourceTemplates, $this->prompts, $logger), @@ -496,18 +478,10 @@ public function build(): Server resourcesListChanged: $this->eventDispatcher instanceof EventDispatcherInterface, prompts: $registry->hasPrompts(), promptsListChanged: $this->eventDispatcher instanceof EventDispatcherInterface, - logging: false, + logging: true, completions: true, ); - // Create notification infrastructure first - $notificationHandler = NotificationHandler::make($registry, $logger); - $notificationSender = new NotificationSender($notificationHandler, null, $logger); - - // Create ClientLogger for components that should send logs via MCP - $clientLogger = new ClientLogger($notificationSender, $logger); - $referenceHandler = new ReferenceHandler($container, $clientLogger); - $serverInfo = $this->serverInfo ?? new Implementation(); $configuration = new Configuration($serverInfo, $capabilities, $this->paginationLimit, $this->instructions, $this->protocolVersion); $referenceHandler = new ReferenceHandler($container); @@ -523,7 +497,7 @@ public function build(): Server new Handler\Request\ListToolsHandler($registry, $this->paginationLimit), new Handler\Request\PingHandler(), new Handler\Request\ReadResourceHandler($registry, $referenceHandler, $logger), - new SetLogLevelHandler($registry, $logger), + new Handler\Request\SetLogLevelHandler($logger), ]); $notificationHandlers = array_merge($this->notificationHandlers, [ @@ -539,6 +513,6 @@ public function build(): Server logger: $logger, ); - return new Server($protocol, $notificationSender, $logger); + return new Server($protocol, $logger); } } diff --git a/src/Server/ClientAwareInterface.php b/src/Server/ClientAwareInterface.php index 86c8c2ef..d5674e55 100644 --- a/src/Server/ClientAwareInterface.php +++ b/src/Server/ClientAwareInterface.php @@ -11,6 +11,9 @@ namespace Mcp\Server; +/** + * @deprecated We dont need this. Use RequestContext as argument injection instead. + */ interface ClientAwareInterface { public function setClient(ClientGateway $clientGateway): void; diff --git a/src/Server/ClientGateway.php b/src/Server/ClientGateway.php index 8179aee2..e044f055 100644 --- a/src/Server/ClientGateway.php +++ b/src/Server/ClientGateway.php @@ -34,6 +34,7 @@ use Mcp\Server\Session\SessionInterface; /** + * @final * Helper class for tools to communicate with the client. * * This class provides a clean API for element handlers to send requests and notifications @@ -64,7 +65,7 @@ * * @author Kyrian Obikwelu */ -final class ClientGateway +class ClientGateway { public function __construct( private readonly SessionInterface $session, diff --git a/src/Server/Handler/Notification/InitializedHandler.php b/src/Server/Handler/Notification/InitializedHandler.php index 08dec76a..2dccc896 100644 --- a/src/Server/Handler/Notification/InitializedHandler.php +++ b/src/Server/Handler/Notification/InitializedHandler.php @@ -25,9 +25,9 @@ public function supports(Notification $notification): bool return $notification instanceof InitializedNotification; } - public function handle(Notification $message, SessionInterface $session): void + public function handle(Notification $notification, SessionInterface $session): void { - \assert($message instanceof InitializedNotification); + \assert($notification instanceof InitializedNotification); $session->set('initialized', true); } diff --git a/src/Server/Handler/Notification/LoggingMessageNotificationHandler.php b/src/Server/Handler/Notification/LoggingMessageNotificationHandler.php deleted file mode 100644 index 58318b2b..00000000 --- a/src/Server/Handler/Notification/LoggingMessageNotificationHandler.php +++ /dev/null @@ -1,109 +0,0 @@ - - */ -final class LoggingMessageNotificationHandler implements NotificationHandlerInterface -{ - public function __construct( - private readonly ReferenceRegistryInterface $registry, - private readonly LoggerInterface $logger, - ) { - } - - public function handle(string $method, array $params): Notification - { - if (!$this->supports($method)) { - throw new InvalidArgumentException("Handler does not support method: {$method}"); - } - - $this->validateRequiredParameter($params); - - $level = $this->getLoggingLevel($params); - - if (!$this->registry->isLoggingEnabled()) { - $this->logger->debug('Logging is disabled, skipping log message'); - throw new InvalidArgumentException('Logging capability is not enabled'); - } - - $this->validateLogLevelThreshold($level); - - return new LoggingMessageNotification( - level: $level, - data: $params['data'], - logger: $params['logger'] ?? null - ); - } - - private function supports(string $method): bool - { - return $method === LoggingMessageNotification::getMethod(); - } - - /** - * @param array $params - */ - private function validateRequiredParameter(array $params): void - { - if (!isset($params['level'])) { - throw new InvalidArgumentException('Missing required parameter "level" for logging notification'); - } - - if (!isset($params['data'])) { - throw new InvalidArgumentException('Missing required parameter "data" for logging notification'); - } - } - - /** - * @param array $params - */ - private function getLoggingLevel(array $params): LoggingLevel - { - return $params['level'] instanceof LoggingLevel - ? $params['level'] - : LoggingLevel::from($params['level']); - } - - private function validateLogLevelThreshold(LoggingLevel $level): void - { - $currentLogLevel = $this->registry->getLoggingLevel(); - - if ($this->shouldSendLogLevel($level, $currentLogLevel)) { - return; - } - - $this->logger->debug( - "Log level {$level->value} is below current threshold {$currentLogLevel->value}, skipping" - ); - throw new InvalidArgumentException('Log level is below current threshold'); - } - - private function shouldSendLogLevel(LoggingLevel $messageLevel, LoggingLevel $currentLevel): bool - { - return $messageLevel->getSeverityIndex() >= $currentLevel->getSeverityIndex(); - } -} diff --git a/src/Server/Handler/NotificationHandler.php b/src/Server/Handler/NotificationHandler.php deleted file mode 100644 index 94a2996d..00000000 --- a/src/Server/Handler/NotificationHandler.php +++ /dev/null @@ -1,154 +0,0 @@ - - */ -final class NotificationHandler -{ - /** - * @var array - */ - private readonly array $handlers; - - /** - * @param array $handlers Method-to-handler mapping - */ - public function __construct( - array $handlers, - private readonly LoggerInterface $logger = new NullLogger(), - ) { - $this->handlers = $handlers; - } - - /** - * Creates a NotificationHandler with default handlers. - */ - public static function make( - ReferenceRegistryInterface $registry, - LoggerInterface $logger = new NullLogger(), - ): self { - return new self( - handlers: [ - LoggingMessageNotification::getMethod() => new LoggingMessageNotificationHandler($registry, $logger), - ], - logger: $logger, - ); - } - - /** - * Processes a notification creation request. - * - * @param string $method The notification method - * @param array $params Parameters for the notification - * - * @return string|null The serialized JSON notification, or null on failure - * - * @throws HandlerNotFoundException When no handler supports the method - */ - public function process(string $method, array $params): ?string - { - $context = ['method' => $method, 'params' => $params]; - $this->logger->debug("Processing notification for method: {$method}", $context); - - $handler = $this->getHandlerFor($method); - - return $this->createAndEncodeNotification($handler, $method, $params); - } - - /** - * Gets the handler for a specific method. - * - * @throws HandlerNotFoundException When no handler supports the method - */ - private function getHandlerFor(string $method): NotificationHandlerInterface - { - $handler = $this->handlers[$method] ?? null; - - if (!$handler) { - throw new HandlerNotFoundException("No notification handler found for method: {$method}"); - } - - return $handler; - } - - /** - * Creates notification using handler and encodes it to JSON. - * - * @param array $params - */ - private function createAndEncodeNotification( - NotificationHandlerInterface $handler, - string $method, - array $params, - ): ?string { - try { - $notification = $handler->handle($method, $params); - - $this->logger->debug('Notification created successfully', [ - 'method' => $method, - 'handler' => $handler::class, - 'notification_class' => $notification::class, - ]); - - return $this->encodeNotification($notification); - } catch (\Throwable $e) { - $this->logger->error('Failed to create notification', [ - 'method' => $method, - 'handler' => $handler::class, - 'error' => $e->getMessage(), - 'exception' => $e, - ]); - - return null; - } - } - - /** - * Encodes a notification to JSON, handling encoding errors gracefully. - */ - private function encodeNotification(Notification $notification): ?string - { - $method = $notification->getMethod(); - - $this->logger->debug('Encoding notification', [ - 'method' => $method, - 'notification_class' => $notification::class, - ]); - - try { - return json_encode($notification, \JSON_THROW_ON_ERROR); - } catch (\JsonException $e) { - $this->logger->error('JSON encoding failed for notification', [ - 'method' => $method, - 'error' => $e->getMessage(), - 'exception' => $e, - ]); - - return null; - } - } -} diff --git a/src/Server/Handler/NotificationHandlerInterface.php b/src/Server/Handler/NotificationHandlerInterface.php deleted file mode 100644 index f09667d1..00000000 --- a/src/Server/Handler/NotificationHandlerInterface.php +++ /dev/null @@ -1,35 +0,0 @@ - - */ -interface NotificationHandlerInterface -{ - /** - * Creates a notification instance from the given parameters. - * - * @param string $method The notification method - * @param array $params Parameters for the notification - * - * @return Notification The created notification instance - */ - public function handle(string $method, array $params): Notification; -} diff --git a/src/Server/Handler/Request/CallToolHandler.php b/src/Server/Handler/Request/CallToolHandler.php index e0430802..dd159afc 100644 --- a/src/Server/Handler/Request/CallToolHandler.php +++ b/src/Server/Handler/Request/CallToolHandler.php @@ -61,6 +61,7 @@ public function handle(Request $request, SessionInterface $session): Response|Er $reference = $this->registry->getTool($toolName); $arguments['_session'] = $session; + $arguments['_request'] = $request; $result = $this->referenceHandler->handle($reference, $arguments); diff --git a/src/Server/Handler/Request/GetPromptHandler.php b/src/Server/Handler/Request/GetPromptHandler.php index 274b8422..eb968a12 100644 --- a/src/Server/Handler/Request/GetPromptHandler.php +++ b/src/Server/Handler/Request/GetPromptHandler.php @@ -57,6 +57,7 @@ public function handle(Request $request, SessionInterface $session): Response|Er $reference = $this->registry->getPrompt($promptName); $arguments['_session'] = $session; + $arguments['_request'] = $request; $result = $this->referenceHandler->handle($reference, $arguments); diff --git a/src/Server/Handler/Request/ReadResourceHandler.php b/src/Server/Handler/Request/ReadResourceHandler.php index f955f4b1..105ef483 100644 --- a/src/Server/Handler/Request/ReadResourceHandler.php +++ b/src/Server/Handler/Request/ReadResourceHandler.php @@ -61,6 +61,7 @@ public function handle(Request $request, SessionInterface $session): Response|Er $arguments = [ 'uri' => $uri, '_session' => $session, + '_request' => $request, ]; if ($reference instanceof ResourceTemplateReference) { diff --git a/src/Server/Handler/Request/SetLogLevelHandler.php b/src/Server/Handler/Request/SetLogLevelHandler.php index 6e771945..73325620 100644 --- a/src/Server/Handler/Request/SetLogLevelHandler.php +++ b/src/Server/Handler/Request/SetLogLevelHandler.php @@ -11,13 +11,12 @@ namespace Mcp\Server\Handler\Request; -use Mcp\Capability\Registry\ReferenceRegistryInterface; use Mcp\Schema\JsonRpc\Request; use Mcp\Schema\JsonRpc\Response; use Mcp\Schema\Request\SetLogLevelRequest; use Mcp\Schema\Result\EmptyResult; +use Mcp\Server\Protocol; use Mcp\Server\Session\SessionInterface; -use Psr\Log\LoggerInterface; /** * Handler for the logging/setLevel request. @@ -29,26 +28,17 @@ */ final class SetLogLevelHandler implements RequestHandlerInterface { - public function __construct( - private readonly ReferenceRegistryInterface $registry, - private readonly LoggerInterface $logger, - ) { - } - - public function supports(Request $message): bool + public function supports(Request $request): bool { - return $message instanceof SetLogLevelRequest; + return $request instanceof SetLogLevelRequest; } - public function handle(Request $message, SessionInterface $session): Response + public function handle(Request $request, SessionInterface $session): Response { - \assert($message instanceof SetLogLevelRequest); - - // Update the log level in the registry via the interface - $this->registry->setLoggingLevel($message->level); + \assert($request instanceof SetLogLevelRequest); - $this->logger->debug("Log level set to: {$message->level->value}"); + $session->set(Protocol::SESSION_LOGGING_LEVEL, $request->level->value); - return new Response($message->getId(), new EmptyResult()); + return new Response($request->getId(), new EmptyResult()); } } diff --git a/src/Server/NotificationSender.php b/src/Server/NotificationSender.php deleted file mode 100644 index ca63585f..00000000 --- a/src/Server/NotificationSender.php +++ /dev/null @@ -1,100 +0,0 @@ - - */ -final class NotificationSender -{ - /** - * @param TransportInterface|null $transport - */ - public function __construct( - private readonly NotificationHandler $notificationHandler, - private ?TransportInterface $transport = null, - private readonly LoggerInterface $logger = new NullLogger(), - ) { - } - - /** - * Sets the transport interface for sending notifications. - * - * @param TransportInterface $transport - */ - public function setTransport(TransportInterface $transport): void - { - $this->transport = $transport; - } - - /** - * Sends a notification to the client. - * - * @param string $method The notification method - * @param array $params Parameters for the notification - * - * @throws RuntimeException If no transport is available - */ - public function send(string $method, array $params): void - { - $this->ensureTransportAvailable(); - - try { - $encodedNotification = $this->notificationHandler->process($method, $params); - - if (null !== $encodedNotification) { - $this->transport->send($encodedNotification, []); - $this->logger->debug('Notification sent successfully', [ - 'method' => $method, - 'transport' => $this->transport::class, - ]); - } else { - $this->logger->warning('Failed to create notification', [ - 'method' => $method, - ]); - } - } catch (\Throwable $e) { - $this->logger->error('Failed to send notification', [ - 'method' => $method, - 'error' => $e->getMessage(), - 'exception' => $e, - ]); - - // Re-throw as RuntimeException to maintain API contract - throw new RuntimeException("Failed to send notification: {$e->getMessage()}", 0, $e); - } - } - - /** - * Ensures transport is available before attempting operations. - * - * @throws RuntimeException If no transport is available - */ - private function ensureTransportAvailable(): void - { - if (null === $this->transport) { - throw new RuntimeException('No transport configured for notification sending'); - } - } -} diff --git a/src/Server/Protocol.php b/src/Server/Protocol.php index 0851e922..5c2d1f0c 100644 --- a/src/Server/Protocol.php +++ b/src/Server/Protocol.php @@ -55,6 +55,8 @@ class Protocol /** Session key for active request meta */ public const SESSION_ACTIVE_REQUEST_META = '_mcp.active_request_meta'; + public const SESSION_LOGGING_LEVEL = '_mcp.logging_level'; + /** * @param array>> $requestHandlers * @param array $notificationHandlers diff --git a/src/Server/RequestContext.php b/src/Server/RequestContext.php new file mode 100644 index 00000000..158057cf --- /dev/null +++ b/src/Server/RequestContext.php @@ -0,0 +1,64 @@ + + */ +final class RequestContext +{ + private ?ClientGateway $clientGateway = null; + private ?ClientLogger $clientLogger = null; + + public function __construct( + private readonly SessionInterface $session, + private readonly Request $request, + ) { + } + + public function getRequest(): Request + { + return $this->request; + } + + public function getSession(): SessionInterface + { + return $this->session; + } + + public function getClientGateway(): ClientGateway + { + if (null == $this->clientGateway) { + $this->clientGateway = new ClientGateway($this->session); + } + + return $this->clientGateway; + } + + public function getClientLogger(): ClientLogger + { + if (null === $this->clientLogger) { + $this->clientLogger = new ClientLogger($this->getClientGateway(), $this->session); + } + + return $this->clientLogger; + } +} diff --git a/tests/Unit/Capability/Discovery/SchemaGeneratorFixture.php b/tests/Unit/Capability/Discovery/SchemaGeneratorFixture.php index 9670dcab..5a7fcaeb 100644 --- a/tests/Unit/Capability/Discovery/SchemaGeneratorFixture.php +++ b/tests/Unit/Capability/Discovery/SchemaGeneratorFixture.php @@ -412,15 +412,4 @@ public function parameterSchemaInferredType( $inferredParam, ): void { } - - /** - * Method with ClientLogger that should be excluded from schema. - * - * @param string $message The message to process - * @param \Mcp\Capability\Logger\ClientLogger $logger Auto-injected logger - */ - public function withClientLogger(string $message, \Mcp\Capability\Logger\ClientLogger $logger): string - { - return $message; - } } diff --git a/tests/Unit/Capability/Discovery/SchemaGeneratorTest.php b/tests/Unit/Capability/Discovery/SchemaGeneratorTest.php index 31f10a3a..4cbfce52 100644 --- a/tests/Unit/Capability/Discovery/SchemaGeneratorTest.php +++ b/tests/Unit/Capability/Discovery/SchemaGeneratorTest.php @@ -327,20 +327,4 @@ public function testInfersParameterTypeAsAnyIfOnlyConstraintsAreGiven() $this->assertEquals(['description' => 'Some parameter', 'minLength' => 3], $schema['properties']['inferredParam']); $this->assertEquals(['inferredParam'], $schema['required']); } - - public function testExcludesClientLoggerFromSchema() - { - $method = new \ReflectionMethod(SchemaGeneratorFixture::class, 'withClientLogger'); - $schema = $this->schemaGenerator->generate($method); - - // Should include the message parameter - $this->assertArrayHasKey('message', $schema['properties']); - $this->assertEquals(['type' => 'string', 'description' => 'The message to process'], $schema['properties']['message']); - - // Should NOT include the logger parameter - $this->assertArrayNotHasKey('logger', $schema['properties']); - - // Required array should only contain client parameters - $this->assertEquals(['message'], $schema['required']); - } } diff --git a/tests/Unit/Capability/Logger/ClientLoggerTest.php b/tests/Unit/Capability/Logger/ClientLoggerTest.php index 93affff9..aa0dc486 100644 --- a/tests/Unit/Capability/Logger/ClientLoggerTest.php +++ b/tests/Unit/Capability/Logger/ClientLoggerTest.php @@ -9,92 +9,84 @@ * file that was distributed with this source code. */ -namespace Tests\Unit\Capability\Logger; +namespace Mcp\Tests\Unit\Capability\Logger; use Mcp\Capability\Logger\ClientLogger; -use Mcp\Capability\Registry\ReferenceRegistryInterface; -use Mcp\Server\Handler\NotificationHandler; -use Mcp\Server\NotificationSender; -use PHPUnit\Framework\MockObject\MockObject; +use Mcp\Schema\Enum\LoggingLevel; +use Mcp\Server\ClientGateway; +use Mcp\Server\Session\Session; use PHPUnit\Framework\TestCase; -use Psr\Log\LoggerInterface; /** * Test for simplified ClientLogger PSR-3 compliance. - * - * @author Adam Jamiu */ final class ClientLoggerTest extends TestCase { - private LoggerInterface&MockObject $fallbackLogger; - - protected function setUp(): void - { - $this->fallbackLogger = $this->createMock(LoggerInterface::class); - } - - public function testImplementsPsr3LoggerInterface(): void - { - $logger = $this->createClientLogger(); - $this->assertInstanceOf(LoggerInterface::class, $logger); - } - - public function testAlwaysLogsToFallbackLogger(): void + public function testLog() { - $this->fallbackLogger - ->expects($this->once()) - ->method('log') - ->with('info', 'Test message', ['key' => 'value']); - - $logger = $this->createClientLogger(); - $logger->info('Test message', ['key' => 'value']); + $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->notice('test'); } - public function testBasicLoggingMethodsWork(): void + public function testLogFilter() { - $logger = $this->createClientLogger(); - - // Test all PSR-3 methods exist and can be called - $this->fallbackLogger->expects($this->exactly(8))->method('log'); - - $logger->emergency('emergency'); - $logger->alert('alert'); - $logger->critical('critical'); - $logger->error('error'); - $logger->warning('warning'); - $logger->notice('notice'); - $logger->info('info'); - $logger->debug('debug'); + $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 testHandlesMcpSendGracefully(): void + public function testLogFilterSameLevel() { - // Expect fallback logger to be called for original message - $this->fallbackLogger - ->expects($this->once()) - ->method('log') - ->with('info', 'Test message', []); - - // May also get error log if MCP send fails (which it likely will without transport) - $this->fallbackLogger - ->expects($this->atMost(1)) - ->method('error'); - - $logger = $this->createClientLogger(); - $logger->info('Test message'); + $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'); } - private function createClientLogger(): ClientLogger + public function testLogWithInvalidLevel() { - // Create minimal working NotificationSender for testing - // Using a minimal ReferenceRegistryInterface mock just to construct NotificationHandler - $registry = $this->createMock(ReferenceRegistryInterface::class); - $notificationHandler = NotificationHandler::make($registry); - $notificationSender = new NotificationSender($notificationHandler, null); - - return new ClientLogger( - $notificationSender, - $this->fallbackLogger - ); + $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->log('foo', 'test'); } } diff --git a/tests/Unit/Capability/RegistryLoggingTest.php b/tests/Unit/Capability/RegistryLoggingTest.php deleted file mode 100644 index f19e28f6..00000000 --- a/tests/Unit/Capability/RegistryLoggingTest.php +++ /dev/null @@ -1,148 +0,0 @@ - - */ -class RegistryLoggingTest extends TestCase -{ - private Registry $registry; - private LoggerInterface&MockObject $logger; - - protected function setUp(): void - { - $this->logger = $this->createMock(LoggerInterface::class); - $this->registry = new Registry(null, $this->logger); - } - - public function testLoggingDEnabledByDefault(): void - { - $this->assertTrue($this->registry->isLoggingEnabled()); - } - - public function testLoggingStateEnablement(): void - { - // Logging starts disabled - $this->assertTrue($this->registry->isLoggingEnabled()); - - // Test enabling logging - $this->registry->disableLogging(); - $this->assertFalse($this->registry->isLoggingEnabled()); - - // Enabling again should have no effect - $this->registry->disableLogging(); - $this->assertFalse($this->registry->isLoggingEnabled()); - } - - public function testGetLogLevelReturnsWarningWhenNotSet(): void - { - $this->assertEquals(LoggingLevel::Warning->value, $this->registry->getLoggingLevel()->value); - } - - public function testLogLevelManagement(): void - { - // Initially should be null - $this->assertEquals(LoggingLevel::Warning->value, $this->registry->getLoggingLevel()->value); - - // Test setting and getting each log level - $levels = [ - LoggingLevel::Debug, - LoggingLevel::Info, - LoggingLevel::Notice, - LoggingLevel::Warning, - LoggingLevel::Error, - LoggingLevel::Critical, - LoggingLevel::Alert, - LoggingLevel::Emergency, - ]; - - foreach ($levels as $level) { - $this->registry->setLoggingLevel($level); - $this->assertEquals($level, $this->registry->getLoggingLevel()); - - // Verify enum properties are preserved - $retrievedLevel = $this->registry->getLoggingLevel(); - $this->assertEquals($level->value, $retrievedLevel->value); - $this->assertEquals($level->getSeverityIndex(), $retrievedLevel->getSeverityIndex()); - } - - // Final state should be the last level - $this->assertEquals(LoggingLevel::Emergency, $this->registry->getLoggingLevel()); - } - - public function testLoggingCapabilities(): void - { - // Test capabilities with logging disabled (default state) - $this->logger - ->expects($this->exactly(3)) - ->method('info') - ->with('No capabilities registered on server.'); - - $capabilities = $this->registry->getCapabilities(); - $this->assertInstanceOf(ServerCapabilities::class, $capabilities); - $this->assertTrue($capabilities->logging); - - // Enable logging and test capabilities - $this->registry->disableLogging(); - $capabilities = $this->registry->getCapabilities(); - $this->assertFalse($capabilities->logging); - - // Test with event dispatcher - /** @var EventDispatcherInterface&MockObject $eventDispatcher */ - $eventDispatcher = $this->createMock(EventDispatcherInterface::class); - $registryWithDispatcher = new Registry($eventDispatcher, $this->logger); - $registryWithDispatcher->disableLogging(); - - $capabilities = $registryWithDispatcher->getCapabilities(); - $this->assertFalse($capabilities->logging); - $this->assertTrue($capabilities->toolsListChanged); - $this->assertTrue($capabilities->resourcesListChanged); - $this->assertTrue($capabilities->promptsListChanged); - } - - public function testLoggingStateIndependentOfLevel(): void - { - // Logging can be disabled - level should remain but logging should be disabled - $this->registry->disableLogging(); - $this->assertFalse($this->registry->isLoggingEnabled()); - $this->assertEquals(LoggingLevel::Warning, $this->registry->getLoggingLevel()); // Default level - - // Level can be set after disabling logging - $this->registry->setLoggingLevel(LoggingLevel::Info); - $this->assertFalse($this->registry->isLoggingEnabled()); - $this->assertEquals(LoggingLevel::Info, $this->registry->getLoggingLevel()); - - // Level can be set on a new registry without disabling logging - $newRegistry = new Registry(null, $this->logger); - $newRegistry->setLoggingLevel(LoggingLevel::Info); - $this->assertTrue($newRegistry->isLoggingEnabled()); - $this->assertEquals(LoggingLevel::Info, $newRegistry->getLoggingLevel()); - - // Test persistence: Set level then disable logging - level should persist - $persistRegistry = new Registry(null, $this->logger); - $persistRegistry->setLoggingLevel(LoggingLevel::Critical); - $this->assertEquals(LoggingLevel::Critical, $persistRegistry->getLoggingLevel()); - - $persistRegistry->disableLogging(); - $this->assertFalse($persistRegistry->isLoggingEnabled()); - $this->assertEquals(LoggingLevel::Critical, $persistRegistry->getLoggingLevel()); - } -} diff --git a/tests/Unit/Schema/Enum/LoggingLevelTest.php b/tests/Unit/Schema/Enum/LoggingLevelTest.php deleted file mode 100644 index 877d38ac..00000000 --- a/tests/Unit/Schema/Enum/LoggingLevelTest.php +++ /dev/null @@ -1,101 +0,0 @@ - - */ -class LoggingLevelTest extends TestCase -{ - public function testEnumValuesAndSeverityIndexes(): void - { - $expectedLevelsWithIndexes = [ - ['level' => LoggingLevel::Debug, 'value' => 'debug', 'index' => 0], - ['level' => LoggingLevel::Info, 'value' => 'info', 'index' => 1], - ['level' => LoggingLevel::Notice, 'value' => 'notice', 'index' => 2], - ['level' => LoggingLevel::Warning, 'value' => 'warning', 'index' => 3], - ['level' => LoggingLevel::Error, 'value' => 'error', 'index' => 4], - ['level' => LoggingLevel::Critical, 'value' => 'critical', 'index' => 5], - ['level' => LoggingLevel::Alert, 'value' => 'alert', 'index' => 6], - ['level' => LoggingLevel::Emergency, 'value' => 'emergency', 'index' => 7], - ]; - - foreach ($expectedLevelsWithIndexes as $data) { - $level = $data['level']; - - // Test enum value - $this->assertEquals($data['value'], $level->value); - - // Test severity index - $this->assertEquals($data['index'], $level->getSeverityIndex()); - - // Test severity index consistency (multiple calls return same result) - $this->assertEquals($data['index'], $level->getSeverityIndex()); - - // Test from string conversion - $fromString = LoggingLevel::from($data['value']); - $this->assertEquals($level, $fromString); - $this->assertEquals($data['value'], $fromString->value); - } - - // Test severity comparisons - each level should have higher index than previous - for ($i = 1; $i < \count($expectedLevelsWithIndexes); ++$i) { - $previous = $expectedLevelsWithIndexes[$i - 1]['level']; - $current = $expectedLevelsWithIndexes[$i]['level']; - $this->assertTrue( - $previous->getSeverityIndex() < $current->getSeverityIndex(), - "Expected {$previous->value} index to be less than {$current->value} index" - ); - } - } - - public function testInvalidLogLevelHandling(): void - { - // Test invalid level string - $this->expectException(\ValueError::class); - $this->expectExceptionMessage('"invalid_level" is not a valid backing value for enum'); - LoggingLevel::from('invalid_level'); - } - - public function testCaseSensitiveLogLevels(): void - { - // Should be case sensitive - 'DEBUG' is not 'debug' - $this->expectException(\ValueError::class); - LoggingLevel::from('DEBUG'); - } - - public function testEnumUniquenessAndCoverage(): void - { - $indexes = []; - $allCases = LoggingLevel::cases(); - - foreach ($allCases as $level) { - $index = $level->getSeverityIndex(); - - // Check that this index hasn't been used before - $this->assertNotContains($index, $indexes, "Severity index {$index} is duplicated for level {$level->value}"); - - $indexes[] = $index; - } - - // Verify we have exactly 8 unique indexes - $this->assertCount(8, $indexes); - $this->assertCount(8, $allCases); - - // Verify indexes are sequential from 0 to 7 - sort($indexes); - $this->assertEquals([0, 1, 2, 3, 4, 5, 6, 7], $indexes); - } -} diff --git a/tests/Unit/Server/BuilderLoggingTest.php b/tests/Unit/Server/BuilderLoggingTest.php deleted file mode 100644 index f2a265b8..00000000 --- a/tests/Unit/Server/BuilderLoggingTest.php +++ /dev/null @@ -1,92 +0,0 @@ - - */ -class BuilderLoggingTest extends TestCase -{ - public function testLoggingEnabledByDefault(): void - { - $builder = new Builder(); - - $this->assertTrue($this->getBuilderLoggingState($builder), 'Builder should start with logging enabled'); - - $server = $builder->setServerInfo('Test Server', '1.0.0')->build(); - $this->assertInstanceOf(Server::class, $server); - } - - public function testDisableClientLoggingConfiguresBuilder(): void - { - $builder = new Builder(); - - $result = $builder->disableClientLogging(); - - // Test method chaining - $this->assertSame($builder, $result, 'disableClientLogging should return builder for chaining'); - - // Test internal state - $this->assertFalse($this->getBuilderLoggingState($builder), 'disableClientLogging should set internal flag'); - - // Test server builds successfully - $server = $builder->setServerInfo('Test Server', '1.0.0')->build(); - $this->assertInstanceOf(Server::class, $server); - } - - public function testMultipleDisableCallsAreIdempotent(): void - { - $builder = new Builder(); - - $builder->disableClientLogging() - ->disableClientLogging() - ->disableClientLogging(); - - $this->assertFalse($this->getBuilderLoggingState($builder), 'Multiple disable calls should maintain disabled state'); - } - - public function testLoggingStatePreservedAcrossBuilds(): void - { - $builder = new Builder(); - $builder->setServerInfo('Test Server', '1.0.0')->disableClientLogging(); - - $server1 = $builder->build(); - $server2 = $builder->build(); - - // State should persist after building - $this->assertFalse($this->getBuilderLoggingState($builder), 'Builder state should persist after builds'); - $this->assertInstanceOf(Server::class, $server1); - $this->assertInstanceOf(Server::class, $server2); - } - - /** - * Get the internal logging state of the builder using reflection. - * This directly tests the builder's internal configuration. - */ - private function getBuilderLoggingState(Builder $builder): bool - { - $reflection = new \ReflectionClass($builder); - $property = $reflection->getProperty('logging'); - $property->setAccessible(true); - - return $property->getValue($builder); - } -} diff --git a/tests/Unit/Server/Handler/Notification/LoggingMessageNotificationHandlerTest.php b/tests/Unit/Server/Handler/Notification/LoggingMessageNotificationHandlerTest.php deleted file mode 100644 index 7d0d196c..00000000 --- a/tests/Unit/Server/Handler/Notification/LoggingMessageNotificationHandlerTest.php +++ /dev/null @@ -1,203 +0,0 @@ - - */ -class LoggingMessageNotificationHandlerTest extends TestCase -{ - private LoggingMessageNotificationHandler $handler; - private ReferenceRegistryInterface&MockObject $registry; - private LoggerInterface&MockObject $logger; - - protected function setUp(): void - { - $this->registry = $this->createMock(ReferenceRegistryInterface::class); - $this->logger = $this->createMock(LoggerInterface::class); - - $this->handler = new LoggingMessageNotificationHandler( - $this->registry, - $this->logger - ); - } - - public function testHandleNotificationCreation(): void - { - $this->registry - ->expects($this->exactly(2)) - ->method('isLoggingEnabled') - ->willReturn(true); - - $this->registry - ->expects($this->exactly(2)) - ->method('getLoggingLevel') - ->willReturnOnConsecutiveCalls(LoggingLevel::Info, LoggingLevel::Debug); - - // Test with LoggingLevel enum - $params1 = [ - 'level' => LoggingLevel::Error, - 'data' => 'Test error message', - 'logger' => 'TestLogger', - ]; - $notification1 = $this->handler->handle(LoggingMessageNotification::getMethod(), $params1); - $this->assertInstanceOf(LoggingMessageNotification::class, $notification1); - /* @var LoggingMessageNotification $notification1 */ - $this->assertEquals(LoggingLevel::Error, $notification1->level); - $this->assertEquals($params1['data'], $notification1->data); - $this->assertEquals($params1['logger'], $notification1->logger); - - // Test with string level conversion - $params2 = [ - 'level' => 'warning', - 'data' => 'String level test', - ]; - $notification2 = $this->handler->handle(LoggingMessageNotification::getMethod(), $params2); - $this->assertInstanceOf(LoggingMessageNotification::class, $notification2); - /* @var LoggingMessageNotification $notification2 */ - $this->assertEquals(LoggingLevel::Warning, $notification2->level); - $this->assertEquals($params2['data'], $notification2->data); - $this->assertNull($notification2->logger); - } - - public function testValidationAndErrors(): void - { - // Test missing level parameter - $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage('Missing required parameter "level" for logging notification'); - $this->handler->handle(LoggingMessageNotification::getMethod(), ['data' => 'Missing level parameter']); - } - - public function testValidateRequiredParameterData(): void - { - $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage('Missing required parameter "data" for logging notification'); - $this->handler->handle(LoggingMessageNotification::getMethod(), ['level' => LoggingLevel::Info]); - } - - public function testLoggingDisabledRejectsMessages(): void - { - $this->registry - ->expects($this->once()) - ->method('isLoggingEnabled') - ->willReturn(false); - - $this->logger - ->expects($this->once()) - ->method('debug') - ->with('Logging is disabled, skipping log message'); - - $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage('Logging capability is not enabled'); - - $params = [ - 'level' => LoggingLevel::Error, - 'data' => 'This should be rejected', - ]; - - $this->handler->handle(LoggingMessageNotification::getMethod(), $params); - } - - public function testLogLevelFiltering(): void - { - // Test equal level is allowed - $this->registry - ->expects($this->exactly(3)) - ->method('isLoggingEnabled') - ->willReturn(true); - - $this->registry - ->expects($this->exactly(3)) - ->method('getLoggingLevel') - ->willReturnOnConsecutiveCalls(LoggingLevel::Warning, LoggingLevel::Warning, LoggingLevel::Error); - - // Equal level should be allowed - $params1 = ['level' => LoggingLevel::Warning, 'data' => 'Warning message at threshold']; - $notification1 = $this->handler->handle(LoggingMessageNotification::getMethod(), $params1); - $this->assertInstanceOf(LoggingMessageNotification::class, $notification1); - /* @var LoggingMessageNotification $notification1 */ - $this->assertEquals(LoggingLevel::Warning, $notification1->level); - - // Higher severity should be allowed - $params2 = ['level' => LoggingLevel::Critical, 'data' => 'Critical message above threshold']; - $notification2 = $this->handler->handle(LoggingMessageNotification::getMethod(), $params2); - $this->assertInstanceOf(LoggingMessageNotification::class, $notification2); - /* @var LoggingMessageNotification $notification2 */ - $this->assertEquals(LoggingLevel::Critical, $notification2->level); - - // Lower severity should be rejected - $this->logger - ->expects($this->once()) - ->method('debug') - ->with('Log level warning is below current threshold error, skipping'); - - $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage('Log level is below current threshold'); - - $params3 = ['level' => LoggingLevel::Warning, 'data' => 'Warning message below threshold']; - $this->handler->handle(LoggingMessageNotification::getMethod(), $params3); - } - - public function testErrorHandling(): void - { - // Test invalid log level - $this->expectException(\ValueError::class); - $this->handler->handle(LoggingMessageNotification::getMethod(), [ - 'level' => 'invalid_level', - 'data' => 'Test data', - ]); - } - - public function testUnsupportedMethodThrowsException(): void - { - $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage('Handler does not support method: unsupported/method'); - $this->handler->handle('unsupported/method', ['level' => LoggingLevel::Info, 'data' => 'Test data']); - } - - public function testNotificationSerialization(): void - { - $this->registry - ->expects($this->once()) - ->method('isLoggingEnabled') - ->willReturn(true); - - $this->registry - ->expects($this->once()) - ->method('getLoggingLevel') - ->willReturn(LoggingLevel::Debug); - - $params = [ - 'level' => LoggingLevel::Info, - 'data' => 'Serialization test', - 'logger' => 'TestLogger', - ]; - - $notification = $this->handler->handle(LoggingMessageNotification::getMethod(), $params); - $serialized = $notification->jsonSerialize(); - - $this->assertEquals('2.0', $serialized['jsonrpc']); - $this->assertEquals('notifications/message', $serialized['method']); - $this->assertEquals('info', $serialized['params']['level']); - $this->assertEquals('Serialization test', $serialized['params']['data']); - $this->assertEquals('TestLogger', $serialized['params']['logger']); - } -} diff --git a/tests/Unit/Server/Handler/Request/SetLogLevelHandlerTest.php b/tests/Unit/Server/Handler/Request/SetLogLevelHandlerTest.php index fde07398..6aca135a 100644 --- a/tests/Unit/Server/Handler/Request/SetLogLevelHandlerTest.php +++ b/tests/Unit/Server/Handler/Request/SetLogLevelHandlerTest.php @@ -11,93 +11,52 @@ namespace Mcp\Tests\Unit\Server\Handler\Request; -use Mcp\Capability\Registry\ReferenceRegistryInterface; use Mcp\Schema\Enum\LoggingLevel; use Mcp\Schema\JsonRpc\Request; -use Mcp\Schema\JsonRpc\Response; use Mcp\Schema\Request\SetLogLevelRequest; use Mcp\Schema\Result\EmptyResult; use Mcp\Server\Handler\Request\SetLogLevelHandler; -use Mcp\Server\Session\SessionInterface; -use PHPUnit\Framework\MockObject\MockObject; +use Mcp\Server\Protocol; +use Mcp\Server\Session\Session; use PHPUnit\Framework\TestCase; -use Psr\Log\LoggerInterface; /** * @author Adam Jamiu */ class SetLogLevelHandlerTest extends TestCase { - private SetLogLevelHandler $handler; - private ReferenceRegistryInterface&MockObject $registry; - private LoggerInterface&MockObject $logger; - private SessionInterface&MockObject $session; - - protected function setUp(): void - { - $this->registry = $this->createMock(ReferenceRegistryInterface::class); - $this->logger = $this->createMock(LoggerInterface::class); - $this->session = $this->createMock(SessionInterface::class); - - $this->handler = new SetLogLevelHandler( - $this->registry, - $this->logger - ); - } - - public function testSupportsSetLogLevelRequest(): void + public function testSupports(): void { $request = $this->createSetLogLevelRequest(LoggingLevel::Info); - - $this->assertTrue($this->handler->supports($request)); + $handler = new SetLogLevelHandler(); + $this->assertTrue($handler->supports($request)); } public function testDoesNotSupportOtherRequests(): void { $otherRequest = $this->createMock(Request::class); - - $this->assertFalse($this->handler->supports($otherRequest)); + $handler = new SetLogLevelHandler(); + $this->assertFalse($handler->supports($otherRequest)); } public function testHandleAllLogLevelsAndSupport(): void { - $logLevels = [ - LoggingLevel::Debug, - LoggingLevel::Info, - LoggingLevel::Notice, - LoggingLevel::Warning, - LoggingLevel::Error, - LoggingLevel::Critical, - LoggingLevel::Alert, - LoggingLevel::Emergency, - ]; + $handler = new SetLogLevelHandler(); - // Test handling all log levels - foreach ($logLevels as $level) { + foreach (LoggingLevel::cases() as $level) { $request = $this->createSetLogLevelRequest($level); - $this->registry - ->expects($this->once()) - ->method('setLoggingLevel') - ->with($level); - - $this->logger - ->expects($this->once()) - ->method('debug') - ->with("Log level set to: {$level->value}"); - - $response = $this->handler->handle($request, $this->session); + $session = $this->getMockBuilder(Session::class) + ->disableOriginalConstructor() + ->onlyMethods(['set']) + ->getMock(); + $session->expects($this->once()) + ->method('set') + ->with(Protocol::SESSION_LOGGING_LEVEL, $level->value); - $this->assertInstanceOf(Response::class, $response); + $response = $handler->handle($request, $session); $this->assertEquals($request->getId(), $response->id); $this->assertInstanceOf(EmptyResult::class, $response->result); - - // Verify EmptyResult serializes correctly - $serialized = json_encode($response->result); - $this->assertEquals('{}', $serialized); - - // Reset mocks for next iteration - $this->setUp(); } } diff --git a/tests/Unit/Server/NotificationSenderTest.php b/tests/Unit/Server/NotificationSenderTest.php deleted file mode 100644 index 2330c823..00000000 --- a/tests/Unit/Server/NotificationSenderTest.php +++ /dev/null @@ -1,187 +0,0 @@ - - */ -final class NotificationSenderTest extends TestCase -{ - private NotificationHandler $notificationHandler; - /** @var TransportInterface&MockObject */ - private TransportInterface&MockObject $transport; - private LoggerInterface&MockObject $logger; - private ReferenceRegistryInterface&MockObject $registry; - private NotificationSender $sender; - - protected function setUp(): void - { - $this->registry = $this->createMock(ReferenceRegistryInterface::class); - $this->transport = $this->createMock(TransportInterface::class); - $this->logger = $this->createMock(LoggerInterface::class); - - // Create real NotificationHandler with mocked dependencies - $this->notificationHandler = NotificationHandler::make($this->registry); - - $this->sender = new NotificationSender( - $this->notificationHandler, - null, - $this->logger - ); - } - - public function testSetTransport(): void - { - // Configure logging to be enabled - $this->registry - ->method('isLoggingEnabled') - ->willReturn(true); - - $this->registry - ->method('getLoggingLevel') - ->willReturn(LoggingLevel::Info); - - // Setting transport should not throw any exceptions - $this->sender->setTransport($this->transport); - - // Verify we can send after setting transport (integration test) - $this->transport - ->expects($this->once()) - ->method('send') - ->with($this->isType('string'), []); - - $this->sender->send('notifications/message', ['level' => 'info', 'data' => 'test']); - } - - public function testSendWithoutTransportThrowsException(): void - { - $this->expectException(RuntimeException::class); - $this->expectExceptionMessage('No transport configured for notification sending'); - - $this->sender->send('notifications/message', ['level' => 'info', 'data' => 'test']); - } - - public function testSendSuccessfulNotification(): void - { - // Configure logging to be enabled - $this->registry - ->method('isLoggingEnabled') - ->willReturn(true); - - $this->registry - ->method('getLoggingLevel') - ->willReturn(LoggingLevel::Info); - - $this->sender->setTransport($this->transport); - - // Verify that transport send is called when we have a valid setup - $this->transport - ->expects($this->once()) - ->method('send') - ->with($this->isType('string'), []); - - $this->sender->send('notifications/message', ['level' => 'info', 'data' => 'test']); - } - - public function testSendHandlerFailureGracefullyHandled(): void - { - $this->sender->setTransport($this->transport); - - // Make logging disabled so handler fails gracefully (returns null) - $this->registry - ->expects($this->once()) - ->method('isLoggingEnabled') - ->willReturn(false); - - // Transport should never be called when notification creation fails - $this->transport - ->expects($this->never()) - ->method('send'); - - // Expect a warning to be logged about failed notification creation - $this->logger - ->expects($this->once()) - ->method('warning') - ->with('Failed to create notification', ['method' => 'notifications/message']); - - // This should not throw an exception - it should fail gracefully - $this->sender->send('notifications/message', ['level' => 'info', 'data' => 'test']); - } - - public function testSendTransportExceptionThrowsRuntimeException(): void - { - $exception = new \Exception('Transport error'); - - $this->sender->setTransport($this->transport); - - // Configure successful logging - $this->registry - ->expects($this->once()) - ->method('isLoggingEnabled') - ->willReturn(true); - - $this->registry - ->expects($this->once()) - ->method('getLoggingLevel') - ->willReturn(LoggingLevel::Info); - - $this->transport - ->expects($this->once()) - ->method('send') - ->with($this->isType('string'), []) - ->willThrowException($exception); - - $this->expectException(RuntimeException::class); - $this->expectExceptionMessage('Failed to send notification: Transport error'); - - $this->sender->send('notifications/message', [ - 'level' => 'info', - 'data' => 'test message', - ]); - } - - public function testConstructorWithTransport(): void - { - // Configure logging to be enabled - $this->registry - ->method('isLoggingEnabled') - ->willReturn(true); - - $this->registry - ->method('getLoggingLevel') - ->willReturn(LoggingLevel::Info); - - $sender = new NotificationSender( - $this->notificationHandler, - $this->transport, - $this->logger - ); - - // Verify the sender can send notifications when constructed with transport - $this->transport - ->expects($this->once()) - ->method('send') - ->with($this->isType('string'), []); - - $sender->send('notifications/message', ['level' => 'info', 'data' => 'test']); - } -} diff --git a/tests/Unit/ServerTest.php b/tests/Unit/ServerTest.php index cbb0eba2..f7a8a370 100644 --- a/tests/Unit/ServerTest.php +++ b/tests/Unit/ServerTest.php @@ -11,11 +11,8 @@ namespace Mcp\Tests\Unit; -use Mcp\Capability\Registry\ReferenceRegistryInterface; use Mcp\Server; use Mcp\Server\Builder; -use Mcp\Server\Handler\NotificationHandler; -use Mcp\Server\NotificationSender; use Mcp\Server\Protocol; use Mcp\Server\Transport\TransportInterface; use PHPUnit\Framework\Attributes\TestDox; @@ -30,18 +27,10 @@ final class ServerTest extends TestCase /** @var MockObject&TransportInterface */ private $transport; - private NotificationSender $notificationSender; - protected function setUp(): void { $this->protocol = $this->createMock(Protocol::class); $this->transport = $this->createMock(TransportInterface::class); - - // Create real NotificationSender with mocked dependencies - /** @var ReferenceRegistryInterface&MockObject $registry */ - $registry = $this->createMock(ReferenceRegistryInterface::class); - $notificationHandler = NotificationHandler::make($registry); - $this->notificationSender = new NotificationSender($notificationHandler, null); } #[TestDox('builder() returns a Builder instance')] @@ -84,7 +73,7 @@ public function testRunOrchestatesTransportLifecycle(): void $callOrder[] = 'close'; }); - $server = new Server($this->protocol, $this->notificationSender); + $server = new Server($this->protocol); $result = $server->run($this->transport); $this->assertEquals([ @@ -110,7 +99,7 @@ public function testRunClosesTransportEvenOnException(): void // close() should still be called even though listen() threw $this->transport->expects($this->once())->method('close'); - $server = new Server($this->protocol, $this->notificationSender); + $server = new Server($this->protocol); $this->expectException(\RuntimeException::class); $this->expectExceptionMessage('Transport error'); @@ -125,7 +114,7 @@ public function testRunPropagatesInitializeException(): void ->method('initialize') ->willThrowException(new \RuntimeException('Initialize error')); - $server = new Server($this->protocol, $this->notificationSender); + $server = new Server($this->protocol); $this->expectException(\RuntimeException::class); $this->expectExceptionMessage('Initialize error'); @@ -145,7 +134,7 @@ public function testRunReturnsTransportListenValue(): void ->method('listen') ->willReturn($expectedReturn); - $server = new Server($this->protocol, $this->notificationSender); + $server = new Server($this->protocol); $result = $server->run($this->transport); $this->assertEquals($expectedReturn, $result); @@ -162,7 +151,7 @@ public function testRunConnectsProtocolToTransport(): void ->method('connect') ->with($this->identicalTo($this->transport)); - $server = new Server($this->protocol, $this->notificationSender); + $server = new Server($this->protocol); $server->run($this->transport); } } From 6d965138e8a21973eb66fe6b5116873e731ecfa7 Mon Sep 17 00:00:00 2001 From: Tobias Nyholm Date: Mon, 1 Dec 2025 09:24:08 +0100 Subject: [PATCH 3/5] fix tests and style --- src/Server/Builder.php | 2 +- .../Handler/Request/SetLogLevelHandler.php | 5 ++++ .../Handler/Request/CallToolHandlerTest.php | 24 +++++++++---------- .../Handler/Request/GetPromptHandlerTest.php | 18 +++++++------- .../Request/ReadResourceHandlerTest.php | 12 +++++----- 5 files changed, 33 insertions(+), 28 deletions(-) diff --git a/src/Server/Builder.php b/src/Server/Builder.php index 053c4f43..da77afbc 100644 --- a/src/Server/Builder.php +++ b/src/Server/Builder.php @@ -497,7 +497,7 @@ public function build(): Server new Handler\Request\ListToolsHandler($registry, $this->paginationLimit), new Handler\Request\PingHandler(), new Handler\Request\ReadResourceHandler($registry, $referenceHandler, $logger), - new Handler\Request\SetLogLevelHandler($logger), + new Handler\Request\SetLogLevelHandler(), ]); $notificationHandlers = array_merge($this->notificationHandlers, [ diff --git a/src/Server/Handler/Request/SetLogLevelHandler.php b/src/Server/Handler/Request/SetLogLevelHandler.php index 73325620..55638983 100644 --- a/src/Server/Handler/Request/SetLogLevelHandler.php +++ b/src/Server/Handler/Request/SetLogLevelHandler.php @@ -24,6 +24,8 @@ * Handles client requests to set the logging level for the server. * The server should send all logs at this level and higher (more severe) to the client. * + * @implements RequestHandlerInterface + * * @author Adam Jamiu */ final class SetLogLevelHandler implements RequestHandlerInterface @@ -33,6 +35,9 @@ public function supports(Request $request): bool return $request instanceof SetLogLevelRequest; } + /** + * @return Response + */ public function handle(Request $request, SessionInterface $session): Response { \assert($request instanceof SetLogLevelRequest); diff --git a/tests/Unit/Server/Handler/Request/CallToolHandlerTest.php b/tests/Unit/Server/Handler/Request/CallToolHandlerTest.php index 5b03f2bb..eb2f2ab6 100644 --- a/tests/Unit/Server/Handler/Request/CallToolHandlerTest.php +++ b/tests/Unit/Server/Handler/Request/CallToolHandlerTest.php @@ -71,7 +71,7 @@ public function testHandleSuccessfulToolCall(): void $this->referenceHandler ->expects($this->once()) ->method('handle') - ->with($toolReference, ['name' => 'John', '_session' => $this->session]) + ->with($toolReference, ['name' => 'John', '_session' => $this->session, '_request' => $request]) ->willReturn('Hello, John!'); $toolReference @@ -104,7 +104,7 @@ public function testHandleToolCallWithEmptyArguments(): void $this->referenceHandler ->expects($this->once()) ->method('handle') - ->with($toolReference, ['_session' => $this->session]) + ->with($toolReference, ['_session' => $this->session, '_request' => $request]) ->willReturn('Simple result'); $toolReference @@ -141,7 +141,7 @@ public function testHandleToolCallWithComplexArguments(): void $this->referenceHandler ->expects($this->once()) ->method('handle') - ->with($toolReference, array_merge($arguments, ['_session' => $this->session])) + ->with($toolReference, array_merge($arguments, ['_session' => $this->session, '_request' => $request])) ->willReturn('Complex result'); $toolReference @@ -192,7 +192,7 @@ public function testHandleToolCallExceptionReturnsResponseWithErrorResult(): voi $this->referenceHandler ->expects($this->once()) ->method('handle') - ->with($toolReference, ['param' => 'value', '_session' => $this->session]) + ->with($toolReference, ['param' => 'value', '_session' => $this->session, '_request' => $request]) ->willThrowException($exception); $this->logger @@ -227,7 +227,7 @@ public function testHandleWithNullResult(): void $this->referenceHandler ->expects($this->once()) ->method('handle') - ->with($toolReference, ['_session' => $this->session]) + ->with($toolReference, ['_session' => $this->session, '_request' => $request]) ->willReturn(null); $toolReference @@ -264,7 +264,7 @@ public function testHandleLogsErrorWithCorrectParameters(): void $this->referenceHandler ->expects($this->once()) ->method('handle') - ->with($toolReference, ['key1' => 'value1', 'key2' => 42, '_session' => $this->session]) + ->with($toolReference, ['key1' => 'value1', 'key2' => 42, '_session' => $this->session, '_request' => $request]) ->willThrowException($exception); $this->logger @@ -274,7 +274,7 @@ public function testHandleLogsErrorWithCorrectParameters(): void 'Error while executing tool "test_tool": "Custom error message".', [ 'tool' => 'test_tool', - 'arguments' => ['key1' => 'value1', 'key2' => 42, '_session' => $this->session], + 'arguments' => ['key1' => 'value1', 'key2' => 42, '_session' => $this->session, '_request' => $request], ], ); @@ -307,7 +307,7 @@ public function testHandleGenericExceptionReturnsError(): void $this->referenceHandler ->expects($this->once()) ->method('handle') - ->with($toolReference, ['param' => 'value', '_session' => $this->session]) + ->with($toolReference, ['param' => 'value', '_session' => $this->session, '_request' => $request]) ->willThrowException($exception); $response = $this->handler->handle($request, $this->session); @@ -334,7 +334,7 @@ public function testHandleWithSpecialCharactersInToolName(): void $this->referenceHandler ->expects($this->once()) ->method('handle') - ->with($toolReference, ['_session' => $this->session]) + ->with($toolReference, ['_session' => $this->session, '_request' => $request]) ->willReturn('Special tool result'); $toolReference @@ -369,7 +369,7 @@ public function testHandleWithSpecialCharactersInArguments(): void $this->referenceHandler ->expects($this->once()) ->method('handle') - ->with($toolReference, array_merge($arguments, ['_session' => $this->session])) + ->with($toolReference, array_merge($arguments, ['_session' => $this->session, '_request' => $request])) ->willReturn('Unicode handled'); $toolReference @@ -399,7 +399,7 @@ public function testHandleReturnsStructuredContentResult(): void $this->referenceHandler ->expects($this->once()) ->method('handle') - ->with($toolReference, ['query' => 'php', '_session' => $this->session]) + ->with($toolReference, ['query' => 'php', '_session' => $this->session, '_request' => $request]) ->willReturn($structuredResult); $toolReference @@ -428,7 +428,7 @@ public function testHandleReturnsCallToolResult(): void $this->referenceHandler ->expects($this->once()) ->method('handle') - ->with($toolReference, ['query' => 'php', '_session' => $this->session]) + ->with($toolReference, ['query' => 'php', '_session' => $this->session, '_request' => $request]) ->willReturn($callToolResult); $toolReference diff --git a/tests/Unit/Server/Handler/Request/GetPromptHandlerTest.php b/tests/Unit/Server/Handler/Request/GetPromptHandlerTest.php index 95b2e5c1..75503e39 100644 --- a/tests/Unit/Server/Handler/Request/GetPromptHandlerTest.php +++ b/tests/Unit/Server/Handler/Request/GetPromptHandlerTest.php @@ -70,7 +70,7 @@ public function testHandleSuccessfulPromptGet(): void $this->referenceHandler ->expects($this->once()) ->method('handle') - ->with($promptReference, ['_session' => $this->session]) + ->with($promptReference, ['_session' => $this->session, '_request' => $request]) ->willReturn($expectedMessages); $promptReference @@ -112,7 +112,7 @@ public function testHandlePromptGetWithArguments(): void $this->referenceHandler ->expects($this->once()) ->method('handle') - ->with($promptReference, array_merge($arguments, ['_session' => $this->session])) + ->with($promptReference, array_merge($arguments, ['_session' => $this->session, '_request' => $request])) ->willReturn($expectedMessages); $promptReference @@ -145,7 +145,7 @@ public function testHandlePromptGetWithNullArguments(): void $this->referenceHandler ->expects($this->once()) ->method('handle') - ->with($promptReference, ['_session' => $this->session]) + ->with($promptReference, ['_session' => $this->session, '_request' => $request]) ->willReturn($expectedMessages); $promptReference @@ -178,7 +178,7 @@ public function testHandlePromptGetWithEmptyArguments(): void $this->referenceHandler ->expects($this->once()) ->method('handle') - ->with($promptReference, ['_session' => $this->session]) + ->with($promptReference, ['_session' => $this->session, '_request' => $request]) ->willReturn($expectedMessages); $promptReference @@ -213,7 +213,7 @@ public function testHandlePromptGetWithMultipleMessages(): void $this->referenceHandler ->expects($this->once()) ->method('handle') - ->with($promptReference, ['_session' => $this->session]) + ->with($promptReference, ['_session' => $this->session, '_request' => $request]) ->willReturn($expectedMessages); $promptReference @@ -299,7 +299,7 @@ public function testHandlePromptGetWithComplexArguments(): void $this->referenceHandler ->expects($this->once()) ->method('handle') - ->with($promptReference, array_merge($arguments, ['_session' => $this->session])) + ->with($promptReference, array_merge($arguments, ['_session' => $this->session, '_request' => $request])) ->willReturn($expectedMessages); $promptReference @@ -337,7 +337,7 @@ public function testHandlePromptGetWithSpecialCharacters(): void $this->referenceHandler ->expects($this->once()) ->method('handle') - ->with($promptReference, array_merge($arguments, ['_session' => $this->session])) + ->with($promptReference, array_merge($arguments, ['_session' => $this->session, '_request' => $request])) ->willReturn($expectedMessages); $promptReference @@ -367,7 +367,7 @@ public function testHandlePromptGetReturnsEmptyMessages(): void $this->referenceHandler ->expects($this->once()) ->method('handle') - ->with($promptReference, ['_session' => $this->session]) + ->with($promptReference, ['_session' => $this->session, '_request' => $request]) ->willReturn([]); $promptReference @@ -405,7 +405,7 @@ public function testHandlePromptGetWithLargeNumberOfArguments(): void $this->referenceHandler ->expects($this->once()) ->method('handle') - ->with($promptReference, array_merge($arguments, ['_session' => $this->session])) + ->with($promptReference, array_merge($arguments, ['_session' => $this->session, '_request' => $request])) ->willReturn($expectedMessages); $promptReference diff --git a/tests/Unit/Server/Handler/Request/ReadResourceHandlerTest.php b/tests/Unit/Server/Handler/Request/ReadResourceHandlerTest.php index a4ed0b17..7483f1c1 100644 --- a/tests/Unit/Server/Handler/Request/ReadResourceHandlerTest.php +++ b/tests/Unit/Server/Handler/Request/ReadResourceHandlerTest.php @@ -75,7 +75,7 @@ public function testHandleSuccessfulResourceRead(): void $this->referenceHandler ->expects($this->once()) ->method('handle') - ->with($resourceReference, ['uri' => $uri, '_session' => $this->session]) + ->with($resourceReference, ['uri' => $uri, '_session' => $this->session, '_request' => $request]) ->willReturn('test'); $resourceReference @@ -115,7 +115,7 @@ public function testHandleResourceReadWithBlobContent(): void $this->referenceHandler ->expects($this->once()) ->method('handle') - ->with($resourceReference, ['uri' => $uri, '_session' => $this->session]) + ->with($resourceReference, ['uri' => $uri, '_session' => $this->session, '_request' => $request]) ->willReturn('fake-image-data'); $resourceReference @@ -159,7 +159,7 @@ public function testHandleResourceReadWithMultipleContents(): void $this->referenceHandler ->expects($this->once()) ->method('handle') - ->with($resourceReference, ['uri' => $uri, '_session' => $this->session]) + ->with($resourceReference, ['uri' => $uri, '_session' => $this->session, '_request' => $request]) ->willReturn('binary-data'); $resourceReference @@ -267,7 +267,7 @@ public function testHandleResourceReadWithDifferentUriSchemes(): void $this->referenceHandler ->expects($this->once()) ->method('handle') - ->with($resourceReference, ['uri' => $uri, '_session' => $this->session]) + ->with($resourceReference, ['uri' => $uri, '_session' => $this->session, '_request' => $request]) ->willReturn('test'); $resourceReference @@ -312,7 +312,7 @@ public function testHandleResourceReadWithEmptyContent(): void $this->referenceHandler ->expects($this->once()) ->method('handle') - ->with($resourceReference, ['uri' => $uri, '_session' => $this->session]) + ->with($resourceReference, ['uri' => $uri, '_session' => $this->session, '_request' => $request]) ->willReturn(''); $resourceReference @@ -374,7 +374,7 @@ public function testHandleResourceReadWithDifferentMimeTypes(): void $this->referenceHandler ->expects($this->once()) ->method('handle') - ->with($resourceReference, ['uri' => $uri, '_session' => $this->session]) + ->with($resourceReference, ['uri' => $uri, '_session' => $this->session, '_request' => $request]) ->willReturn($expectedContent); $resourceReference From e18b4dee7d2e1d5cce18639608a67f99156edcdc Mon Sep 17 00:00:00 2001 From: Tobias Nyholm Date: Mon, 1 Dec 2025 09:28:50 +0100 Subject: [PATCH 4/5] Better deprecation --- src/Server/ClientAwareInterface.php | 2 +- src/Server/ClientAwareTrait.php | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/src/Server/ClientAwareInterface.php b/src/Server/ClientAwareInterface.php index d5674e55..c48712c5 100644 --- a/src/Server/ClientAwareInterface.php +++ b/src/Server/ClientAwareInterface.php @@ -12,7 +12,7 @@ namespace Mcp\Server; /** - * @deprecated We dont need this. Use RequestContext as argument injection instead. + * @deprecated This is deprecated since 0.2.0 and will be removed in 0.3.0. Use RequestContext with argument injection instead. */ interface ClientAwareInterface { diff --git a/src/Server/ClientAwareTrait.php b/src/Server/ClientAwareTrait.php index 493ca7cc..8b27be1d 100644 --- a/src/Server/ClientAwareTrait.php +++ b/src/Server/ClientAwareTrait.php @@ -17,6 +17,8 @@ /** * @phpstan-import-type SampleOptions from ClientGateway + * + * @deprecated since 0.2.0, to be removed in 0.3.0. Use the RequestContext->getClientGateway() directly instead. */ trait ClientAwareTrait { From ddd64e9b8b718dce09b97873ac29806fb1141772 Mon Sep 17 00:00:00 2001 From: Tobias Nyholm Date: Mon, 1 Dec 2025 09:43:48 +0100 Subject: [PATCH 5/5] fix: minor --- tests/Unit/Capability/Discovery/DocBlockTestFixture.php | 4 ---- 1 file changed, 4 deletions(-) diff --git a/tests/Unit/Capability/Discovery/DocBlockTestFixture.php b/tests/Unit/Capability/Discovery/DocBlockTestFixture.php index 97f7161b..a218ad63 100644 --- a/tests/Unit/Capability/Discovery/DocBlockTestFixture.php +++ b/tests/Unit/Capability/Discovery/DocBlockTestFixture.php @@ -77,10 +77,6 @@ public function methodWithReturn(): string */ public function methodWithMultipleTags(float $value): bool { - if ($value < 0) { - throw new \RuntimeException('Processing failed for negative values'); - } - return true; }