diff --git a/composer.json b/composer.json index 00188f46..f31d0239 100644 --- a/composer.json +++ b/composer.json @@ -60,6 +60,7 @@ "Mcp\\Example\\StdioDiscoveryCalculator\\": "examples/stdio-discovery-calculator/", "Mcp\\Example\\StdioEnvVariables\\": "examples/stdio-env-variables/", "Mcp\\Example\\StdioExplicitRegistration\\": "examples/stdio-explicit-registration/", + "Mcp\\Example\\StdioLoggingShowcase\\": "examples/stdio-logging-showcase/", "Mcp\\Tests\\": "tests/" } }, diff --git a/docs/mcp-elements.md b/docs/mcp-elements.md index 911b3d52..12320ee0 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) @@ -478,6 +479,55 @@ public function generatePrompt(string $topic, string $style): array The SDK automatically validates that all messages have valid roles and converts the result into the appropriate MCP prompt message format. +## 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 diff --git a/docs/server-builder.md b/docs/server-builder.md index 03fcaece..a02dc64e 100644 --- a/docs/server-builder.md +++ b/docs/server-builder.md @@ -508,6 +508,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-discovery-calculator/McpElements.php b/examples/stdio-discovery-calculator/McpElements.php index 71aea372..6844bde9 100644 --- a/examples/stdio-discovery-calculator/McpElements.php +++ b/examples/stdio-discovery-calculator/McpElements.php @@ -13,6 +13,7 @@ use Mcp\Capability\Attribute\McpResource; use Mcp\Capability\Attribute\McpTool; +use Mcp\Capability\Logger\ClientLogger; use Psr\Log\LoggerInterface; use Psr\Log\NullLogger; @@ -40,14 +41,15 @@ public function __construct( * Supports 'add', 'subtract', 'multiply', 'divide'. * Obeys the 'precision' and 'allow_negative' settings from the config resource. * - * @param float $a the first operand - * @param float $b the second operand - * @param string $operation the operation ('add', 'subtract', 'multiply', 'divide') + * @param float $a the first operand + * @param float $b the second operand + * @param string $operation the operation ('add', 'subtract', 'multiply', 'divide') + * @param ClientLogger $logger Auto-injected MCP logger * * @return float|string the result of the calculation, or an error message string */ #[McpTool(name: 'calculate')] - public function calculate(float $a, float $b, string $operation): float|string + public function calculate(float $a, float $b, string $operation, ClientLogger $logger): float|string { $this->logger->info(\sprintf('Calculating: %f %s %f', $a, $operation, $b)); @@ -65,25 +67,48 @@ public function calculate(float $a, float $b, string $operation): float|string break; case 'divide': if (0 == $b) { + $logger->warning('Division by zero attempted', [ + 'operand_a' => $a, + 'operand_b' => $b, + ]); + return 'Error: Division by zero.'; } $result = $a / $b; break; default: + $logger->error('Unknown operation requested', [ + 'operation' => $operation, + 'supported_operations' => ['add', 'subtract', 'multiply', 'divide'], + ]); + return "Error: Unknown operation '{$operation}'. Supported: add, subtract, multiply, divide."; } if (!$this->config['allow_negative'] && $result < 0) { + $logger->warning('Negative result blocked by configuration', [ + 'result' => $result, + 'allow_negative_setting' => false, + ]); + return 'Error: Negative results are disabled.'; } - return round($result, $this->config['precision']); + $finalResult = round($result, $this->config['precision']); + $logger->info('Calculation completed successfully', [ + 'result' => $finalResult, + 'precision' => $this->config['precision'], + ]); + + return $finalResult; } /** * Provides the current calculator configuration. * Can be read by clients to understand precision etc. * + * @param ClientLogger $logger Auto-injected MCP logger for demonstration + * * @return Config the configuration array */ #[McpResource( @@ -92,9 +117,12 @@ public function calculate(float $a, float $b, string $operation): float|string description: 'Current settings for the calculator tool (precision, allow_negative).', mimeType: 'application/json', )] - public function getConfiguration(): array + public function getConfiguration(ClientLogger $logger): array { - $this->logger->info('Resource config://calculator/settings read.'); + $logger->info('📊 Resource config://calculator/settings accessed via auto-injection!', [ + 'current_config' => $this->config, + 'auto_injection_demo' => 'ClientLogger was automatically injected into this resource handler', + ]); return $this->config; } @@ -103,8 +131,9 @@ public function getConfiguration(): array * Updates a specific configuration setting. * Note: This requires more robust validation in a real app. * - * @param string $setting the setting key ('precision' or 'allow_negative') - * @param mixed $value the new value (int for precision, bool for allow_negative) + * @param string $setting the setting key ('precision' or 'allow_negative') + * @param mixed $value the new value (int for precision, bool for allow_negative) + * @param ClientLogger $logger Auto-injected MCP logger * * @return array{ * success: bool, @@ -113,18 +142,32 @@ public function getConfiguration(): array * } success message or error */ #[McpTool(name: 'update_setting')] - public function updateSetting(string $setting, mixed $value): array + public function updateSetting(string $setting, mixed $value, ClientLogger $logger): array { $this->logger->info(\sprintf('Setting tool called: setting=%s, value=%s', $setting, var_export($value, true))); if (!\array_key_exists($setting, $this->config)) { + $logger->error('Unknown setting requested', [ + 'setting' => $setting, + 'available_settings' => array_keys($this->config), + ]); + return ['success' => false, 'error' => "Unknown setting '{$setting}'."]; } if ('precision' === $setting) { if (!\is_int($value) || $value < 0 || $value > 10) { + $logger->warning('Invalid precision value provided', [ + 'value' => $value, + 'valid_range' => '0-10', + ]); + return ['success' => false, 'error' => 'Invalid precision value. Must be integer between 0 and 10.']; } $this->config['precision'] = $value; + $logger->info('Precision setting updated', [ + 'new_precision' => $value, + 'previous_config' => $this->config, + ]); // In real app, notify subscribers of config://calculator/settings change // $registry->notifyResourceChanged('config://calculator/settings'); @@ -138,10 +181,19 @@ public function updateSetting(string $setting, mixed $value): array } elseif (\in_array(strtolower((string) $value), ['false', '0', 'no', 'off'])) { $value = false; } else { + $logger->warning('Invalid allow_negative value provided', [ + 'value' => $value, + 'expected_type' => 'boolean', + ]); + return ['success' => false, 'error' => 'Invalid allow_negative value. Must be boolean (true/false).']; } } $this->config['allow_negative'] = $value; + $logger->info('Allow negative setting updated', [ + 'new_allow_negative' => $value, + 'updated_config' => $this->config, + ]); // $registry->notifyResourceChanged('config://calculator/settings'); return ['success' => true, 'message' => 'Allow negative results set to '.($value ? 'true' : 'false').'.']; 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/Discovery/SchemaGenerator.php b/src/Capability/Discovery/SchemaGenerator.php index 936a35cf..bdde50be 100644 --- a/src/Capability/Discovery/SchemaGenerator.php +++ b/src/Capability/Discovery/SchemaGenerator.php @@ -409,6 +409,10 @@ private function parseParametersInfo(\ReflectionMethod|\ReflectionFunction $refl $parametersInfo = []; foreach ($reflection->getParameters() as $rp) { + if ($this->isAutoInjectedParameter($rp)) { + continue; + } + $paramName = $rp->getName(); $paramTag = $paramTags['$'.$paramName] ?? null; @@ -784,4 +788,25 @@ private function mapSimpleTypeToJsonSchema(string $type): string default => \in_array(strtolower($type), ['datetime', 'datetimeinterface']) ? 'string' : 'object', }; } + + /** + * Determines if a parameter was auto-injected and should be excluded from schema generation. + * + * Parameters that are auto-injected by the framework (like ClientLogger) should not appear + * in the tool schema since they're not provided by the client. + */ + private function isAutoInjectedParameter(\ReflectionParameter $parameter): bool + { + $type = $parameter->getType(); + + if (!$type instanceof \ReflectionNamedType) { + return false; + } + + $typeName = $type->getName(); + + // Auto-inject for ClientLogger or LoggerInterface types + return 'Mcp\\Capability\\Logger\\ClientLogger' === $typeName + || 'Psr\\Log\\LoggerInterface' === $typeName; + } } 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 94db079f..7cbd3590 100644 --- a/src/Capability/Registry.php +++ b/src/Capability/Registry.php @@ -23,6 +23,7 @@ use Mcp\Event\ResourceTemplateListChangedEvent; use Mcp\Event\ToolListChangedEvent; use Mcp\Exception\InvalidCursorException; +use Mcp\Schema\Enum\LoggingLevel; use Mcp\Schema\Page; use Mcp\Schema\Prompt; use Mcp\Schema\Resource; @@ -65,12 +66,55 @@ final class Registry implements ReferenceProviderInterface, ReferenceRegistryInt private ServerCapabilities $serverCapabilities; + private bool $logging = true; + + private LoggingLevel $loggingLevel = LoggingLevel::Warning; + public function __construct( private readonly ?EventDispatcherInterface $eventDispatcher = null, private readonly LoggerInterface $logger = new NullLogger(), ) { } + /** + * 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; + } + public function getCapabilities(): ServerCapabilities { if (!$this->hasElements()) { @@ -85,7 +129,7 @@ public function getCapabilities(): ServerCapabilities resourcesListChanged: $this->eventDispatcher instanceof EventDispatcherInterface, prompts: [] !== $this->prompts, promptsListChanged: $this->eventDispatcher instanceof EventDispatcherInterface, - logging: false, + logging: $this->logging, completions: true, ); } diff --git a/src/Capability/Registry/ReferenceHandler.php b/src/Capability/Registry/ReferenceHandler.php index b0333788..bb8c8ebe 100644 --- a/src/Capability/Registry/ReferenceHandler.php +++ b/src/Capability/Registry/ReferenceHandler.php @@ -11,6 +11,7 @@ namespace Mcp\Capability\Registry; +use Mcp\Capability\Logger\ClientLogger; use Mcp\Exception\InvalidArgumentException; use Mcp\Exception\RegistryException; use Psr\Container\ContainerInterface; @@ -22,6 +23,7 @@ final class ReferenceHandler implements ReferenceHandlerInterface { public function __construct( private readonly ?ContainerInterface $container = null, + private readonly ?ClientLogger $clientLogger = null, ) { } @@ -89,6 +91,18 @@ private function prepareArguments(\ReflectionFunctionAbstract $reflection, array $paramName = $parameter->getName(); $paramPosition = $parameter->getPosition(); + // Auto-inject ClientLogger if parameter expects it + if ($this->shouldInjectClientLogger($parameter)) { + if (null !== $this->clientLogger) { + $finalArgs[$paramPosition] = $this->clientLogger; + continue; + } elseif ($parameter->allowsNull() || $parameter->isOptional()) { + $finalArgs[$paramPosition] = null; + continue; + } + // If ClientLogger is required but not available, fall through to normal handling + } + if (isset($arguments[$paramName])) { $argument = $arguments[$paramName]; try { @@ -115,6 +129,23 @@ private function prepareArguments(\ReflectionFunctionAbstract $reflection, array return array_values($finalArgs); } + /** + * Determines if the parameter should receive auto-injected ClientLogger. + */ + private function shouldInjectClientLogger(\ReflectionParameter $parameter): bool + { + $type = $parameter->getType(); + + if (!$type instanceof \ReflectionNamedType) { + return false; + } + + $typeName = $type->getName(); + + // Auto-inject for ClientLogger or LoggerInterface types + return ClientLogger::class === $typeName || \Psr\Log\LoggerInterface::class === $typeName; + } + /** * Gets a ReflectionMethod or ReflectionFunction for a callable. */ diff --git a/src/Capability/Registry/ReferenceRegistryInterface.php b/src/Capability/Registry/ReferenceRegistryInterface.php index 75581f0b..768fd516 100644 --- a/src/Capability/Registry/ReferenceRegistryInterface.php +++ b/src/Capability/Registry/ReferenceRegistryInterface.php @@ -12,6 +12,7 @@ namespace Mcp\Capability\Registry; use Mcp\Capability\Discovery\DiscoveryState; +use Mcp\Schema\Enum\LoggingLevel; use Mcp\Schema\Prompt; use Mcp\Schema\Resource; use Mcp\Schema\ResourceTemplate; @@ -88,4 +89,31 @@ public function getDiscoveryState(): DiscoveryState; * Manual elements are preserved. */ public function setDiscoveryState(DiscoveryState $state): void; + + /** + * Disables logging for the server. + */ + public function disableLogging(): void; + + /** + * Checks if logging capability is enabled. + * + * @return bool True if logging capability is enabled, false otherwise + */ + public function isLoggingEnabled(): bool; + + /** + * Sets the current logging 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; + + /** + * Gets the current logging level set by the client. + * + * @return LoggingLevel The current log level + */ + public function getLoggingLevel(): LoggingLevel; } 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 1eb24e8c..137e4db3 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(), ) { } @@ -48,6 +50,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); try { return $transport->listen(); diff --git a/src/Server/Builder.php b/src/Server/Builder.php index 95ab9ea1..b5531746 100644 --- a/src/Server/Builder.php +++ b/src/Server/Builder.php @@ -20,6 +20,7 @@ use Mcp\Capability\Discovery\DocBlockParser; use Mcp\Capability\Discovery\HandlerResolver; use Mcp\Capability\Discovery\SchemaGenerator; +use Mcp\Capability\Logger\ClientLogger; use Mcp\Capability\Registry; use Mcp\Capability\Registry\Container; use Mcp\Capability\Registry\ElementReference; @@ -37,7 +38,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; @@ -85,6 +88,8 @@ final class Builder */ private array $notificationHandlers = []; + private bool $logging = true; + /** * @var array{ * handler: Handler, @@ -235,6 +240,16 @@ public function addNotificationHandlers(iterable $handlers): 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. */ @@ -376,6 +391,10 @@ public function build(): Server $container = $this->container ?? new Container(); $registry = new Registry($this->eventDispatcher, $logger); + if (!$this->logging) { + $registry->disableLogging(); + } + $this->registerCapabilities($registry, $logger); if ($this->serverCapabilities) { $registry->setServerCapabilities($this->serverCapabilities); @@ -392,7 +411,14 @@ public function build(): Server $capabilities = $registry->getCapabilities(); $configuration = new Configuration($this->serverInfo, $capabilities, $this->paginationLimit, $this->instructions); - $referenceHandler = new ReferenceHandler($container); + + // 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); $requestHandlers = array_merge($this->requestHandlers, [ new Handler\Request\PingHandler(), @@ -404,6 +430,7 @@ public function build(): Server new Handler\Request\ReadResourceHandler($registry, $referenceHandler, $logger), new Handler\Request\ListPromptsHandler($registry, $this->paginationLimit), new Handler\Request\GetPromptHandler($registry, $referenceHandler, $logger), + new SetLogLevelHandler($registry, $logger), ]); $notificationHandlers = array_merge($this->notificationHandlers, [ @@ -419,7 +446,7 @@ public function build(): Server logger: $logger, ); - return new Server($protocol, $logger); + return new Server($protocol, $notificationSender, $logger); } private function performDiscovery( 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/Registry/RegistryTest.php b/tests/Unit/Capability/Registry/RegistryTest.php index e1f47689..0d9e8766 100644 --- a/tests/Unit/Capability/Registry/RegistryTest.php +++ b/tests/Unit/Capability/Registry/RegistryTest.php @@ -77,7 +77,7 @@ public function testGetCapabilitiesWhenPopulated(): void $this->assertTrue($capabilities->prompts); $this->assertTrue($capabilities->completions); $this->assertFalse($capabilities->resourcesSubscribe); - $this->assertFalse($capabilities->logging); + $this->assertTrue($capabilities->logging); // Logging is enabled by default } public function testSetCustomCapabilities(): void 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); } }