From c51af7e08d9593d7e2096571b4ad5202adc365ea Mon Sep 17 00:00:00 2001 From: Adam Jamiu Date: Fri, 10 Oct 2025 05:36:22 +0100 Subject: [PATCH 01/14] Implemented MCP logging specification with auto-injection --- composer.json | 1 + .../McpElements.php | 93 +++++-- .../stdio-discovery-calculator/server.php | 1 + .../LoggingShowcaseHandlers.php | 245 +++++++++++++++++ examples/stdio-logging-showcase/server.php | 38 +++ src/Capability/Logger/McpLogger.php | 85 ++++++ src/Capability/Registry.php | 46 +++- src/Capability/Registry/ReferenceHandler.php | 31 +++ .../Registry/ReferenceProviderInterface.php | 31 +++ src/Schema/Enum/LoggingLevel.php | 21 ++ src/Server.php | 8 + src/Server/Builder.php | 48 +++- src/Server/Handler/JsonRpcHandler.php | 257 ++++++++++++++++++ .../LoggingMessageNotificationHandler.php | 116 ++++++++ src/Server/Handler/NotificationHandler.php | 154 +++++++++++ .../Handler/NotificationHandlerInterface.php | 35 +++ .../Handler/Request/SetLogLevelHandler.php | 55 ++++ src/Server/NotificationSender.php | 95 +++++++ .../Unit/Capability/Logger/McpLoggerTest.php | 100 +++++++ tests/Unit/Capability/RegistryLoggingTest.php | 200 ++++++++++++++ tests/Unit/Schema/Enum/LoggingLevelTest.php | 101 +++++++ tests/Unit/Server/BuilderLoggingTest.php | 130 +++++++++ .../LoggingMessageNotificationHandlerTest.php | 221 +++++++++++++++ .../Request/SetLogLevelHandlerTest.php | 176 ++++++++++++ tests/Unit/Server/NotificationSenderTest.php | 203 ++++++++++++++ tests/Unit/ServerTest.php | 12 + 26 files changed, 2474 insertions(+), 29 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/McpLogger.php create mode 100644 src/Server/Handler/JsonRpcHandler.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/McpLoggerTest.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 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/examples/stdio-discovery-calculator/McpElements.php b/examples/stdio-discovery-calculator/McpElements.php index 71aea372..b3949262 100644 --- a/examples/stdio-discovery-calculator/McpElements.php +++ b/examples/stdio-discovery-calculator/McpElements.php @@ -13,8 +13,7 @@ use Mcp\Capability\Attribute\McpResource; use Mcp\Capability\Attribute\McpTool; -use Psr\Log\LoggerInterface; -use Psr\Log\NullLogger; +use Mcp\Capability\Logger\McpLogger; /** * @phpstan-type Config array{precision: int, allow_negative: bool} @@ -29,27 +28,28 @@ final class McpElements 'allow_negative' => true, ]; - public function __construct( - private readonly LoggerInterface $logger = new NullLogger(), - ) { - } - /** * Performs a calculation based on the operation. * * 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 McpLogger $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, McpLogger $logger): float|string { - $this->logger->info(\sprintf('Calculating: %f %s %f', $a, $operation, $b)); + $logger->info('🧮 Calculate tool called', [ + 'operand_a' => $a, + 'operand_b' => $b, + 'operation' => $operation, + 'auto_injection' => 'McpLogger auto-injected successfully', + ]); $op = strtolower($operation); @@ -65,25 +65,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 McpLogger $logger Auto-injected MCP logger for demonstration + * * @return Config the configuration array */ #[McpResource( @@ -92,9 +115,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(McpLogger $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' => 'McpLogger was automatically injected into this resource handler', + ]); return $this->config; } @@ -103,8 +129,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 McpLogger $logger Auto-injected MCP logger * * @return array{ * success: bool, @@ -113,18 +140,37 @@ 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, McpLogger $logger): array { - $this->logger->info(\sprintf('Setting tool called: setting=%s, value=%s', $setting, var_export($value, true))); + $logger->info('🔧 Update setting tool called', [ + 'setting' => $setting, + 'value' => $value, + 'current_config' => $this->config, + 'auto_injection' => 'McpLogger auto-injected successfully', + ]); 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 +184,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-discovery-calculator/server.php b/examples/stdio-discovery-calculator/server.php index fe223240..7bf48406 100644 --- a/examples/stdio-discovery-calculator/server.php +++ b/examples/stdio-discovery-calculator/server.php @@ -23,6 +23,7 @@ ->setInstructions('This server supports basic arithmetic operations: add, subtract, multiply, and divide. Send JSON-RPC requests to perform calculations.') ->setContainer(container()) ->setLogger(logger()) + ->enableMcpLogging() // Enable MCP logging capability and auto-injection! ->setDiscovery(__DIR__, ['.']) ->build(); diff --git a/examples/stdio-logging-showcase/LoggingShowcaseHandlers.php b/examples/stdio-logging-showcase/LoggingShowcaseHandlers.php new file mode 100644 index 00000000..9a958fa9 --- /dev/null +++ b/examples/stdio-logging-showcase/LoggingShowcaseHandlers.php @@ -0,0 +1,245 @@ + + */ + #[McpTool(name: 'log_message', description: 'Demonstrates MCP logging with different levels')] + public function logMessage(string $message, string $level, McpLogger $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 'warning': + $logger->warning("âš ī¸ Warning: $message", ['tool' => 'log_message']); + break; + case 'error': + $logger->error("❌ Error: $message", ['tool' => 'log_message']); + break; + default: + $logger->warning("Unknown level '$level', defaulting to info"); + $logger->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, + ]; + } + + /** + * Tool that simulates a complex operation with detailed logging. + * + * @param array $data Input data to process + * @param LoggerInterface $logger Auto-injected logger (will be McpLogger) + * + * @return array + */ + #[McpTool(name: 'process_data', description: 'Processes data with comprehensive logging')] + public function processData(array $data, LoggerInterface $logger): array + { + $logger->info('🔄 Starting data processing', ['input_count' => \count($data)]); + + $results = []; + $errors = []; + + foreach ($data as $index => $item) { + $logger->debug("Processing item $index", ['item' => $item]); + + try { + if (!\is_string($item) && !is_numeric($item)) { + throw new \InvalidArgumentException('Item must be string or numeric'); + } + + $processed = strtoupper((string) $item); + $results[] = $processed; + + $logger->debug("✅ Successfully processed item $index", [ + 'original' => $item, + 'processed' => $processed, + ]); + } catch (\Exception $e) { + $logger->error("❌ Failed to process item $index", [ + 'item' => $item, + 'error' => $e->getMessage(), + ]); + $errors[] = "Item $index: ".$e->getMessage(); + } + } + + if (empty($errors)) { + $logger->info('🎉 Data processing completed successfully', [ + 'processed_count' => \count($results), + ]); + } else { + $logger->warning('âš ī¸ Data processing completed with errors', [ + 'processed_count' => \count($results), + 'error_count' => \count($errors), + ]); + } + + return [ + 'processed_items' => $results, + 'errors' => $errors, + 'summary' => [ + 'total_input' => \count($data), + 'successful' => \count($results), + 'failed' => \count($errors), + ], + ]; + } + + /** + * Resource that provides logging configuration with auto-injected logger. + * + * @param McpLogger $logger Auto-injected MCP logger + * + * @return array + */ + #[McpResource( + uri: 'config://logging/settings', + name: 'logging_config', + description: 'Current logging configuration and auto-injection status.', + mimeType: 'application/json' + )] + public function getLoggingConfig(McpLogger $logger): array + { + $logger->info('📋 Retrieving logging configuration'); + + $config = [ + 'auto_injection' => 'enabled', + 'supported_types' => ['McpLogger', 'LoggerInterface'], + 'levels' => ['debug', 'info', 'warning', 'error'], + 'features' => [ + 'auto_injection', + 'mcp_transport', + 'fallback_logging', + 'structured_data', + ], + ]; + + $logger->debug('Configuration retrieved', $config); + + return $config; + } + + /** + * Prompt that generates logging examples with auto-injected logger. + * + * @param string $example_type Type of logging example to generate + * @param LoggerInterface $logger Auto-injected logger + * + * @return array + */ + #[McpPrompt(name: 'logging_examples', description: 'Generates logging code examples')] + public function generateLoggingExamples(string $example_type, LoggerInterface $logger): array + { + $logger->info('📝 Generating logging examples', ['type' => $example_type]); + + $examples = match (strtolower($example_type)) { + 'tool' => [ + 'title' => 'Tool Handler with Auto-Injected Logger', + 'code' => ' +#[McpTool(name: "my_tool")] +public function myTool(string $input, McpLogger $logger): array +{ + $logger->info("Tool called", ["input" => $input]); + // Your tool logic here + return ["result" => "processed"]; +}', + 'description' => 'McpLogger is automatically injected - no configuration needed!', + ], + + 'resource' => [ + 'title' => 'Resource Handler with Logger Interface', + 'code' => ' +#[McpResource(uri: "my://resource")] +public function getResource(LoggerInterface $logger): string +{ + $logger->debug("Resource accessed"); + return "resource content"; +}', + 'description' => 'Works with both McpLogger and LoggerInterface types', + ], + + 'function' => [ + 'title' => 'Function Handler with Auto-Injection', + 'code' => ' +function myHandler(array $params, McpLogger $logger): array +{ + $logger->warning("Function handler called"); + return $params; +}', + 'description' => 'Even function handlers get auto-injection!', + ], + + default => [ + 'title' => 'Basic Logging Pattern', + 'code' => ' +// Just declare McpLogger as a parameter +public function handler($data, McpLogger $logger) +{ + $logger->info("Handler started"); + // Auto-injected, no setup required! +}', + 'description' => 'The simplest way to get MCP logging', + ], + }; + + $logger->info('✅ Generated logging example', ['type' => $example_type]); + + return [ + 'prompt' => "Here's how to use auto-injected MCP logging:", + 'example' => $examples, + 'tips' => [ + 'Just add McpLogger or LoggerInterface as a parameter', + 'No configuration or setup required', + 'Logger is automatically provided by the MCP SDK', + 'Logs are sent to connected MCP clients', + 'Fallback logger used if MCP transport unavailable', + ], + ]; + } +} diff --git a/examples/stdio-logging-showcase/server.php b/examples/stdio-logging-showcase/server.php new file mode 100644 index 00000000..45621faa --- /dev/null +++ b/examples/stdio-logging-showcase/server.php @@ -0,0 +1,38 @@ +#!/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()) + ->enableMcpLogging() // Enable MCP logging capability and auto-injection! + ->setDiscovery(__DIR__, ['.']) + ->build(); + +$transport = new StdioTransport(logger: logger()); + +$server->connect($transport); + +logger()->info('Logging Showcase Server is ready!'); +logger()->info('This example demonstrates auto-injection of McpLogger into capability handlers.'); +logger()->info('Available tools: analyze_data, process_batch, configure_system'); +logger()->info('Available resources: config://logging/settings, data://system/metrics'); +logger()->info('Available prompts: generate_report, create_summary'); diff --git a/src/Capability/Logger/McpLogger.php b/src/Capability/Logger/McpLogger.php new file mode 100644 index 00000000..dfdc78b6 --- /dev/null +++ b/src/Capability/Logger/McpLogger.php @@ -0,0 +1,85 @@ + + */ +final class McpLogger extends AbstractLogger +{ + public function __construct( + private readonly NotificationSender $notificationSender, + private readonly ?LoggerInterface $fallbackLogger = null, + ) { + } + + public function log($level, \Stringable|string $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..43ab1f4a 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 $loggingMessageNotificationEnabled = false; + + private ?LoggingLevel $currentLoggingMessageNotificationLevel = null; + public function __construct( private readonly ?EventDispatcherInterface $eventDispatcher = null, private readonly LoggerInterface $logger = new NullLogger(), ) { } + /** + * Enables logging message notifications for this registry. + */ + public function enableLoggingMessageNotification(): void + { + $this->loggingMessageNotificationEnabled = true; + } + + /** + * Checks if logging message notification capability is enabled. + * + * @return bool True if logging message notification capability is enabled, false otherwise + */ + public function isLoggingMessageNotificationEnabled(): bool + { + return $this->loggingMessageNotificationEnabled; + } + + /** + * 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 setLoggingMessageNotificationLevel(LoggingLevel $level): void + { + $this->currentLoggingMessageNotificationLevel = $level; + } + + /** + * Gets the current logging message notification level set by the client. + * + * @return LoggingLevel|null The current log level, or null if not set + */ + public function getLoggingMessageNotificationLevel(): ?LoggingLevel + { + return $this->currentLoggingMessageNotificationLevel; + } + 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->loggingMessageNotificationEnabled, completions: true, ); } diff --git a/src/Capability/Registry/ReferenceHandler.php b/src/Capability/Registry/ReferenceHandler.php index b0333788..a0362631 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\McpLogger; 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 ?McpLogger $mcpLogger = null, ) { } @@ -89,6 +91,18 @@ private function prepareArguments(\ReflectionFunctionAbstract $reflection, array $paramName = $parameter->getName(); $paramPosition = $parameter->getPosition(); + // Auto-inject McpLogger if parameter expects it + if ($this->shouldInjectMcpLogger($parameter)) { + if (null !== $this->mcpLogger) { + $finalArgs[$paramPosition] = $this->mcpLogger; + continue; + } elseif ($parameter->allowsNull() || $parameter->isOptional()) { + $finalArgs[$paramPosition] = null; + continue; + } + // If McpLogger 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 McpLogger. + */ + private function shouldInjectMcpLogger(\ReflectionParameter $parameter): bool + { + $type = $parameter->getType(); + + if (!$type instanceof \ReflectionNamedType) { + return false; + } + + $typeName = $type->getName(); + + // Auto-inject for McpLogger or LoggerInterface types + return McpLogger::class === $typeName || \Psr\Log\LoggerInterface::class === $typeName; + } + /** * Gets a ReflectionMethod or ReflectionFunction for a callable. */ diff --git a/src/Capability/Registry/ReferenceProviderInterface.php b/src/Capability/Registry/ReferenceProviderInterface.php index 2f60014b..377ee812 100644 --- a/src/Capability/Registry/ReferenceProviderInterface.php +++ b/src/Capability/Registry/ReferenceProviderInterface.php @@ -11,6 +11,7 @@ namespace Mcp\Capability\Registry; +use Mcp\Schema\Enum\LoggingLevel; use Mcp\Schema\Page; /** @@ -65,4 +66,34 @@ public function getResourceTemplates(?int $limit = null, ?string $cursor = null) * Checks if any elements (manual or discovered) are currently registered. */ public function hasElements(): bool; + + /** + * Enables logging message notifications for the MCP server. + * + * When enabled, the server will advertise logging capability to clients, + * indicating that it can emit structured log messages according to the MCP specification. + */ + public function enableLoggingMessageNotification(): void; + + /** + * Checks if logging message notification capability is enabled. + * + * @return bool True if logging message notification capability is enabled, false otherwise + */ + public function isLoggingMessageNotificationEnabled(): bool; + + /** + * 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 setLoggingMessageNotificationLevel(LoggingLevel $level): void; + + /** + * Gets the current logging message notification level set by the client. + * + * @return LoggingLevel|null The current log level, or null if not set + */ + public function getLoggingMessageNotificationLevel(): ?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..e96a15c1 100644 --- a/src/Server.php +++ b/src/Server.php @@ -13,6 +13,7 @@ use Mcp\Server\Builder; use Mcp\Server\Protocol; +use Mcp\Server\NotificationSender; use Mcp\Server\Transport\TransportInterface; use Psr\Log\LoggerInterface; use Psr\Log\NullLogger; @@ -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..68898ce7 100644 --- a/src/Server/Builder.php +++ b/src/Server/Builder.php @@ -20,9 +20,9 @@ use Mcp\Capability\Discovery\DocBlockParser; use Mcp\Capability\Discovery\HandlerResolver; use Mcp\Capability\Discovery\SchemaGenerator; +use Mcp\Capability\Logger\McpLogger; use Mcp\Capability\Registry; use Mcp\Capability\Registry\Container; -use Mcp\Capability\Registry\ElementReference; use Mcp\Capability\Registry\ReferenceHandler; use Mcp\Exception\ConfigurationException; use Mcp\JsonRpc\MessageFactory; @@ -38,6 +38,8 @@ use Mcp\Server; use Mcp\Server\Handler\Notification\NotificationHandlerInterface; use Mcp\Server\Handler\Request\RequestHandlerInterface; +use Mcp\Server\Handler\NotificationHandler; +use Mcp\Server\Handler\Request\SetLogLevelHandler; use Mcp\Server\Session\InMemorySessionStore; use Mcp\Server\Session\SessionFactory; use Mcp\Server\Session\SessionFactoryInterface; @@ -85,6 +87,8 @@ final class Builder */ private array $notificationHandlers = []; + private bool $loggingMessageNotificationEnabled = false; + /** * @var array{ * handler: Handler, @@ -235,6 +239,20 @@ public function addNotificationHandlers(iterable $handlers): self return $this; } + /** + * Enables MCP logging capability for the server. + * + * When enabled, the server will advertise logging capability to clients, + * indicating that it can emit structured log messages according to the MCP specification. + * This enables auto-injection of McpLogger into capability handlers. + */ + public function enableMcpLogging(): self + { + $this->loggingMessageNotificationEnabled = true; + + return $this; + } + /** * Provides a PSR-3 logger instance. Defaults to NullLogger. */ @@ -376,6 +394,11 @@ public function build(): Server $container = $this->container ?? new Container(); $registry = new Registry($this->eventDispatcher, $logger); + // Enable MCP logging capability if requested + if ($this->loggingMessageNotificationEnabled) { + $registry->enableLoggingMessageNotification(); + } + $this->registerCapabilities($registry, $logger); if ($this->serverCapabilities) { $registry->setServerCapabilities($this->serverCapabilities); @@ -392,7 +415,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 McpLogger for components that should send logs via MCP + $mcpLogger = new McpLogger($notificationSender, $logger); + $referenceHandler = new ReferenceHandler($container, $mcpLogger); $requestHandlers = array_merge($this->requestHandlers, [ new Handler\Request\PingHandler(), @@ -404,6 +434,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, [ @@ -416,10 +447,9 @@ public function build(): Server messageFactory: $messageFactory, sessionFactory: $sessionFactory, sessionStore: $sessionStore, - logger: $logger, ); - return new Server($protocol, $logger); + return new Server($protocol, $notificationSender, $logger); } private function performDiscovery( @@ -456,7 +486,7 @@ private function registerCapabilities( $reflection = HandlerResolver::resolve($data['handler']); if ($reflection instanceof \ReflectionFunction) { - $name = $data['name'] ?? 'closure_tool_'.spl_object_id($data['handler']); + $name = $data['name'] ?? 'closure_tool_' . spl_object_id($data['handler']); $description = $data['description'] ?? null; } else { $classShortName = $reflection->getDeclaringClass()->getShortName(); @@ -489,7 +519,7 @@ private function registerCapabilities( $reflection = HandlerResolver::resolve($data['handler']); if ($reflection instanceof \ReflectionFunction) { - $name = $data['name'] ?? 'closure_resource_'.spl_object_id($data['handler']); + $name = $data['name'] ?? 'closure_resource_' . spl_object_id($data['handler']); $description = $data['description'] ?? null; } else { $classShortName = $reflection->getDeclaringClass()->getShortName(); @@ -525,7 +555,7 @@ private function registerCapabilities( $reflection = HandlerResolver::resolve($data['handler']); if ($reflection instanceof \ReflectionFunction) { - $name = $data['name'] ?? 'closure_template_'.spl_object_id($data['handler']); + $name = $data['name'] ?? 'closure_template_' . spl_object_id($data['handler']); $description = $data['description'] ?? null; } else { $classShortName = $reflection->getDeclaringClass()->getShortName(); @@ -561,7 +591,7 @@ private function registerCapabilities( $reflection = HandlerResolver::resolve($data['handler']); if ($reflection instanceof \ReflectionFunction) { - $name = $data['name'] ?? 'closure_prompt_'.spl_object_id($data['handler']); + $name = $data['name'] ?? 'closure_prompt_' . spl_object_id($data['handler']); $description = $data['description'] ?? null; } else { $classShortName = $reflection->getDeclaringClass()->getShortName(); @@ -584,7 +614,7 @@ private function registerCapabilities( continue; } - $paramTag = $paramTags['$'.$param->getName()] ?? null; + $paramTag = $paramTags['$' . $param->getName()] ?? null; $arguments[] = new PromptArgument( $param->getName(), $paramTag ? trim((string) $paramTag->getDescription()) : null, diff --git a/src/Server/Handler/JsonRpcHandler.php b/src/Server/Handler/JsonRpcHandler.php new file mode 100644 index 00000000..0988b10b --- /dev/null +++ b/src/Server/Handler/JsonRpcHandler.php @@ -0,0 +1,257 @@ + + */ +class JsonRpcHandler +{ + /** + * @param array $methodHandlers + */ + public function __construct( + private readonly array $methodHandlers, + private readonly MessageFactory $messageFactory, + private readonly SessionFactoryInterface $sessionFactory, + private readonly SessionStoreInterface $sessionStore, + private readonly LoggerInterface $logger = new NullLogger(), + ) {} + + /** + * @return iterable}> + */ + public function process(string $input, ?Uuid $sessionId): iterable + { + $this->logger->info('Received message to process.', ['message' => $input]); + + $this->runGarbageCollection(); + + try { + $messages = iterator_to_array($this->messageFactory->create($input)); + } catch (\JsonException $e) { + $this->logger->warning('Failed to decode json message.', ['exception' => $e]); + $error = Error::forParseError($e->getMessage()); + yield [$this->encodeResponse($error), []]; + + return; + } + + $hasInitializeRequest = false; + foreach ($messages as $message) { + if ($message instanceof InitializeRequest) { + $hasInitializeRequest = true; + break; + } + } + + $session = null; + + if ($hasInitializeRequest) { + // Spec: An initialize request must not be part of a batch. + if (\count($messages) > 1) { + $error = Error::forInvalidRequest('The "initialize" request MUST NOT be part of a batch.'); + yield [$this->encodeResponse($error), []]; + + return; + } + + // Spec: An initialize request must not have a session ID. + if ($sessionId) { + $error = Error::forInvalidRequest('A session ID MUST NOT be sent with an "initialize" request.'); + yield [$this->encodeResponse($error), []]; + + return; + } + + $session = $this->sessionFactory->create($this->sessionStore); + } else { + if (!$sessionId) { + $error = Error::forInvalidRequest('A valid session id is REQUIRED for non-initialize requests.'); + yield [$this->encodeResponse($error), ['status_code' => 400]]; + + return; + } + + if (!$this->sessionStore->exists($sessionId)) { + $error = Error::forInvalidRequest('Session not found or has expired.'); + yield [$this->encodeResponse($error), ['status_code' => 404]]; + + return; + } + + $session = $this->sessionFactory->createWithId($sessionId, $this->sessionStore); + } + + foreach ($messages as $message) { + if ($message instanceof InvalidInputMessageException) { + $this->logger->warning('Failed to create message.', ['exception' => $message]); + $error = Error::forInvalidRequest($message->getMessage()); + yield [$this->encodeResponse($error), []]; + continue; + } + + $this->logger->debug(\sprintf('Decoded incoming message "%s".', $message::class), [ + 'method' => $message->getMethod(), + ]); + + $messageId = $message instanceof Request ? $message->getId() : 0; + + try { + $response = $this->handle($message, $session); + yield [$this->encodeResponse($response), ['session_id' => $session->getId()]]; + } catch (\DomainException) { + yield [null, []]; + } catch (NotFoundExceptionInterface $e) { + $this->logger->warning( + \sprintf('Failed to create response: %s', $e->getMessage()), + ['exception' => $e], + ); + + $error = Error::forMethodNotFound($e->getMessage(), $messageId); + yield [$this->encodeResponse($error), []]; + } catch (\InvalidArgumentException $e) { + $this->logger->warning(\sprintf('Invalid argument: %s', $e->getMessage()), ['exception' => $e]); + + $error = Error::forInvalidParams($e->getMessage(), $messageId); + yield [$this->encodeResponse($error), []]; + } catch (\Throwable $e) { + $this->logger->critical(\sprintf('Uncaught exception: %s', $e->getMessage()), ['exception' => $e]); + + $error = Error::forInternalError($e->getMessage(), $messageId); + yield [$this->encodeResponse($error), []]; + } + } + + $session->save(); + } + + /** + * Encodes a response to JSON, handling encoding errors gracefully. + */ + private function encodeResponse(Response|Error|null $response): ?string + { + if (null === $response) { + $this->logger->info('The handler created an empty response.'); + + return null; + } + + $this->logger->info('Encoding response.', ['response' => $response]); + + try { + if ($response instanceof Response && [] === $response->result) { + return json_encode($response, \JSON_THROW_ON_ERROR | \JSON_FORCE_OBJECT); + } + + return json_encode($response, \JSON_THROW_ON_ERROR); + } catch (\JsonException $e) { + $this->logger->error('Failed to encode response to JSON.', [ + 'message_id' => $response->getId(), + 'exception' => $e, + ]); + + $fallbackError = new Error( + id: $response->getId(), + code: Error::INTERNAL_ERROR, + message: 'Response could not be encoded to JSON' + ); + + return json_encode($fallbackError, \JSON_THROW_ON_ERROR); + } + } + + /** + * If the handler does support the message, but does not create a response, other handlers will be tried. + * + * @throws NotFoundExceptionInterface When no handler is found for the request method + * @throws ExceptionInterface When a request handler throws an exception + */ + private function handle(HasMethodInterface $message, SessionInterface $session): Response|Error|null + { + $this->logger->info(\sprintf('Handling message for method "%s".', $message::getMethod()), [ + 'message' => $message, + ]); + + $handled = false; + foreach ($this->methodHandlers as $handler) { + if (!$handler->supports($message)) { + continue; + } + + $return = $handler->handle($message, $session); + $handled = true; + + $this->logger->debug(\sprintf('Message handled by "%s".', $handler::class), [ + 'method' => $message::getMethod(), + 'response' => $return, + ]); + + if (null !== $return) { + return $return; + } + } + + if ($handled) { + return null; + } + + throw new HandlerNotFoundException(\sprintf('No handler found for method "%s".', $message::getMethod())); + } + + /** + * Run garbage collection on expired sessions. + * Uses the session store's internal TTL configuration. + */ + private function runGarbageCollection(): void + { + if (random_int(0, 100) > 1) { + return; + } + + $deletedSessions = $this->sessionStore->gc(); + if (!empty($deletedSessions)) { + $this->logger->debug('Garbage collected expired sessions.', [ + 'count' => \count($deletedSessions), + 'session_ids' => array_map(fn(Uuid $id) => $id->toRfc4122(), $deletedSessions), + ]); + } + } + + /** + * Destroy a specific session. + */ + public function destroySession(Uuid $sessionId): void + { + $this->sessionStore->destroy($sessionId); + $this->logger->info('Session destroyed.', ['session_id' => $sessionId->toRfc4122()]); + } +} diff --git a/src/Server/Handler/Notification/LoggingMessageNotificationHandler.php b/src/Server/Handler/Notification/LoggingMessageNotificationHandler.php new file mode 100644 index 00000000..f269da3e --- /dev/null +++ b/src/Server/Handler/Notification/LoggingMessageNotificationHandler.php @@ -0,0 +1,116 @@ + + */ +final class LoggingMessageNotificationHandler implements NotificationHandlerInterface +{ + public function __construct( + private readonly ReferenceProviderInterface $referenceProvider, + 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->referenceProvider->isLoggingMessageNotificationEnabled()) { + $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->referenceProvider->getLoggingMessageNotificationLevel(); + + // Only filter by log level if client has explicitly set one + // If no log level is set (null), send all notifications + if (null === $currentLogLevel || $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'); + } + + /** + * Determines if a log message should be sent based on current log level threshold. + * + * Messages at the current level and higher (more severe) should be sent. + */ + 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..69dcd05e --- /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( + ReferenceProviderInterface $referenceProvider, + LoggerInterface $logger = new NullLogger(), + ): self { + return new self( + handlers: [ + LoggingMessageNotification::getMethod() => new LoggingMessageNotificationHandler($referenceProvider, $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..7b0eafb6 --- /dev/null +++ b/src/Server/Handler/Request/SetLogLevelHandler.php @@ -0,0 +1,55 @@ + + */ +final class SetLogLevelHandler implements MethodHandlerInterface +{ + public function __construct( + private readonly ReferenceProviderInterface $referenceProvider, + private readonly LoggerInterface $logger, + ) { + } + + public function supports(HasMethodInterface $message): bool + { + return $message instanceof SetLogLevelRequest; + } + + public function handle(SetLogLevelRequest|HasMethodInterface $message, SessionInterface $session): Response + { + \assert($message instanceof SetLogLevelRequest); + + // Update the log level in the registry via the interface + $this->referenceProvider->setLoggingMessageNotificationLevel($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..87283b03 --- /dev/null +++ b/src/Server/NotificationSender.php @@ -0,0 +1,95 @@ + + */ +final class NotificationSender +{ + public function __construct( + private readonly NotificationHandler $notificationHandler, + private ?TransportInterface $transport = null, + private readonly LoggerInterface $logger = new NullLogger(), + ) { + } + + /** + * Sets the transport interface for sending notifications. + */ + 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/Logger/McpLoggerTest.php b/tests/Unit/Capability/Logger/McpLoggerTest.php new file mode 100644 index 00000000..5c441565 --- /dev/null +++ b/tests/Unit/Capability/Logger/McpLoggerTest.php @@ -0,0 +1,100 @@ + + */ +final class McpLoggerTest extends TestCase +{ + private LoggerInterface&MockObject $fallbackLogger; + + protected function setUp(): void + { + $this->fallbackLogger = $this->createMock(LoggerInterface::class); + } + + public function testImplementsPsr3LoggerInterface(): void + { + $logger = $this->createMcpLogger(); + $this->assertInstanceOf(LoggerInterface::class, $logger); + } + + public function testAlwaysLogsToFallbackLogger(): void + { + $this->fallbackLogger + ->expects($this->once()) + ->method('log') + ->with('info', 'Test message', ['key' => 'value']); + + $logger = $this->createMcpLogger(); + $logger->info('Test message', ['key' => 'value']); + } + + public function testBasicLoggingMethodsWork(): void + { + $logger = $this->createMcpLogger(); + + // 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->createMcpLogger(); + $logger->info('Test message'); + } + + private function createMcpLogger(): McpLogger + { + // Create minimal working NotificationSender for testing + // Using a minimal ReferenceProvider mock just to construct NotificationHandler + $referenceProvider = $this->createMock(ReferenceProviderInterface::class); + $notificationHandler = NotificationHandler::make($referenceProvider); + $notificationSender = new NotificationSender($notificationHandler, null); + + return new McpLogger( + $notificationSender, + $this->fallbackLogger + ); + } +} diff --git a/tests/Unit/Capability/RegistryLoggingTest.php b/tests/Unit/Capability/RegistryLoggingTest.php new file mode 100644 index 00000000..bf47838f --- /dev/null +++ b/tests/Unit/Capability/RegistryLoggingTest.php @@ -0,0 +1,200 @@ + + */ +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 testLoggingDisabledByDefault(): void + { + $this->assertFalse($this->registry->isLoggingMessageNotificationEnabled()); + } + + public function testLoggingStateEnablement(): void + { + // Logging starts disabled + $this->assertFalse($this->registry->isLoggingMessageNotificationEnabled()); + + // Test enabling logging + $this->registry->enableLoggingMessageNotification(); + $this->assertTrue($this->registry->isLoggingMessageNotificationEnabled()); + + // Enabling again should have no effect + $this->registry->enableLoggingMessageNotification(); + $this->assertTrue($this->registry->isLoggingMessageNotificationEnabled()); + + // Create new instances to test disabled state + for ($i = 0; $i < 3; ++$i) { + $newRegistry = new Registry(null, $this->logger); + $this->assertFalse($newRegistry->isLoggingMessageNotificationEnabled()); + $newRegistry->enableLoggingMessageNotification(); + $this->assertTrue($newRegistry->isLoggingMessageNotificationEnabled()); + } + } + + public function testLogLevelManagement(): void + { + // Initially should be null + $this->assertNull($this->registry->getLoggingMessageNotificationLevel()); + + // 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->setLoggingMessageNotificationLevel($level); + $this->assertEquals($level, $this->registry->getLoggingMessageNotificationLevel()); + + // Verify enum properties are preserved + $retrievedLevel = $this->registry->getLoggingMessageNotificationLevel(); + $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->getLoggingMessageNotificationLevel()); + + // Test multiple level changes + $changeLevels = [ + LoggingLevel::Debug, + LoggingLevel::Warning, + LoggingLevel::Critical, + LoggingLevel::Info, + ]; + + foreach ($changeLevels as $level) { + $this->registry->setLoggingMessageNotificationLevel($level); + $this->assertEquals($level, $this->registry->getLoggingMessageNotificationLevel()); + } + } + + public function testGetLogLevelReturnsNullWhenNotSet(): void + { + // Verify default state + $this->assertNull($this->registry->getLoggingMessageNotificationLevel()); + + // Enable logging but don't set level + $this->registry->enableLoggingMessageNotification(); + $this->assertNull($this->registry->getLoggingMessageNotificationLevel()); + } + + 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->assertFalse($capabilities->logging); + + // Enable logging and test capabilities + $this->registry->enableLoggingMessageNotification(); + $capabilities = $this->registry->getCapabilities(); + $this->assertTrue($capabilities->logging); + + // Test with event dispatcher + /** @var EventDispatcherInterface&MockObject $eventDispatcher */ + $eventDispatcher = $this->createMock(EventDispatcherInterface::class); + $registryWithDispatcher = new Registry($eventDispatcher, $this->logger); + $registryWithDispatcher->enableLoggingMessageNotification(); + + $capabilities = $registryWithDispatcher->getCapabilities(); + $this->assertTrue($capabilities->logging); + $this->assertTrue($capabilities->toolsListChanged); + $this->assertTrue($capabilities->resourcesListChanged); + $this->assertTrue($capabilities->promptsListChanged); + } + + public function testLoggingStateIndependentOfLevel(): void + { + // Logging can be enabled without setting a level + $this->registry->enableLoggingMessageNotification(); + $this->assertTrue($this->registry->isLoggingMessageNotificationEnabled()); + $this->assertNull($this->registry->getLoggingMessageNotificationLevel()); + + // Level can be set after enabling logging + $this->registry->setLoggingMessageNotificationLevel(LoggingLevel::Info); + $this->assertTrue($this->registry->isLoggingMessageNotificationEnabled()); + $this->assertEquals(LoggingLevel::Info, $this->registry->getLoggingMessageNotificationLevel()); + + // Level can be set on a new registry without enabling logging + $newRegistry = new Registry(null, $this->logger); + $newRegistry->setLoggingMessageNotificationLevel(LoggingLevel::Info); + $this->assertFalse($newRegistry->isLoggingMessageNotificationEnabled()); + $this->assertEquals(LoggingLevel::Info, $newRegistry->getLoggingMessageNotificationLevel()); + + // Test persistence: Set level then enable logging - level should persist + $persistRegistry = new Registry(null, $this->logger); + $persistRegistry->setLoggingMessageNotificationLevel(LoggingLevel::Critical); + $this->assertEquals(LoggingLevel::Critical, $persistRegistry->getLoggingMessageNotificationLevel()); + + $persistRegistry->enableLoggingMessageNotification(); + $this->assertTrue($persistRegistry->isLoggingMessageNotificationEnabled()); + $this->assertEquals(LoggingLevel::Critical, $persistRegistry->getLoggingMessageNotificationLevel()); + } + + public function testRegistryIntegration(): void + { + // Test registry with default constructor + $defaultRegistry = new Registry(); + $this->assertFalse($defaultRegistry->isLoggingMessageNotificationEnabled()); + $this->assertNull($defaultRegistry->getLoggingMessageNotificationLevel()); + + // Test integration with other registry functionality + $this->registry->enableLoggingMessageNotification(); + $this->registry->setLoggingMessageNotificationLevel(LoggingLevel::Error); + + // Verify logging state doesn't interfere with other functionality + $this->assertTrue($this->registry->isLoggingMessageNotificationEnabled()); + $this->assertEquals(LoggingLevel::Error, $this->registry->getLoggingMessageNotificationLevel()); + + // Basic capability check + $this->logger + ->expects($this->once()) + ->method('info') + ->with('No capabilities registered on server.'); + + $capabilities = $this->registry->getCapabilities(); + $this->assertTrue($capabilities->logging); + $this->assertTrue($capabilities->completions); + } +} 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..98a9df73 --- /dev/null +++ b/tests/Unit/Server/BuilderLoggingTest.php @@ -0,0 +1,130 @@ + + */ +class BuilderLoggingTest extends TestCase +{ + public function testLoggingDisabledByDefault(): void + { + $builder = new Builder(); + + $this->assertFalse($this->getBuilderLoggingState($builder), 'Builder should start with logging disabled'); + + $server = $builder->setServerInfo('Test Server', '1.0.0')->build(); + $this->assertInstanceOf(Server::class, $server); + } + + public function testEnableMcpLoggingConfiguresBuilder(): void + { + $builder = new Builder(); + + $result = $builder->enableMcpLogging(); + + // Test method chaining + $this->assertSame($builder, $result, 'enableMcpLogging should return builder for chaining'); + + // Test internal state + $this->assertTrue($this->getBuilderLoggingState($builder), 'enableMcpLogging should set internal flag'); + + // Test server builds successfully + $server = $builder->setServerInfo('Test Server', '1.0.0')->build(); + $this->assertInstanceOf(Server::class, $server); + } + + public function testMultipleEnableCallsAreIdempotent(): void + { + $builder = new Builder(); + + $builder->enableMcpLogging() + ->enableMcpLogging() + ->enableMcpLogging(); + + $this->assertTrue($this->getBuilderLoggingState($builder), 'Multiple enable calls should maintain enabled state'); + } + + public function testLoggingStatePreservedAcrossBuilds(): void + { + $builder = new Builder(); + $builder->setServerInfo('Test Server', '1.0.0')->enableMcpLogging(); + + $server1 = $builder->build(); + $server2 = $builder->build(); + + // State should persist after building + $this->assertTrue($this->getBuilderLoggingState($builder), 'Builder state should persist after builds'); + $this->assertInstanceOf(Server::class, $server1); + $this->assertInstanceOf(Server::class, $server2); + } + + public function testLoggingWithOtherBuilderConfiguration(): void + { + $logger = $this->createMock(LoggerInterface::class); + \assert($logger instanceof LoggerInterface); + $builder = new Builder(); + + $server = $builder + ->setServerInfo('Test Server', '1.0.0', 'Test description') + ->setLogger($logger) + ->enableMcpLogging() + ->setPaginationLimit(50) + ->addTool(fn () => 'test', 'test_tool', 'Test tool') + ->build(); + + $this->assertInstanceOf(Server::class, $server); + $this->assertTrue($this->getBuilderLoggingState($builder), 'Logging should work with other configurations'); + } + + public function testIndependentBuilderInstances(): void + { + $builderWithLogging = new Builder(); + $builderWithoutLogging = new Builder(); + + $builderWithLogging->enableMcpLogging(); + // Don't enable on second builder + + $this->assertTrue($this->getBuilderLoggingState($builderWithLogging), 'First builder should have logging enabled'); + $this->assertFalse($this->getBuilderLoggingState($builderWithoutLogging), 'Second builder should have logging disabled'); + + // Both should build successfully + $server1 = $builderWithLogging->setServerInfo('Test1', '1.0.0')->build(); + $server2 = $builderWithoutLogging->setServerInfo('Test2', '1.0.0')->build(); + + $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('loggingMessageNotificationEnabled'); + $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..1d2369f5 --- /dev/null +++ b/tests/Unit/Server/Handler/Notification/LoggingMessageNotificationHandlerTest.php @@ -0,0 +1,221 @@ + + */ +class LoggingMessageNotificationHandlerTest extends TestCase +{ + private LoggingMessageNotificationHandler $handler; + private ReferenceProviderInterface&MockObject $referenceProvider; + private LoggerInterface&MockObject $logger; + + protected function setUp(): void + { + $this->referenceProvider = $this->createMock(ReferenceProviderInterface::class); + $this->logger = $this->createMock(LoggerInterface::class); + + $this->handler = new LoggingMessageNotificationHandler( + $this->referenceProvider, + $this->logger + ); + } + + public function testHandleNotificationCreation(): void + { + $this->referenceProvider + ->expects($this->exactly(3)) + ->method('isLoggingMessageNotificationEnabled') + ->willReturn(true); + + $this->referenceProvider + ->expects($this->exactly(3)) + ->method('getLoggingMessageNotificationLevel') + ->willReturnOnConsecutiveCalls(LoggingLevel::Info, LoggingLevel::Debug, null); + + // 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('Test error message', $notification1->data); + $this->assertEquals('TestLogger', $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('String level test', $notification2->data); + $this->assertNull($notification2->logger); + + // Test with complex data and no log level threshold + $complexData = [ + 'error' => 'Connection failed', + 'details' => ['host' => 'localhost', 'port' => 5432, 'retry_count' => 3], + 'timestamp' => time(), + ]; + $params3 = [ + 'level' => LoggingLevel::Critical, + 'data' => $complexData, + 'logger' => 'DatabaseService', + ]; + $notification3 = $this->handler->handle(LoggingMessageNotification::getMethod(), $params3); + $this->assertInstanceOf(LoggingMessageNotification::class, $notification3); + /* @var LoggingMessageNotification $notification3 */ + $this->assertEquals(LoggingLevel::Critical, $notification3->level); + $this->assertEquals($complexData, $notification3->data); + $this->assertEquals('DatabaseService', $notification3->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->referenceProvider + ->expects($this->once()) + ->method('isLoggingMessageNotificationEnabled') + ->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->referenceProvider + ->expects($this->exactly(3)) + ->method('isLoggingMessageNotificationEnabled') + ->willReturn(true); + + $this->referenceProvider + ->expects($this->exactly(3)) + ->method('getLoggingMessageNotificationLevel') + ->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->referenceProvider + ->expects($this->once()) + ->method('isLoggingMessageNotificationEnabled') + ->willReturn(true); + + $this->referenceProvider + ->expects($this->once()) + ->method('getLoggingMessageNotificationLevel') + ->willReturn(null); + + $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..99eedcf7 --- /dev/null +++ b/tests/Unit/Server/Handler/Request/SetLogLevelHandlerTest.php @@ -0,0 +1,176 @@ + + */ +class SetLogLevelHandlerTest extends TestCase +{ + private SetLogLevelHandler $handler; + private ReferenceProviderInterface&MockObject $referenceProvider; + private LoggerInterface&MockObject $logger; + private SessionInterface&MockObject $session; + + protected function setUp(): void + { + $this->referenceProvider = $this->createMock(ReferenceProviderInterface::class); + $this->logger = $this->createMock(LoggerInterface::class); + $this->session = $this->createMock(SessionInterface::class); + + $this->handler = new SetLogLevelHandler( + $this->referenceProvider, + $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 testHandleValidLogLevel(): void + { + $request = $this->createSetLogLevelRequest(LoggingLevel::Warning); + + $this->referenceProvider + ->expects($this->once()) + ->method('setLoggingMessageNotificationLevel') + ->with(LoggingLevel::Warning); + + $this->logger + ->expects($this->once()) + ->method('debug') + ->with('Log level set to: warning'); + + $response = $this->handler->handle($request, $this->session); + + $this->assertInstanceOf(Response::class, $response); + $this->assertEquals($request->getId(), $response->id); + $this->assertInstanceOf(EmptyResult::class, $response->result); + } + + public function testHandleAllLogLevelsAndSupport(): void + { + $logLevels = [ + LoggingLevel::Debug, + LoggingLevel::Info, + LoggingLevel::Notice, + LoggingLevel::Warning, + LoggingLevel::Error, + LoggingLevel::Critical, + LoggingLevel::Alert, + LoggingLevel::Emergency, + ]; + + // Test supports() method + $testRequest = $this->createSetLogLevelRequest(LoggingLevel::Info); + $this->assertTrue($this->handler->supports($testRequest)); + + $otherRequest = $this->createMock(Request::class); + $this->assertFalse($this->handler->supports($otherRequest)); + + // Test handling all log levels + foreach ($logLevels as $level) { + $request = $this->createSetLogLevelRequest($level); + + $this->referenceProvider + ->expects($this->once()) + ->method('setLoggingMessageNotificationLevel') + ->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(); + } + } + + public function testHandlerReusabilityAndStatelessness(): void + { + $handler1 = new SetLogLevelHandler($this->referenceProvider, $this->logger); + $handler2 = new SetLogLevelHandler($this->referenceProvider, $this->logger); + + $request = $this->createSetLogLevelRequest(LoggingLevel::Info); + + // Both handlers should work identically + $this->assertTrue($handler1->supports($request)); + $this->assertTrue($handler2->supports($request)); + + // Test reusability with multiple requests + $requests = [ + $this->createSetLogLevelRequest(LoggingLevel::Debug), + $this->createSetLogLevelRequest(LoggingLevel::Error), + ]; + + // Configure mocks for multiple calls + $this->referenceProvider + ->expects($this->exactly(2)) + ->method('setLoggingMessageNotificationLevel'); + + $this->logger + ->expects($this->exactly(2)) + ->method('debug'); + + foreach ($requests as $req) { + $response = $this->handler->handle($req, $this->session); + $this->assertInstanceOf(Response::class, $response); + $this->assertEquals($req->getId(), $response->id); + } + } + + 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..306f78a4 --- /dev/null +++ b/tests/Unit/Server/NotificationSenderTest.php @@ -0,0 +1,203 @@ + + */ +final class NotificationSenderTest extends TestCase +{ + private NotificationHandler $notificationHandler; + private TransportInterface&MockObject $transport; + private LoggerInterface&MockObject $logger; + private ReferenceProviderInterface&MockObject $referenceProvider; + private NotificationSender $sender; + + protected function setUp(): void + { + $this->referenceProvider = $this->createMock(ReferenceProviderInterface::class); + $this->transport = $this->createMock(TransportInterface::class); + $this->logger = $this->createMock(LoggerInterface::class); + + // Create real NotificationHandler with mocked dependencies + $this->notificationHandler = NotificationHandler::make($this->referenceProvider); + + $this->sender = new NotificationSender( + $this->notificationHandler, + null, + $this->logger + ); + } + + public function testSetTransport(): void + { + // Configure logging to be enabled + $this->referenceProvider + ->method('isLoggingMessageNotificationEnabled') + ->willReturn(true); + + $this->referenceProvider + ->method('getLoggingMessageNotificationLevel') + ->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->referenceProvider + ->method('isLoggingMessageNotificationEnabled') + ->willReturn(true); + + $this->referenceProvider + ->method('getLoggingMessageNotificationLevel') + ->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 testSendNullNotificationDoesNotCallTransport(): void + { + $this->sender->setTransport($this->transport); + + // Configure to disable logging so handler returns null + $this->referenceProvider + ->expects($this->once()) + ->method('isLoggingMessageNotificationEnabled') + ->willReturn(false); + + $this->transport + ->expects($this->never()) + ->method('send'); + + $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->referenceProvider + ->expects($this->once()) + ->method('isLoggingMessageNotificationEnabled') + ->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->referenceProvider + ->expects($this->once()) + ->method('isLoggingMessageNotificationEnabled') + ->willReturn(true); + + $this->referenceProvider + ->expects($this->once()) + ->method('getLoggingMessageNotificationLevel') + ->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->referenceProvider + ->method('isLoggingMessageNotificationEnabled') + ->willReturn(true); + + $this->referenceProvider + ->method('getLoggingMessageNotificationLevel') + ->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..dbf092b1 100644 --- a/tests/Unit/ServerTest.php +++ b/tests/Unit/ServerTest.php @@ -11,12 +11,17 @@ namespace Mcp\Tests\Unit; +use Mcp\Capability\Registry\ReferenceProviderInterface; use Mcp\Server; use Mcp\Server\Builder; use Mcp\Server\Protocol; use Mcp\Server\Transport\TransportInterface; use PHPUnit\Framework\Attributes\TestDox; use PHPUnit\Framework\MockObject\MockObject; +use Mcp\Server\Handler\JsonRpcHandler; +use Mcp\Server\Handler\NotificationHandler; +use Mcp\Server\NotificationSender; +use Mcp\Server\Transport\InMemoryTransport; use PHPUnit\Framework\TestCase; final class ServerTest extends TestCase @@ -153,5 +158,12 @@ public function testRunConnectsProtocolToTransport(): void $server = new Server($this->protocol); $server->run($this->transport); + $referenceProvider = $this->createMock(ReferenceProviderInterface::class); + $notificationHandler = NotificationHandler::make($referenceProvider); + $notificationSender = new NotificationSender($notificationHandler); + $server = new Server($handler, $notificationSender); + $server->run($transport); + + $transport->listen(); } } From 4a1b62c437ee2cf8667606518747a615f8daf4af Mon Sep 17 00:00:00 2001 From: Adam Jamiu Date: Fri, 10 Oct 2025 06:12:38 +0100 Subject: [PATCH 02/14] Added doc --- README.md | 31 ++++ docs/mcp-logging.md | 393 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 424 insertions(+) create mode 100644 docs/mcp-logging.md diff --git a/README.md b/README.md index 8d4aaa81..40f7bee7 100644 --- a/README.md +++ b/README.md @@ -231,6 +231,7 @@ $server = Server::builder() ## Documentation +<<<<<<< HEAD **Core Concepts:** - [Server Builder](docs/server-builder.md) - Complete ServerBuilder reference and configuration - [Transports](docs/transports.md) - STDIO and HTTP transport setup and usage @@ -240,6 +241,36 @@ $server = Server::builder() - [Examples](docs/examples.md) - Comprehensive example walkthroughs **External Resources:** +======= +### MCP Logging + +The SDK provides comprehensive logging capabilities following the [MCP logging specification](https://modelcontextprotocol.io/specification/2025-06-18/server/utilities/logging): + +- **Auto-injection**: `McpLogger` automatically injected into capability handlers +- **Client-controlled filtering**: Clients can set log levels to control verbosity +- **Centralized logging**: All server logs flow to client for unified debugging +- **Fallback support**: Compatible with existing PSR-3 loggers + +**Quick example:** +```php +#[McpTool(name: 'my_tool')] +public function myTool(string $input, McpLogger $logger): array { + $logger->info('Tool called', ['input' => $input]); + return ['result' => 'processed']; +} + +// Enable in server +$server = Server::builder() + ->enableMcpLogging() // Enable centralized logging + ->build(); +``` + +📖 **[Complete MCP Logging Guide](docs/mcp-logging.md)** + +### Additional Resources + +- [SDK documentation](doc/index.rst) +>>>>>>> 34ae3c3 (Added doc) - [Model Context Protocol documentation](https://modelcontextprotocol.io) - [Model Context Protocol specification](https://spec.modelcontextprotocol.io) - [Officially supported servers](https://github.com/modelcontextprotocol/servers) diff --git a/docs/mcp-logging.md b/docs/mcp-logging.md new file mode 100644 index 00000000..b80ac1cc --- /dev/null +++ b/docs/mcp-logging.md @@ -0,0 +1,393 @@ +# MCP Logging + +This document describes how to use the Model Context Protocol (MCP) logging capabilities in the PHP SDK. + +## Overview + +The MCP logging implementation provides centralized logging capabilities that allow clients to receive and filter log messages from servers. This is particularly useful when working with multiple MCP servers, as it enables unified debugging from a single client interface. + +## Key Features + +- **Auto-injection**: `McpLogger` is automatically injected into capability handlers +- **Client-controlled filtering**: Clients can set log levels to control message verbosity +- **Centralized logging**: All server logs flow to the client via MCP notifications +- **Fallback support**: Compatible with existing PSR-3 loggers for local debugging +- **Zero configuration**: Works out of the box with minimal setup + +## Quick Start + +### 1. Enable MCP Logging + +```php +use Mcp\Server; + +$server = Server::builder() + ->setServerInfo('My Server', '1.0.0') + ->enableMcpLogging() // Enable MCP logging capability + ->build(); +``` + +### 2. Use Auto-injected Logger in Handlers + +The `McpLogger` is automatically injected into any capability handler that declares it as a parameter: + +```php +use Mcp\Capability\Attribute\McpTool; +use Mcp\Capability\Logger\McpLogger; + +class MyHandlers +{ + #[McpTool(name: 'process_data')] + public function processData(array $data, McpLogger $logger): array + { + $logger->info('Processing data', ['count' => count($data)]); + + try { + $result = $this->performProcessing($data); + $logger->debug('Processing completed successfully'); + return $result; + } catch (\Exception $e) { + $logger->error('Processing failed', ['error' => $e->getMessage()]); + throw $e; + } + } +} +``` + +## Auto-injection + +The MCP SDK automatically injects loggers into capability handlers when you declare them as parameters. This works for: + +- **Tools** (`#[McpTool]`) +- **Resources** (`#[McpResource]`) +- **Prompts** (`#[McpPrompt]`) + +### Supported Logger Types + +You can use either type for auto-injection: + +```php +use Mcp\Capability\Logger\McpLogger; +use Psr\Log\LoggerInterface; + +// MCP-specific logger (recommended) +public function myTool(string $input, McpLogger $logger): array +{ + $logger->info('Tool called', ['input' => $input]); + // Logs are sent to client via MCP notifications + return ['result' => 'processed']; +} + +// PSR-3 compatible interface +public function myTool(string $input, LoggerInterface $logger): array +{ + $logger->info('Tool called', ['input' => $input]); + // Will receive McpLogger instance that implements LoggerInterface + return ['result' => 'processed']; +} +``` + +## Log Levels + +The implementation supports all RFC-5424 syslog severity levels: + +```php +$logger->emergency('System is unusable'); +$logger->alert('Action must be taken immediately'); +$logger->critical('Critical conditions'); +$logger->error('Error conditions'); +$logger->warning('Warning conditions'); +$logger->notice('Normal but significant condition'); +$logger->info('Informational messages'); +$logger->debug('Debug-level messages'); +``` + +### Client Log Level Control + +Clients can control which log levels they receive by sending a `logging/setLevel` request: + +```json +{ + "jsonrpc": "2.0", + "id": 1, + "method": "logging/setLevel", + "params": { + "level": "warning" + } +} +``` + +When a log level is set, only messages at that level and higher (more severe) will be sent to the client. + +## Advanced Usage + +### Fallback Logging + +You can provide a fallback PSR-3 logger for local debugging: + +```php +use Mcp\Server; +use Psr\Log\LoggerInterface; + +$server = Server::builder() + ->setServerInfo('My Server', '1.0.0') + ->setLogger($myPsr3Logger) // Fallback logger for local debugging + ->enableMcpLogging() // MCP logging for client notifications + ->build(); +``` + +With this setup: +- Log messages are sent to the client via MCP notifications +- Log messages are also written to your local PSR-3 logger for server-side debugging + +### Custom Container Integration + +If you're using a DI container, the MCP logger works seamlessly: + +```php +use Mcp\Server; +use Psr\Log\LoggerInterface; + +$container = new MyContainer(); +$container->set(LoggerInterface::class, $myCustomLogger); + +$server = Server::builder() + ->setContainer($container) + ->enableMcpLogging() + ->build(); +``` + +## Examples + +### Basic Tool with Logging + +```php +use Mcp\Capability\Attribute\McpTool; +use Mcp\Capability\Logger\McpLogger; + +#[McpTool(name: 'calculate', description: 'Performs mathematical calculations')] +public function calculate(float $a, float $b, string $operation, McpLogger $logger): array +{ + $logger->info('Calculation requested', [ + 'operand_a' => $a, + 'operand_b' => $b, + 'operation' => $operation + ]); + + switch ($operation) { + case 'add': + $result = $a + $b; + break; + case 'divide': + if ($b == 0) { + $logger->error('Division by zero attempted', ['operand_b' => $b]); + throw new \InvalidArgumentException('Cannot divide by zero'); + } + $result = $a / $b; + break; + default: + $logger->warning('Unknown operation requested', ['operation' => $operation]); + throw new \InvalidArgumentException("Unknown operation: $operation"); + } + + $logger->debug('Calculation completed', ['result' => $result]); + return ['result' => $result]; +} +``` + +### Resource with Logging + +```php +use Mcp\Capability\Attribute\McpResource; +use Mcp\Capability\Logger\McpLogger; + +#[McpResource( + uri: 'config://app/settings', + name: 'app_config', + description: 'Application configuration' +)] +public function getConfig(McpLogger $logger): array +{ + $logger->debug('Configuration accessed'); + + $config = $this->loadConfiguration(); + + $logger->info('Configuration loaded', [ + 'settings_count' => count($config), + 'last_modified' => $config['metadata']['last_modified'] ?? 'unknown' + ]); + + return $config; +} +``` + +### Error Handling with Logging + +```php +use Mcp\Capability\Attribute\McpTool; +use Mcp\Capability\Logger\McpLogger; + +#[McpTool(name: 'fetch_data')] +public function fetchData(string $url, McpLogger $logger): array +{ + $logger->info('Starting data fetch', ['url' => $url]); + + try { + $data = $this->httpClient->get($url); + $logger->debug('HTTP request successful', [ + 'url' => $url, + 'response_size' => strlen($data) + ]); + + $parsed = json_decode($data, true); + if (json_last_error() !== JSON_ERROR_NONE) { + $logger->error('JSON parsing failed', [ + 'url' => $url, + 'json_error' => json_last_error_msg() + ]); + throw new \RuntimeException('Invalid JSON response'); + } + + $logger->info('Data fetch completed successfully', [ + 'url' => $url, + 'items_count' => count($parsed) + ]); + + return $parsed; + + } catch (\Exception $e) { + $logger->critical('Data fetch failed', [ + 'url' => $url, + 'error' => $e->getMessage(), + 'exception_class' => get_class($e) + ]); + throw $e; + } +} +``` + +## MCP Notification Format + +Log messages are sent to clients as MCP notifications following this format: + +```json +{ + "jsonrpc": "2.0", + "method": "notifications/message", + "params": { + "level": "info", + "data": "Processing completed successfully", + "logger": "MyService" + } +} +``` + +Where: +- `level`: The log level (debug, info, notice, warning, error, critical, alert, emergency) +- `data`: The log message (string or structured data) +- `logger`: Optional logger name for message categorization + +## Best Practices + +### 1. Use Structured Logging + +Include context data with your log messages: + +```php +$logger->info('User action performed', [ + 'user_id' => $userId, + 'action' => 'file_upload', + 'file_size' => $fileSize, + 'duration_ms' => $duration +]); +``` + +### 2. Choose Appropriate Log Levels + +- **Debug**: Detailed diagnostic information +- **Info**: General operational messages +- **Notice**: Significant but normal events +- **Warning**: Something unexpected happened but the application continues +- **Error**: Error occurred but application can continue +- **Critical**: Critical error that might cause the application to abort +- **Alert**: Action must be taken immediately +- **Emergency**: System is unusable + +### 3. Avoid Logging Sensitive Data + +Never log passwords, API keys, or personal information: + +```php +// ❌ Bad - logs sensitive data +$logger->info('User login', ['password' => $password]); + +// ✅ Good - logs safely +$logger->info('User login attempt', ['username' => $username]); +``` + +### 4. Use Logger Names for Organization + +When working with complex applications, use logger names to categorize messages: + +```php +public function processPayment(array $data, McpLogger $logger): array +{ + // The logger will include context about which handler generated the log + $logger->info('Payment processing started', ['amount' => $data['amount']]); +} +``` + +## Troubleshooting + +### Logs Not Appearing in Client + +1. **Check if logging is enabled**: Ensure `->enableMcpLogging()` is called +2. **Verify log level**: Client might have set a higher log level threshold +3. **Check transport**: Ensure MCP transport is properly connected + +### Auto-injection Not Working + +1. **Parameter type**: Ensure parameter is typed as `McpLogger` or `LoggerInterface` +2. **Method signature**: Verify the parameter is in the method signature +3. **Builder configuration**: Confirm `->enableMcpLogging()` is called + +### Performance Considerations + +- Log messages are sent over the MCP transport, so avoid excessive debug logging in production +- Use appropriate log levels to allow clients to filter noise +- Consider the size of structured data in log messages + +## Migration from Existing Loggers + +If you're already using PSR-3 loggers, migration is straightforward: + +```php +use Mcp\Capability\Attribute\McpTool; +use Psr\Log\LoggerInterface; + +// Before: Manual logger injection +class MyService +{ + public function __construct(private LoggerInterface $logger) {} + + #[McpTool(name: 'my_tool')] + public function myTool(string $input): array + { + $this->logger->info('Tool called'); + return ['result' => 'processed']; + } +} + +// After: Auto-injection (remove constructor, add parameter) +class MyService +{ + #[McpTool(name: 'my_tool')] + public function myTool(string $input, LoggerInterface $logger): array + { + $logger->info('Tool called'); + return ['result' => 'processed']; + } +} +``` + +The MCP logger implements the PSR-3 `LoggerInterface`, so your existing logging calls will work without changes. \ No newline at end of file From 77a774f619f619c20f51211e56c6413b807f9b5c Mon Sep 17 00:00:00 2001 From: Adam Jamiu Date: Fri, 10 Oct 2025 06:37:54 +0100 Subject: [PATCH 03/14] Modified doc --- README.md | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/README.md b/README.md index 40f7bee7..c34a97b2 100644 --- a/README.md +++ b/README.md @@ -231,7 +231,6 @@ $server = Server::builder() ## Documentation -<<<<<<< HEAD **Core Concepts:** - [Server Builder](docs/server-builder.md) - Complete ServerBuilder reference and configuration - [Transports](docs/transports.md) - STDIO and HTTP transport setup and usage @@ -240,8 +239,6 @@ $server = Server::builder() **Learning:** - [Examples](docs/examples.md) - Comprehensive example walkthroughs -**External Resources:** -======= ### MCP Logging The SDK provides comprehensive logging capabilities following the [MCP logging specification](https://modelcontextprotocol.io/specification/2025-06-18/server/utilities/logging): @@ -267,10 +264,8 @@ $server = Server::builder() 📖 **[Complete MCP Logging Guide](docs/mcp-logging.md)** -### Additional Resources - +**External Resources:** - [SDK documentation](doc/index.rst) ->>>>>>> 34ae3c3 (Added doc) - [Model Context Protocol documentation](https://modelcontextprotocol.io) - [Model Context Protocol specification](https://spec.modelcontextprotocol.io) - [Officially supported servers](https://github.com/modelcontextprotocol/servers) From eb998cfa69d667dbcc6e9a28562d503533ae1dde Mon Sep 17 00:00:00 2001 From: Adam Jamiu Date: Fri, 10 Oct 2025 06:50:35 +0100 Subject: [PATCH 04/14] Fixed lint issues --- examples/stdio-logging-showcase/server.php | 6 +++--- src/Server/Builder.php | 11 ++++++----- src/Server/Handler/JsonRpcHandler.php | 5 +++-- 3 files changed, 12 insertions(+), 10 deletions(-) diff --git a/examples/stdio-logging-showcase/server.php b/examples/stdio-logging-showcase/server.php index 45621faa..b7d7504b 100644 --- a/examples/stdio-logging-showcase/server.php +++ b/examples/stdio-logging-showcase/server.php @@ -33,6 +33,6 @@ logger()->info('Logging Showcase Server is ready!'); logger()->info('This example demonstrates auto-injection of McpLogger into capability handlers.'); -logger()->info('Available tools: analyze_data, process_batch, configure_system'); -logger()->info('Available resources: config://logging/settings, data://system/metrics'); -logger()->info('Available prompts: generate_report, create_summary'); +logger()->info('Available tools: log_message, process_data'); +logger()->info('Available resources: config://logging/settings'); +logger()->info('Available prompts: logging_examples'); diff --git a/src/Server/Builder.php b/src/Server/Builder.php index 68898ce7..b762dba8 100644 --- a/src/Server/Builder.php +++ b/src/Server/Builder.php @@ -23,6 +23,7 @@ use Mcp\Capability\Logger\McpLogger; use Mcp\Capability\Registry; use Mcp\Capability\Registry\Container; +use Mcp\Capability\Registry\ElementReference; use Mcp\Capability\Registry\ReferenceHandler; use Mcp\Exception\ConfigurationException; use Mcp\JsonRpc\MessageFactory; @@ -486,7 +487,7 @@ private function registerCapabilities( $reflection = HandlerResolver::resolve($data['handler']); if ($reflection instanceof \ReflectionFunction) { - $name = $data['name'] ?? 'closure_tool_' . spl_object_id($data['handler']); + $name = $data['name'] ?? 'closure_tool_'.spl_object_id($data['handler']); $description = $data['description'] ?? null; } else { $classShortName = $reflection->getDeclaringClass()->getShortName(); @@ -519,7 +520,7 @@ private function registerCapabilities( $reflection = HandlerResolver::resolve($data['handler']); if ($reflection instanceof \ReflectionFunction) { - $name = $data['name'] ?? 'closure_resource_' . spl_object_id($data['handler']); + $name = $data['name'] ?? 'closure_resource_'.spl_object_id($data['handler']); $description = $data['description'] ?? null; } else { $classShortName = $reflection->getDeclaringClass()->getShortName(); @@ -555,7 +556,7 @@ private function registerCapabilities( $reflection = HandlerResolver::resolve($data['handler']); if ($reflection instanceof \ReflectionFunction) { - $name = $data['name'] ?? 'closure_template_' . spl_object_id($data['handler']); + $name = $data['name'] ?? 'closure_template_'.spl_object_id($data['handler']); $description = $data['description'] ?? null; } else { $classShortName = $reflection->getDeclaringClass()->getShortName(); @@ -591,7 +592,7 @@ private function registerCapabilities( $reflection = HandlerResolver::resolve($data['handler']); if ($reflection instanceof \ReflectionFunction) { - $name = $data['name'] ?? 'closure_prompt_' . spl_object_id($data['handler']); + $name = $data['name'] ?? 'closure_prompt_'.spl_object_id($data['handler']); $description = $data['description'] ?? null; } else { $classShortName = $reflection->getDeclaringClass()->getShortName(); @@ -614,7 +615,7 @@ private function registerCapabilities( continue; } - $paramTag = $paramTags['$' . $param->getName()] ?? null; + $paramTag = $paramTags['$'.$param->getName()] ?? null; $arguments[] = new PromptArgument( $param->getName(), $paramTag ? trim((string) $paramTag->getDescription()) : null, diff --git a/src/Server/Handler/JsonRpcHandler.php b/src/Server/Handler/JsonRpcHandler.php index 0988b10b..5f785bc5 100644 --- a/src/Server/Handler/JsonRpcHandler.php +++ b/src/Server/Handler/JsonRpcHandler.php @@ -44,7 +44,8 @@ public function __construct( private readonly SessionFactoryInterface $sessionFactory, private readonly SessionStoreInterface $sessionStore, private readonly LoggerInterface $logger = new NullLogger(), - ) {} + ) { + } /** * @return iterable}> @@ -241,7 +242,7 @@ private function runGarbageCollection(): void if (!empty($deletedSessions)) { $this->logger->debug('Garbage collected expired sessions.', [ 'count' => \count($deletedSessions), - 'session_ids' => array_map(fn(Uuid $id) => $id->toRfc4122(), $deletedSessions), + 'session_ids' => array_map(fn (Uuid $id) => $id->toRfc4122(), $deletedSessions), ]); } } From 9362f6c715974b3cfa1917525bf7aa868f1580c5 Mon Sep 17 00:00:00 2001 From: Adam Jamiu Date: Fri, 10 Oct 2025 07:00:30 +0100 Subject: [PATCH 05/14] Fixed issues found in pipeline --- src/Capability/Logger/McpLogger.php | 2 +- .../StdioCalculatorExampleTest-tools_list.json | 14 ++++++++++++-- 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/src/Capability/Logger/McpLogger.php b/src/Capability/Logger/McpLogger.php index dfdc78b6..29691258 100644 --- a/src/Capability/Logger/McpLogger.php +++ b/src/Capability/Logger/McpLogger.php @@ -33,7 +33,7 @@ public function __construct( ) { } - public function log($level, \Stringable|string $message, array $context = []): void + public function log($level, string|\Stringable $message, array $context = []): void { // Always log to fallback logger if provided (for local debugging) $this->fallbackLogger?->log($level, $message, $context); diff --git a/tests/Inspector/snapshots/StdioCalculatorExampleTest-tools_list.json b/tests/Inspector/snapshots/StdioCalculatorExampleTest-tools_list.json index 2812c849..7f8f944a 100644 --- a/tests/Inspector/snapshots/StdioCalculatorExampleTest-tools_list.json +++ b/tests/Inspector/snapshots/StdioCalculatorExampleTest-tools_list.json @@ -14,6 +14,10 @@ "type": "number", "description": "the second operand" }, + "logger": { + "type": "object", + "description": "Auto-injected MCP logger" + }, "operation": { "type": "string", "description": "the operation ('add', 'subtract', 'multiply', 'divide')" @@ -22,7 +26,8 @@ "required": [ "a", "b", - "operation" + "operation", + "logger" ] } }, @@ -32,6 +37,10 @@ "inputSchema": { "type": "object", "properties": { + "logger": { + "type": "object", + "description": "Auto-injected MCP logger" + }, "setting": { "type": "string", "description": "the setting key ('precision' or 'allow_negative')" @@ -42,7 +51,8 @@ }, "required": [ "setting", - "value" + "value", + "logger" ] } } From cd27bac0d2007cb1b47f68cf03f1b2a40cfd0684 Mon Sep 17 00:00:00 2001 From: Adam Jamiu Date: Sat, 11 Oct 2025 20:09:29 +0100 Subject: [PATCH 06/14] Fixed issue with McpLogger compatibility with LoggerInterface --- src/Capability/Logger/McpLogger.php | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/Capability/Logger/McpLogger.php b/src/Capability/Logger/McpLogger.php index 29691258..c5d5e143 100644 --- a/src/Capability/Logger/McpLogger.php +++ b/src/Capability/Logger/McpLogger.php @@ -33,7 +33,13 @@ public function __construct( ) { } - public function log($level, string|\Stringable $message, array $context = []): void + /** + * 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); From 2fdb8d890bc5d403501f9902b0e38421588bd2aa Mon Sep 17 00:00:00 2001 From: Adam Jamiu Date: Sat, 11 Oct 2025 21:47:39 +0100 Subject: [PATCH 07/14] Updated with latest Server method interface --- examples/stdio-logging-showcase/server.php | 2 +- src/Server/NotificationSender.php | 5 +++++ tests/Unit/Capability/Discovery/DocBlockTestFixture.php | 4 ++++ tests/Unit/Server/NotificationSenderTest.php | 1 + tests/Unit/ServerTest.php | 2 -- 5 files changed, 11 insertions(+), 3 deletions(-) diff --git a/examples/stdio-logging-showcase/server.php b/examples/stdio-logging-showcase/server.php index b7d7504b..60ac9af7 100644 --- a/examples/stdio-logging-showcase/server.php +++ b/examples/stdio-logging-showcase/server.php @@ -29,7 +29,7 @@ $transport = new StdioTransport(logger: logger()); -$server->connect($transport); +$server->run($transport); logger()->info('Logging Showcase Server is ready!'); logger()->info('This example demonstrates auto-injection of McpLogger into capability handlers.'); diff --git a/src/Server/NotificationSender.php b/src/Server/NotificationSender.php index 87283b03..ca63585f 100644 --- a/src/Server/NotificationSender.php +++ b/src/Server/NotificationSender.php @@ -28,6 +28,9 @@ */ final class NotificationSender { + /** + * @param TransportInterface|null $transport + */ public function __construct( private readonly NotificationHandler $notificationHandler, private ?TransportInterface $transport = null, @@ -37,6 +40,8 @@ public function __construct( /** * Sets the transport interface for sending notifications. + * + * @param TransportInterface $transport */ public function setTransport(TransportInterface $transport): void { 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/Server/NotificationSenderTest.php b/tests/Unit/Server/NotificationSenderTest.php index 306f78a4..d85eff03 100644 --- a/tests/Unit/Server/NotificationSenderTest.php +++ b/tests/Unit/Server/NotificationSenderTest.php @@ -27,6 +27,7 @@ final class NotificationSenderTest extends TestCase { private NotificationHandler $notificationHandler; + /** @var TransportInterface&MockObject */ private TransportInterface&MockObject $transport; private LoggerInterface&MockObject $logger; private ReferenceProviderInterface&MockObject $referenceProvider; diff --git a/tests/Unit/ServerTest.php b/tests/Unit/ServerTest.php index dbf092b1..6b1d3319 100644 --- a/tests/Unit/ServerTest.php +++ b/tests/Unit/ServerTest.php @@ -163,7 +163,5 @@ public function testRunConnectsProtocolToTransport(): void $notificationSender = new NotificationSender($notificationHandler); $server = new Server($handler, $notificationSender); $server->run($transport); - - $transport->listen(); } } From 4a8b33e335f0fefdf9d59b75ad08bc120039e1a2 Mon Sep 17 00:00:00 2001 From: Adam Jamiu Date: Mon, 13 Oct 2025 23:43:49 +0100 Subject: [PATCH 08/14] Exclude Auto injected client logger from generated input-schema --- src/Capability/Discovery/SchemaGenerator.php | 25 +++++++++++++++++++ ...StdioCalculatorExampleTest-tools_list.json | 14 ++--------- .../Discovery/SchemaGeneratorFixture.php | 11 ++++++++ .../Discovery/SchemaGeneratorTest.php | 16 ++++++++++++ 4 files changed, 54 insertions(+), 12 deletions(-) diff --git a/src/Capability/Discovery/SchemaGenerator.php b/src/Capability/Discovery/SchemaGenerator.php index 936a35cf..a1f694e4 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 McpLogger) 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 McpLogger or LoggerInterface types + return 'Mcp\\Capability\\Logger\\McpLogger' === $typeName + || 'Psr\\Log\\LoggerInterface' === $typeName; + } } diff --git a/tests/Inspector/snapshots/StdioCalculatorExampleTest-tools_list.json b/tests/Inspector/snapshots/StdioCalculatorExampleTest-tools_list.json index 7f8f944a..2812c849 100644 --- a/tests/Inspector/snapshots/StdioCalculatorExampleTest-tools_list.json +++ b/tests/Inspector/snapshots/StdioCalculatorExampleTest-tools_list.json @@ -14,10 +14,6 @@ "type": "number", "description": "the second operand" }, - "logger": { - "type": "object", - "description": "Auto-injected MCP logger" - }, "operation": { "type": "string", "description": "the operation ('add', 'subtract', 'multiply', 'divide')" @@ -26,8 +22,7 @@ "required": [ "a", "b", - "operation", - "logger" + "operation" ] } }, @@ -37,10 +32,6 @@ "inputSchema": { "type": "object", "properties": { - "logger": { - "type": "object", - "description": "Auto-injected MCP logger" - }, "setting": { "type": "string", "description": "the setting key ('precision' or 'allow_negative')" @@ -51,8 +42,7 @@ }, "required": [ "setting", - "value", - "logger" + "value" ] } } diff --git a/tests/Unit/Capability/Discovery/SchemaGeneratorFixture.php b/tests/Unit/Capability/Discovery/SchemaGeneratorFixture.php index 5a7fcaeb..4994cec3 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 McpLogger that should be excluded from schema. + * + * @param string $message The message to process + * @param \Mcp\Capability\Logger\McpLogger $logger Auto-injected logger + */ + public function withMcpLogger(string $message, \Mcp\Capability\Logger\McpLogger $logger): string + { + return $message; + } } diff --git a/tests/Unit/Capability/Discovery/SchemaGeneratorTest.php b/tests/Unit/Capability/Discovery/SchemaGeneratorTest.php index 4cbfce52..89ce167e 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 testExcludesMcpLoggerFromSchema() + { + $method = new \ReflectionMethod(SchemaGeneratorFixture::class, 'withMcpLogger'); + $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']); + } } From b701bb175629d28b3b5487a9d31cb39a327fc75f Mon Sep 17 00:00:00 2001 From: Adam Jamiu Date: Tue, 14 Oct 2025 00:15:10 +0100 Subject: [PATCH 09/14] Streamlined examples/stdio-logging-showcase --- .../LoggingShowcaseHandlers.php | 201 ++---------------- examples/stdio-logging-showcase/server.php | 3 - 2 files changed, 18 insertions(+), 186 deletions(-) diff --git a/examples/stdio-logging-showcase/LoggingShowcaseHandlers.php b/examples/stdio-logging-showcase/LoggingShowcaseHandlers.php index 9a958fa9..0a6330a3 100644 --- a/examples/stdio-logging-showcase/LoggingShowcaseHandlers.php +++ b/examples/stdio-logging-showcase/LoggingShowcaseHandlers.php @@ -11,11 +11,8 @@ namespace Mcp\Example\StdioLoggingShowcase; -use Mcp\Capability\Attribute\McpPrompt; -use Mcp\Capability\Attribute\McpResource; use Mcp\Capability\Attribute\McpTool; use Mcp\Capability\Logger\McpLogger; -use Psr\Log\LoggerInterface; /** * Example handlers showcasing auto-injected MCP logging capabilities. @@ -44,23 +41,35 @@ public function logMessage(string $message, string $level, McpLogger $logger): a switch (strtolower($level)) { case 'debug': - $logger->debug("🔍 Debug: $message", ['tool' => 'log_message']); + $logger->debug("Debug: $message", ['tool' => 'log_message']); break; case 'info': - $logger->info("â„šī¸ Info: $message", ['tool' => 'log_message']); + $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']); + $logger->warning("Warning: $message", ['tool' => 'log_message']); break; case 'error': - $logger->error("❌ Error: $message", ['tool' => 'log_message']); + $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("📝 $message", ['tool' => 'log_message']); + $logger->info("Info: $message", ['tool' => 'log_message']); } - $logger->debug('✅ log_message tool completed successfully'); + $logger->debug('log_message tool completed successfully'); return [ 'message' => "Logged message with level: $level", @@ -68,178 +77,4 @@ public function logMessage(string $message, string $level, McpLogger $logger): a 'level_used' => $level, ]; } - - /** - * Tool that simulates a complex operation with detailed logging. - * - * @param array $data Input data to process - * @param LoggerInterface $logger Auto-injected logger (will be McpLogger) - * - * @return array - */ - #[McpTool(name: 'process_data', description: 'Processes data with comprehensive logging')] - public function processData(array $data, LoggerInterface $logger): array - { - $logger->info('🔄 Starting data processing', ['input_count' => \count($data)]); - - $results = []; - $errors = []; - - foreach ($data as $index => $item) { - $logger->debug("Processing item $index", ['item' => $item]); - - try { - if (!\is_string($item) && !is_numeric($item)) { - throw new \InvalidArgumentException('Item must be string or numeric'); - } - - $processed = strtoupper((string) $item); - $results[] = $processed; - - $logger->debug("✅ Successfully processed item $index", [ - 'original' => $item, - 'processed' => $processed, - ]); - } catch (\Exception $e) { - $logger->error("❌ Failed to process item $index", [ - 'item' => $item, - 'error' => $e->getMessage(), - ]); - $errors[] = "Item $index: ".$e->getMessage(); - } - } - - if (empty($errors)) { - $logger->info('🎉 Data processing completed successfully', [ - 'processed_count' => \count($results), - ]); - } else { - $logger->warning('âš ī¸ Data processing completed with errors', [ - 'processed_count' => \count($results), - 'error_count' => \count($errors), - ]); - } - - return [ - 'processed_items' => $results, - 'errors' => $errors, - 'summary' => [ - 'total_input' => \count($data), - 'successful' => \count($results), - 'failed' => \count($errors), - ], - ]; - } - - /** - * Resource that provides logging configuration with auto-injected logger. - * - * @param McpLogger $logger Auto-injected MCP logger - * - * @return array - */ - #[McpResource( - uri: 'config://logging/settings', - name: 'logging_config', - description: 'Current logging configuration and auto-injection status.', - mimeType: 'application/json' - )] - public function getLoggingConfig(McpLogger $logger): array - { - $logger->info('📋 Retrieving logging configuration'); - - $config = [ - 'auto_injection' => 'enabled', - 'supported_types' => ['McpLogger', 'LoggerInterface'], - 'levels' => ['debug', 'info', 'warning', 'error'], - 'features' => [ - 'auto_injection', - 'mcp_transport', - 'fallback_logging', - 'structured_data', - ], - ]; - - $logger->debug('Configuration retrieved', $config); - - return $config; - } - - /** - * Prompt that generates logging examples with auto-injected logger. - * - * @param string $example_type Type of logging example to generate - * @param LoggerInterface $logger Auto-injected logger - * - * @return array - */ - #[McpPrompt(name: 'logging_examples', description: 'Generates logging code examples')] - public function generateLoggingExamples(string $example_type, LoggerInterface $logger): array - { - $logger->info('📝 Generating logging examples', ['type' => $example_type]); - - $examples = match (strtolower($example_type)) { - 'tool' => [ - 'title' => 'Tool Handler with Auto-Injected Logger', - 'code' => ' -#[McpTool(name: "my_tool")] -public function myTool(string $input, McpLogger $logger): array -{ - $logger->info("Tool called", ["input" => $input]); - // Your tool logic here - return ["result" => "processed"]; -}', - 'description' => 'McpLogger is automatically injected - no configuration needed!', - ], - - 'resource' => [ - 'title' => 'Resource Handler with Logger Interface', - 'code' => ' -#[McpResource(uri: "my://resource")] -public function getResource(LoggerInterface $logger): string -{ - $logger->debug("Resource accessed"); - return "resource content"; -}', - 'description' => 'Works with both McpLogger and LoggerInterface types', - ], - - 'function' => [ - 'title' => 'Function Handler with Auto-Injection', - 'code' => ' -function myHandler(array $params, McpLogger $logger): array -{ - $logger->warning("Function handler called"); - return $params; -}', - 'description' => 'Even function handlers get auto-injection!', - ], - - default => [ - 'title' => 'Basic Logging Pattern', - 'code' => ' -// Just declare McpLogger as a parameter -public function handler($data, McpLogger $logger) -{ - $logger->info("Handler started"); - // Auto-injected, no setup required! -}', - 'description' => 'The simplest way to get MCP logging', - ], - }; - - $logger->info('✅ Generated logging example', ['type' => $example_type]); - - return [ - 'prompt' => "Here's how to use auto-injected MCP logging:", - 'example' => $examples, - 'tips' => [ - 'Just add McpLogger or LoggerInterface as a parameter', - 'No configuration or setup required', - 'Logger is automatically provided by the MCP SDK', - 'Logs are sent to connected MCP clients', - 'Fallback logger used if MCP transport unavailable', - ], - ]; - } } diff --git a/examples/stdio-logging-showcase/server.php b/examples/stdio-logging-showcase/server.php index 60ac9af7..243f3ce0 100644 --- a/examples/stdio-logging-showcase/server.php +++ b/examples/stdio-logging-showcase/server.php @@ -33,6 +33,3 @@ logger()->info('Logging Showcase Server is ready!'); logger()->info('This example demonstrates auto-injection of McpLogger into capability handlers.'); -logger()->info('Available tools: log_message, process_data'); -logger()->info('Available resources: config://logging/settings'); -logger()->info('Available prompts: logging_examples'); From 11cfb167e078fe8c105a816969bd824c42619fd3 Mon Sep 17 00:00:00 2001 From: Adam Jamiu Date: Wed, 15 Oct 2025 03:44:01 +0100 Subject: [PATCH 10/14] Simplified docs --- README.md | 50 +++--- docs/mcp-logging.md | 393 -------------------------------------------- 2 files changed, 25 insertions(+), 418 deletions(-) delete mode 100644 docs/mcp-logging.md diff --git a/README.md b/README.md index c34a97b2..75d0fb27 100644 --- a/README.md +++ b/README.md @@ -229,6 +229,31 @@ $server = Server::builder() ->build(); ``` +### Logging + +Automatically inject PSR-3 compatible logger into your registered handlers: + +```php +// Enable logging in server +$server = Server::builder() + ->enableMcpLogging() + ->build(); + +// Use in any handler - logger is auto-injected +#[McpTool] +public function processData(string $input, McpLogger $logger): array { + $logger->info('Processing data', ['input' => $input]); + return ['result' => 'processed']; +} + +// Also works with PSR-3 LoggerInterface +#[McpResource(uri: 'data://config')] +public function getConfig(LoggerInterface $logger): array { + $logger->info('Config accessed'); + return ['setting' => 'value']; +} +``` + ## Documentation **Core Concepts:** @@ -239,33 +264,8 @@ $server = Server::builder() **Learning:** - [Examples](docs/examples.md) - Comprehensive example walkthroughs -### MCP Logging - -The SDK provides comprehensive logging capabilities following the [MCP logging specification](https://modelcontextprotocol.io/specification/2025-06-18/server/utilities/logging): - -- **Auto-injection**: `McpLogger` automatically injected into capability handlers -- **Client-controlled filtering**: Clients can set log levels to control verbosity -- **Centralized logging**: All server logs flow to client for unified debugging -- **Fallback support**: Compatible with existing PSR-3 loggers - -**Quick example:** -```php -#[McpTool(name: 'my_tool')] -public function myTool(string $input, McpLogger $logger): array { - $logger->info('Tool called', ['input' => $input]); - return ['result' => 'processed']; -} - -// Enable in server -$server = Server::builder() - ->enableMcpLogging() // Enable centralized logging - ->build(); -``` - -📖 **[Complete MCP Logging Guide](docs/mcp-logging.md)** **External Resources:** -- [SDK documentation](doc/index.rst) - [Model Context Protocol documentation](https://modelcontextprotocol.io) - [Model Context Protocol specification](https://spec.modelcontextprotocol.io) - [Officially supported servers](https://github.com/modelcontextprotocol/servers) diff --git a/docs/mcp-logging.md b/docs/mcp-logging.md deleted file mode 100644 index b80ac1cc..00000000 --- a/docs/mcp-logging.md +++ /dev/null @@ -1,393 +0,0 @@ -# MCP Logging - -This document describes how to use the Model Context Protocol (MCP) logging capabilities in the PHP SDK. - -## Overview - -The MCP logging implementation provides centralized logging capabilities that allow clients to receive and filter log messages from servers. This is particularly useful when working with multiple MCP servers, as it enables unified debugging from a single client interface. - -## Key Features - -- **Auto-injection**: `McpLogger` is automatically injected into capability handlers -- **Client-controlled filtering**: Clients can set log levels to control message verbosity -- **Centralized logging**: All server logs flow to the client via MCP notifications -- **Fallback support**: Compatible with existing PSR-3 loggers for local debugging -- **Zero configuration**: Works out of the box with minimal setup - -## Quick Start - -### 1. Enable MCP Logging - -```php -use Mcp\Server; - -$server = Server::builder() - ->setServerInfo('My Server', '1.0.0') - ->enableMcpLogging() // Enable MCP logging capability - ->build(); -``` - -### 2. Use Auto-injected Logger in Handlers - -The `McpLogger` is automatically injected into any capability handler that declares it as a parameter: - -```php -use Mcp\Capability\Attribute\McpTool; -use Mcp\Capability\Logger\McpLogger; - -class MyHandlers -{ - #[McpTool(name: 'process_data')] - public function processData(array $data, McpLogger $logger): array - { - $logger->info('Processing data', ['count' => count($data)]); - - try { - $result = $this->performProcessing($data); - $logger->debug('Processing completed successfully'); - return $result; - } catch (\Exception $e) { - $logger->error('Processing failed', ['error' => $e->getMessage()]); - throw $e; - } - } -} -``` - -## Auto-injection - -The MCP SDK automatically injects loggers into capability handlers when you declare them as parameters. This works for: - -- **Tools** (`#[McpTool]`) -- **Resources** (`#[McpResource]`) -- **Prompts** (`#[McpPrompt]`) - -### Supported Logger Types - -You can use either type for auto-injection: - -```php -use Mcp\Capability\Logger\McpLogger; -use Psr\Log\LoggerInterface; - -// MCP-specific logger (recommended) -public function myTool(string $input, McpLogger $logger): array -{ - $logger->info('Tool called', ['input' => $input]); - // Logs are sent to client via MCP notifications - return ['result' => 'processed']; -} - -// PSR-3 compatible interface -public function myTool(string $input, LoggerInterface $logger): array -{ - $logger->info('Tool called', ['input' => $input]); - // Will receive McpLogger instance that implements LoggerInterface - return ['result' => 'processed']; -} -``` - -## Log Levels - -The implementation supports all RFC-5424 syslog severity levels: - -```php -$logger->emergency('System is unusable'); -$logger->alert('Action must be taken immediately'); -$logger->critical('Critical conditions'); -$logger->error('Error conditions'); -$logger->warning('Warning conditions'); -$logger->notice('Normal but significant condition'); -$logger->info('Informational messages'); -$logger->debug('Debug-level messages'); -``` - -### Client Log Level Control - -Clients can control which log levels they receive by sending a `logging/setLevel` request: - -```json -{ - "jsonrpc": "2.0", - "id": 1, - "method": "logging/setLevel", - "params": { - "level": "warning" - } -} -``` - -When a log level is set, only messages at that level and higher (more severe) will be sent to the client. - -## Advanced Usage - -### Fallback Logging - -You can provide a fallback PSR-3 logger for local debugging: - -```php -use Mcp\Server; -use Psr\Log\LoggerInterface; - -$server = Server::builder() - ->setServerInfo('My Server', '1.0.0') - ->setLogger($myPsr3Logger) // Fallback logger for local debugging - ->enableMcpLogging() // MCP logging for client notifications - ->build(); -``` - -With this setup: -- Log messages are sent to the client via MCP notifications -- Log messages are also written to your local PSR-3 logger for server-side debugging - -### Custom Container Integration - -If you're using a DI container, the MCP logger works seamlessly: - -```php -use Mcp\Server; -use Psr\Log\LoggerInterface; - -$container = new MyContainer(); -$container->set(LoggerInterface::class, $myCustomLogger); - -$server = Server::builder() - ->setContainer($container) - ->enableMcpLogging() - ->build(); -``` - -## Examples - -### Basic Tool with Logging - -```php -use Mcp\Capability\Attribute\McpTool; -use Mcp\Capability\Logger\McpLogger; - -#[McpTool(name: 'calculate', description: 'Performs mathematical calculations')] -public function calculate(float $a, float $b, string $operation, McpLogger $logger): array -{ - $logger->info('Calculation requested', [ - 'operand_a' => $a, - 'operand_b' => $b, - 'operation' => $operation - ]); - - switch ($operation) { - case 'add': - $result = $a + $b; - break; - case 'divide': - if ($b == 0) { - $logger->error('Division by zero attempted', ['operand_b' => $b]); - throw new \InvalidArgumentException('Cannot divide by zero'); - } - $result = $a / $b; - break; - default: - $logger->warning('Unknown operation requested', ['operation' => $operation]); - throw new \InvalidArgumentException("Unknown operation: $operation"); - } - - $logger->debug('Calculation completed', ['result' => $result]); - return ['result' => $result]; -} -``` - -### Resource with Logging - -```php -use Mcp\Capability\Attribute\McpResource; -use Mcp\Capability\Logger\McpLogger; - -#[McpResource( - uri: 'config://app/settings', - name: 'app_config', - description: 'Application configuration' -)] -public function getConfig(McpLogger $logger): array -{ - $logger->debug('Configuration accessed'); - - $config = $this->loadConfiguration(); - - $logger->info('Configuration loaded', [ - 'settings_count' => count($config), - 'last_modified' => $config['metadata']['last_modified'] ?? 'unknown' - ]); - - return $config; -} -``` - -### Error Handling with Logging - -```php -use Mcp\Capability\Attribute\McpTool; -use Mcp\Capability\Logger\McpLogger; - -#[McpTool(name: 'fetch_data')] -public function fetchData(string $url, McpLogger $logger): array -{ - $logger->info('Starting data fetch', ['url' => $url]); - - try { - $data = $this->httpClient->get($url); - $logger->debug('HTTP request successful', [ - 'url' => $url, - 'response_size' => strlen($data) - ]); - - $parsed = json_decode($data, true); - if (json_last_error() !== JSON_ERROR_NONE) { - $logger->error('JSON parsing failed', [ - 'url' => $url, - 'json_error' => json_last_error_msg() - ]); - throw new \RuntimeException('Invalid JSON response'); - } - - $logger->info('Data fetch completed successfully', [ - 'url' => $url, - 'items_count' => count($parsed) - ]); - - return $parsed; - - } catch (\Exception $e) { - $logger->critical('Data fetch failed', [ - 'url' => $url, - 'error' => $e->getMessage(), - 'exception_class' => get_class($e) - ]); - throw $e; - } -} -``` - -## MCP Notification Format - -Log messages are sent to clients as MCP notifications following this format: - -```json -{ - "jsonrpc": "2.0", - "method": "notifications/message", - "params": { - "level": "info", - "data": "Processing completed successfully", - "logger": "MyService" - } -} -``` - -Where: -- `level`: The log level (debug, info, notice, warning, error, critical, alert, emergency) -- `data`: The log message (string or structured data) -- `logger`: Optional logger name for message categorization - -## Best Practices - -### 1. Use Structured Logging - -Include context data with your log messages: - -```php -$logger->info('User action performed', [ - 'user_id' => $userId, - 'action' => 'file_upload', - 'file_size' => $fileSize, - 'duration_ms' => $duration -]); -``` - -### 2. Choose Appropriate Log Levels - -- **Debug**: Detailed diagnostic information -- **Info**: General operational messages -- **Notice**: Significant but normal events -- **Warning**: Something unexpected happened but the application continues -- **Error**: Error occurred but application can continue -- **Critical**: Critical error that might cause the application to abort -- **Alert**: Action must be taken immediately -- **Emergency**: System is unusable - -### 3. Avoid Logging Sensitive Data - -Never log passwords, API keys, or personal information: - -```php -// ❌ Bad - logs sensitive data -$logger->info('User login', ['password' => $password]); - -// ✅ Good - logs safely -$logger->info('User login attempt', ['username' => $username]); -``` - -### 4. Use Logger Names for Organization - -When working with complex applications, use logger names to categorize messages: - -```php -public function processPayment(array $data, McpLogger $logger): array -{ - // The logger will include context about which handler generated the log - $logger->info('Payment processing started', ['amount' => $data['amount']]); -} -``` - -## Troubleshooting - -### Logs Not Appearing in Client - -1. **Check if logging is enabled**: Ensure `->enableMcpLogging()` is called -2. **Verify log level**: Client might have set a higher log level threshold -3. **Check transport**: Ensure MCP transport is properly connected - -### Auto-injection Not Working - -1. **Parameter type**: Ensure parameter is typed as `McpLogger` or `LoggerInterface` -2. **Method signature**: Verify the parameter is in the method signature -3. **Builder configuration**: Confirm `->enableMcpLogging()` is called - -### Performance Considerations - -- Log messages are sent over the MCP transport, so avoid excessive debug logging in production -- Use appropriate log levels to allow clients to filter noise -- Consider the size of structured data in log messages - -## Migration from Existing Loggers - -If you're already using PSR-3 loggers, migration is straightforward: - -```php -use Mcp\Capability\Attribute\McpTool; -use Psr\Log\LoggerInterface; - -// Before: Manual logger injection -class MyService -{ - public function __construct(private LoggerInterface $logger) {} - - #[McpTool(name: 'my_tool')] - public function myTool(string $input): array - { - $this->logger->info('Tool called'); - return ['result' => 'processed']; - } -} - -// After: Auto-injection (remove constructor, add parameter) -class MyService -{ - #[McpTool(name: 'my_tool')] - public function myTool(string $input, LoggerInterface $logger): array - { - $logger->info('Tool called'); - return ['result' => 'processed']; - } -} -``` - -The MCP logger implements the PSR-3 `LoggerInterface`, so your existing logging calls will work without changes. \ No newline at end of file From 4614dc5270510dd1123c4d2c05804263b1158740 Mon Sep 17 00:00:00 2001 From: Adam Jamiu Date: Wed, 15 Oct 2025 04:15:34 +0100 Subject: [PATCH 11/14] Renamed McpLogger to ClientLogger --- README.md | 4 +- .../McpElements.php | 37 +++++++++---------- .../stdio-discovery-calculator/server.php | 2 +- .../LoggingShowcaseHandlers.php | 10 ++--- examples/stdio-logging-showcase/server.php | 4 +- src/Capability/Discovery/SchemaGenerator.php | 6 +-- .../{McpLogger.php => ClientLogger.php} | 2 +- src/Capability/Registry/ReferenceHandler.php | 22 +++++------ src/Server/Builder.php | 15 ++++---- .../Discovery/SchemaGeneratorFixture.php | 6 +-- .../Discovery/SchemaGeneratorTest.php | 4 +- ...McpLoggerTest.php => ClientLoggerTest.php} | 18 ++++----- tests/Unit/Server/BuilderLoggingTest.php | 20 +++++----- 13 files changed, 74 insertions(+), 76 deletions(-) rename src/Capability/Logger/{McpLogger.php => ClientLogger.php} (98%) rename tests/Unit/Capability/Logger/{McpLoggerTest.php => ClientLoggerTest.php} (86%) diff --git a/README.md b/README.md index 75d0fb27..1a2edfb7 100644 --- a/README.md +++ b/README.md @@ -236,12 +236,12 @@ Automatically inject PSR-3 compatible logger into your registered handlers: ```php // Enable logging in server $server = Server::builder() - ->enableMcpLogging() + ->enableClientLogging() ->build(); // Use in any handler - logger is auto-injected #[McpTool] -public function processData(string $input, McpLogger $logger): array { +public function processData(string $input, ClientLogger $logger): array { $logger->info('Processing data', ['input' => $input]); return ['result' => 'processed']; } diff --git a/examples/stdio-discovery-calculator/McpElements.php b/examples/stdio-discovery-calculator/McpElements.php index b3949262..69061f40 100644 --- a/examples/stdio-discovery-calculator/McpElements.php +++ b/examples/stdio-discovery-calculator/McpElements.php @@ -13,7 +13,9 @@ use Mcp\Capability\Attribute\McpResource; use Mcp\Capability\Attribute\McpTool; -use Mcp\Capability\Logger\McpLogger; +use Mcp\Capability\Logger\ClientLogger; +use Psr\Log\LoggerInterface; +use Psr\Log\NullLogger; /** * @phpstan-type Config array{precision: int, allow_negative: bool} @@ -28,6 +30,11 @@ final class McpElements 'allow_negative' => true, ]; + public function __construct( + private readonly LoggerInterface $logger = new NullLogger(), + ) { + } + /** * Performs a calculation based on the operation. * @@ -37,19 +44,14 @@ final class McpElements * @param float $a the first operand * @param float $b the second operand * @param string $operation the operation ('add', 'subtract', 'multiply', 'divide') - * @param McpLogger $logger Auto-injected MCP logger + * @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, McpLogger $logger): float|string + public function calculate(float $a, float $b, string $operation, ClientLogger $logger): float|string { - $logger->info('🧮 Calculate tool called', [ - 'operand_a' => $a, - 'operand_b' => $b, - 'operation' => $operation, - 'auto_injection' => 'McpLogger auto-injected successfully', - ]); + $this->logger->info(\sprintf('Calculating: %f %s %f', $a, $operation, $b)); $op = strtolower($operation); @@ -105,7 +107,7 @@ public function calculate(float $a, float $b, string $operation, McpLogger $logg * Provides the current calculator configuration. * Can be read by clients to understand precision etc. * - * @param McpLogger $logger Auto-injected MCP logger for demonstration + * @param ClientLogger $logger Auto-injected MCP logger for demonstration * * @return Config the configuration array */ @@ -115,11 +117,11 @@ public function calculate(float $a, float $b, string $operation, McpLogger $logg description: 'Current settings for the calculator tool (precision, allow_negative).', mimeType: 'application/json', )] - public function getConfiguration(McpLogger $logger): array + public function getConfiguration(ClientLogger $logger): array { $logger->info('📊 Resource config://calculator/settings accessed via auto-injection!', [ 'current_config' => $this->config, - 'auto_injection_demo' => 'McpLogger was automatically injected into this resource handler', + 'auto_injection_demo' => 'ClientLogger was automatically injected into this resource handler', ]); return $this->config; @@ -131,7 +133,7 @@ public function getConfiguration(McpLogger $logger): array * * @param string $setting the setting key ('precision' or 'allow_negative') * @param mixed $value the new value (int for precision, bool for allow_negative) - * @param McpLogger $logger Auto-injected MCP logger + * @param ClientLogger $logger Auto-injected MCP logger * * @return array{ * success: bool, @@ -140,14 +142,9 @@ public function getConfiguration(McpLogger $logger): array * } success message or error */ #[McpTool(name: 'update_setting')] - public function updateSetting(string $setting, mixed $value, McpLogger $logger): array + public function updateSetting(string $setting, mixed $value, ClientLogger $logger): array { - $logger->info('🔧 Update setting tool called', [ - 'setting' => $setting, - 'value' => $value, - 'current_config' => $this->config, - 'auto_injection' => 'McpLogger auto-injected successfully', - ]); + $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, diff --git a/examples/stdio-discovery-calculator/server.php b/examples/stdio-discovery-calculator/server.php index 7bf48406..147d86f9 100644 --- a/examples/stdio-discovery-calculator/server.php +++ b/examples/stdio-discovery-calculator/server.php @@ -23,7 +23,7 @@ ->setInstructions('This server supports basic arithmetic operations: add, subtract, multiply, and divide. Send JSON-RPC requests to perform calculations.') ->setContainer(container()) ->setLogger(logger()) - ->enableMcpLogging() // Enable MCP logging capability and auto-injection! + ->enableClientLogging() // Enable Client logging capability and auto-injection! ->setDiscovery(__DIR__, ['.']) ->build(); diff --git a/examples/stdio-logging-showcase/LoggingShowcaseHandlers.php b/examples/stdio-logging-showcase/LoggingShowcaseHandlers.php index 0a6330a3..c3e4bc60 100644 --- a/examples/stdio-logging-showcase/LoggingShowcaseHandlers.php +++ b/examples/stdio-logging-showcase/LoggingShowcaseHandlers.php @@ -12,27 +12,27 @@ namespace Mcp\Example\StdioLoggingShowcase; use Mcp\Capability\Attribute\McpTool; -use Mcp\Capability\Logger\McpLogger; +use Mcp\Capability\Logger\ClientLogger; /** * Example handlers showcasing auto-injected MCP logging capabilities. * - * This demonstrates how handlers can receive McpLogger automatically + * This demonstrates how handlers can receive ClientLogger automatically * without any manual configuration - just declare it as a parameter! */ final class LoggingShowcaseHandlers { /** - * Tool that demonstrates different logging levels with auto-injected McpLogger. + * Tool that demonstrates different logging levels with auto-injected ClientLogger. * * @param string $message The message to log * @param string $level The logging level (debug, info, warning, error) - * @param McpLogger $logger Auto-injected MCP logger + * @param ClientLogger $logger Auto-injected MCP logger * * @return array */ #[McpTool(name: 'log_message', description: 'Demonstrates MCP logging with different levels')] - public function logMessage(string $message, string $level, McpLogger $logger): array + public function logMessage(string $message, string $level, ClientLogger $logger): array { $logger->info('🚀 Starting log_message tool', [ 'requested_level' => $level, diff --git a/examples/stdio-logging-showcase/server.php b/examples/stdio-logging-showcase/server.php index 243f3ce0..9953aa12 100644 --- a/examples/stdio-logging-showcase/server.php +++ b/examples/stdio-logging-showcase/server.php @@ -23,7 +23,7 @@ ->setServerInfo('Stdio Logging Showcase', '1.0.0', 'Demonstration of auto-injected MCP logging in capability handlers.') ->setContainer(container()) ->setLogger(logger()) - ->enableMcpLogging() // Enable MCP logging capability and auto-injection! + ->enableClientLogging() // Enable MCP logging capability and auto-injection! ->setDiscovery(__DIR__, ['.']) ->build(); @@ -32,4 +32,4 @@ $server->run($transport); logger()->info('Logging Showcase Server is ready!'); -logger()->info('This example demonstrates auto-injection of McpLogger into capability handlers.'); +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 a1f694e4..a90203c4 100644 --- a/src/Capability/Discovery/SchemaGenerator.php +++ b/src/Capability/Discovery/SchemaGenerator.php @@ -792,7 +792,7 @@ private function mapSimpleTypeToJsonSchema(string $type): string /** * Determines if a parameter was auto-injected and should be excluded from schema generation. * - * Parameters that are auto-injected by the framework (like McpLogger) should not appear + * 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 @@ -805,8 +805,8 @@ private function isAutoInjectedParameter(\ReflectionParameter $parameter): bool $typeName = $type->getName(); - // Auto-inject for McpLogger or LoggerInterface types - return 'Mcp\\Capability\\Logger\\McpLogger' === $typeName + // Auto-inject for ClientLogger or LoggerInterface types + return 'Mcp\\Capability\\Logger\\ClientLogger' === $typeName || 'Psr\\Log\\LoggerInterface' === $typeName; } } diff --git a/src/Capability/Logger/McpLogger.php b/src/Capability/Logger/ClientLogger.php similarity index 98% rename from src/Capability/Logger/McpLogger.php rename to src/Capability/Logger/ClientLogger.php index c5d5e143..1fb6d6fb 100644 --- a/src/Capability/Logger/McpLogger.php +++ b/src/Capability/Logger/ClientLogger.php @@ -25,7 +25,7 @@ * * @author Adam Jamiu */ -final class McpLogger extends AbstractLogger +final class ClientLogger extends AbstractLogger { public function __construct( private readonly NotificationSender $notificationSender, diff --git a/src/Capability/Registry/ReferenceHandler.php b/src/Capability/Registry/ReferenceHandler.php index a0362631..bb8c8ebe 100644 --- a/src/Capability/Registry/ReferenceHandler.php +++ b/src/Capability/Registry/ReferenceHandler.php @@ -11,7 +11,7 @@ namespace Mcp\Capability\Registry; -use Mcp\Capability\Logger\McpLogger; +use Mcp\Capability\Logger\ClientLogger; use Mcp\Exception\InvalidArgumentException; use Mcp\Exception\RegistryException; use Psr\Container\ContainerInterface; @@ -23,7 +23,7 @@ final class ReferenceHandler implements ReferenceHandlerInterface { public function __construct( private readonly ?ContainerInterface $container = null, - private readonly ?McpLogger $mcpLogger = null, + private readonly ?ClientLogger $clientLogger = null, ) { } @@ -91,16 +91,16 @@ private function prepareArguments(\ReflectionFunctionAbstract $reflection, array $paramName = $parameter->getName(); $paramPosition = $parameter->getPosition(); - // Auto-inject McpLogger if parameter expects it - if ($this->shouldInjectMcpLogger($parameter)) { - if (null !== $this->mcpLogger) { - $finalArgs[$paramPosition] = $this->mcpLogger; + // 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 McpLogger is required but not available, fall through to normal handling + // If ClientLogger is required but not available, fall through to normal handling } if (isset($arguments[$paramName])) { @@ -130,9 +130,9 @@ private function prepareArguments(\ReflectionFunctionAbstract $reflection, array } /** - * Determines if the parameter should receive auto-injected McpLogger. + * Determines if the parameter should receive auto-injected ClientLogger. */ - private function shouldInjectMcpLogger(\ReflectionParameter $parameter): bool + private function shouldInjectClientLogger(\ReflectionParameter $parameter): bool { $type = $parameter->getType(); @@ -142,8 +142,8 @@ private function shouldInjectMcpLogger(\ReflectionParameter $parameter): bool $typeName = $type->getName(); - // Auto-inject for McpLogger or LoggerInterface types - return McpLogger::class === $typeName || \Psr\Log\LoggerInterface::class === $typeName; + // Auto-inject for ClientLogger or LoggerInterface types + return ClientLogger::class === $typeName || \Psr\Log\LoggerInterface::class === $typeName; } /** diff --git a/src/Server/Builder.php b/src/Server/Builder.php index b762dba8..dae1a6d6 100644 --- a/src/Server/Builder.php +++ b/src/Server/Builder.php @@ -20,7 +20,7 @@ use Mcp\Capability\Discovery\DocBlockParser; use Mcp\Capability\Discovery\HandlerResolver; use Mcp\Capability\Discovery\SchemaGenerator; -use Mcp\Capability\Logger\McpLogger; +use Mcp\Capability\Logger\ClientLogger; use Mcp\Capability\Registry; use Mcp\Capability\Registry\Container; use Mcp\Capability\Registry\ElementReference; @@ -241,13 +241,13 @@ public function addNotificationHandlers(iterable $handlers): self } /** - * Enables MCP logging capability for the server. + * Enables Client logging capability for the server. * * When enabled, the server will advertise logging capability to clients, * indicating that it can emit structured log messages according to the MCP specification. - * This enables auto-injection of McpLogger into capability handlers. + * This enables auto-injection of ClientLogger into capability handlers. */ - public function enableMcpLogging(): self + public function enableClientLogging(): self { $this->loggingMessageNotificationEnabled = true; @@ -421,9 +421,9 @@ public function build(): Server $notificationHandler = NotificationHandler::make($registry, $logger); $notificationSender = new NotificationSender($notificationHandler, null, $logger); - // Create McpLogger for components that should send logs via MCP - $mcpLogger = new McpLogger($notificationSender, $logger); - $referenceHandler = new ReferenceHandler($container, $mcpLogger); + // 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(), @@ -448,6 +448,7 @@ public function build(): Server messageFactory: $messageFactory, sessionFactory: $sessionFactory, sessionStore: $sessionStore, + logger: $logger, ); return new Server($protocol, $notificationSender, $logger); diff --git a/tests/Unit/Capability/Discovery/SchemaGeneratorFixture.php b/tests/Unit/Capability/Discovery/SchemaGeneratorFixture.php index 4994cec3..c42cfcb4 100644 --- a/tests/Unit/Capability/Discovery/SchemaGeneratorFixture.php +++ b/tests/Unit/Capability/Discovery/SchemaGeneratorFixture.php @@ -414,12 +414,12 @@ public function parameterSchemaInferredType( } /** - * Method with McpLogger that should be excluded from schema. + * Method with ClientLogger that should be excluded from schema. * * @param string $message The message to process - * @param \Mcp\Capability\Logger\McpLogger $logger Auto-injected logger + * @param \Mcp\Capability\Logger\ClientLogger $logger Auto-injected logger */ - public function withMcpLogger(string $message, \Mcp\Capability\Logger\McpLogger $logger): string + 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 89ce167e..29870837 100644 --- a/tests/Unit/Capability/Discovery/SchemaGeneratorTest.php +++ b/tests/Unit/Capability/Discovery/SchemaGeneratorTest.php @@ -328,9 +328,9 @@ public function testInfersParameterTypeAsAnyIfOnlyConstraintsAreGiven() $this->assertEquals(['inferredParam'], $schema['required']); } - public function testExcludesMcpLoggerFromSchema() + public function testExcludesClientLoggerFromSchema() { - $method = new \ReflectionMethod(SchemaGeneratorFixture::class, 'withMcpLogger'); + $method = new \ReflectionMethod(SchemaGeneratorFixture::class, 'withClientLogger'); $schema = $this->schemaGenerator->generate($method); // Should include the message parameter diff --git a/tests/Unit/Capability/Logger/McpLoggerTest.php b/tests/Unit/Capability/Logger/ClientLoggerTest.php similarity index 86% rename from tests/Unit/Capability/Logger/McpLoggerTest.php rename to tests/Unit/Capability/Logger/ClientLoggerTest.php index 5c441565..a732b76d 100644 --- a/tests/Unit/Capability/Logger/McpLoggerTest.php +++ b/tests/Unit/Capability/Logger/ClientLoggerTest.php @@ -11,7 +11,7 @@ namespace Tests\Unit\Capability\Logger; -use Mcp\Capability\Logger\McpLogger; +use Mcp\Capability\Logger\ClientLogger; use Mcp\Capability\Registry\ReferenceProviderInterface; use Mcp\Server\Handler\NotificationHandler; use Mcp\Server\NotificationSender; @@ -20,11 +20,11 @@ use Psr\Log\LoggerInterface; /** - * Test for simplified McpLogger PSR-3 compliance. + * Test for simplified ClientLogger PSR-3 compliance. * * @author Adam Jamiu */ -final class McpLoggerTest extends TestCase +final class ClientLoggerTest extends TestCase { private LoggerInterface&MockObject $fallbackLogger; @@ -35,7 +35,7 @@ protected function setUp(): void public function testImplementsPsr3LoggerInterface(): void { - $logger = $this->createMcpLogger(); + $logger = $this->createClientLogger(); $this->assertInstanceOf(LoggerInterface::class, $logger); } @@ -46,13 +46,13 @@ public function testAlwaysLogsToFallbackLogger(): void ->method('log') ->with('info', 'Test message', ['key' => 'value']); - $logger = $this->createMcpLogger(); + $logger = $this->createClientLogger(); $logger->info('Test message', ['key' => 'value']); } public function testBasicLoggingMethodsWork(): void { - $logger = $this->createMcpLogger(); + $logger = $this->createClientLogger(); // Test all PSR-3 methods exist and can be called $this->fallbackLogger->expects($this->exactly(8))->method('log'); @@ -80,11 +80,11 @@ public function testHandlesMcpSendGracefully(): void ->expects($this->atMost(1)) ->method('error'); - $logger = $this->createMcpLogger(); + $logger = $this->createClientLogger(); $logger->info('Test message'); } - private function createMcpLogger(): McpLogger + private function createClientLogger(): ClientLogger { // Create minimal working NotificationSender for testing // Using a minimal ReferenceProvider mock just to construct NotificationHandler @@ -92,7 +92,7 @@ private function createMcpLogger(): McpLogger $notificationHandler = NotificationHandler::make($referenceProvider); $notificationSender = new NotificationSender($notificationHandler, null); - return new McpLogger( + return new ClientLogger( $notificationSender, $this->fallbackLogger ); diff --git a/tests/Unit/Server/BuilderLoggingTest.php b/tests/Unit/Server/BuilderLoggingTest.php index 98a9df73..e2deb10a 100644 --- a/tests/Unit/Server/BuilderLoggingTest.php +++ b/tests/Unit/Server/BuilderLoggingTest.php @@ -36,17 +36,17 @@ public function testLoggingDisabledByDefault(): void $this->assertInstanceOf(Server::class, $server); } - public function testEnableMcpLoggingConfiguresBuilder(): void + public function testEnableClientLoggingConfiguresBuilder(): void { $builder = new Builder(); - $result = $builder->enableMcpLogging(); + $result = $builder->enableClientLogging(); // Test method chaining - $this->assertSame($builder, $result, 'enableMcpLogging should return builder for chaining'); + $this->assertSame($builder, $result, 'enableClientLogging should return builder for chaining'); // Test internal state - $this->assertTrue($this->getBuilderLoggingState($builder), 'enableMcpLogging should set internal flag'); + $this->assertTrue($this->getBuilderLoggingState($builder), 'enableClientLogging should set internal flag'); // Test server builds successfully $server = $builder->setServerInfo('Test Server', '1.0.0')->build(); @@ -57,9 +57,9 @@ public function testMultipleEnableCallsAreIdempotent(): void { $builder = new Builder(); - $builder->enableMcpLogging() - ->enableMcpLogging() - ->enableMcpLogging(); + $builder->enableClientLogging() + ->enableClientLogging() + ->enableClientLogging(); $this->assertTrue($this->getBuilderLoggingState($builder), 'Multiple enable calls should maintain enabled state'); } @@ -67,7 +67,7 @@ public function testMultipleEnableCallsAreIdempotent(): void public function testLoggingStatePreservedAcrossBuilds(): void { $builder = new Builder(); - $builder->setServerInfo('Test Server', '1.0.0')->enableMcpLogging(); + $builder->setServerInfo('Test Server', '1.0.0')->enableClientLogging(); $server1 = $builder->build(); $server2 = $builder->build(); @@ -87,7 +87,7 @@ public function testLoggingWithOtherBuilderConfiguration(): void $server = $builder ->setServerInfo('Test Server', '1.0.0', 'Test description') ->setLogger($logger) - ->enableMcpLogging() + ->enableClientLogging() ->setPaginationLimit(50) ->addTool(fn () => 'test', 'test_tool', 'Test tool') ->build(); @@ -101,7 +101,7 @@ public function testIndependentBuilderInstances(): void $builderWithLogging = new Builder(); $builderWithoutLogging = new Builder(); - $builderWithLogging->enableMcpLogging(); + $builderWithLogging->enableClientLogging(); // Don't enable on second builder $this->assertTrue($this->getBuilderLoggingState($builderWithLogging), 'First builder should have logging enabled'); From e21a81dab0bfcb9322e66454873e755b916fc1c8 Mon Sep 17 00:00:00 2001 From: Adam Jamiu Date: Sun, 19 Oct 2025 22:56:39 +0100 Subject: [PATCH 12/14] Enabled logging by default --- README.md | 25 ---- docs/mcp-elements.md | 50 +++++++ docs/server-builder.md | 1 + .../McpElements.php | 11 +- .../stdio-discovery-calculator/server.php | 3 +- examples/stdio-logging-showcase/server.php | 3 +- src/Capability/Discovery/SchemaGenerator.php | 2 +- src/Capability/Registry.php | 28 ++-- .../Registry/ReferenceProviderInterface.php | 31 ---- .../Registry/ReferenceRegistryInterface.php | 28 ++++ src/Server/Builder.php | 18 +-- .../LoggingMessageNotificationHandler.php | 17 +-- src/Server/Handler/NotificationHandler.php | 6 +- .../Handler/Request/SetLogLevelHandler.php | 6 +- .../Capability/Logger/ClientLoggerTest.php | 8 +- .../Unit/Capability/Registry/RegistryTest.php | 2 +- tests/Unit/Capability/RegistryLoggingTest.php | 132 ++++++------------ tests/Unit/Server/BuilderLoggingTest.php | 66 ++------- .../LoggingMessageNotificationHandlerTest.php | 68 ++++----- .../Request/SetLogLevelHandlerTest.php | 75 +--------- tests/Unit/Server/NotificationSenderTest.php | 61 +++----- tests/Unit/ServerTest.php | 4 +- 22 files changed, 235 insertions(+), 410 deletions(-) diff --git a/README.md b/README.md index 1a2edfb7..c7c64c23 100644 --- a/README.md +++ b/README.md @@ -229,31 +229,6 @@ $server = Server::builder() ->build(); ``` -### Logging - -Automatically inject PSR-3 compatible logger into your registered handlers: - -```php -// Enable logging in server -$server = Server::builder() - ->enableClientLogging() - ->build(); - -// Use in any handler - logger is auto-injected -#[McpTool] -public function processData(string $input, ClientLogger $logger): array { - $logger->info('Processing data', ['input' => $input]); - return ['result' => 'processed']; -} - -// Also works with PSR-3 LoggerInterface -#[McpResource(uri: 'data://config')] -public function getConfig(LoggerInterface $logger): array { - $logger->info('Config accessed'); - return ['setting' => 'value']; -} -``` - ## Documentation **Core Concepts:** 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 69061f40..ace68d62 100644 --- a/examples/stdio-discovery-calculator/McpElements.php +++ b/examples/stdio-discovery-calculator/McpElements.php @@ -32,8 +32,7 @@ final class McpElements public function __construct( private readonly LoggerInterface $logger = new NullLogger(), - ) { - } + ) {} /** * Performs a calculation based on the operation. @@ -95,7 +94,7 @@ public function calculate(float $a, float $b, string $operation, ClientLogger $l } $finalResult = round($result, $this->config['precision']); - $logger->info('✅ Calculation completed successfully', [ + $logger->info('Calculation completed successfully', [ 'result' => $finalResult, 'precision' => $this->config['precision'], ]); @@ -164,7 +163,7 @@ public function updateSetting(string $setting, mixed $value, ClientLogger $logge return ['success' => false, 'error' => 'Invalid precision value. Must be integer between 0 and 10.']; } $this->config['precision'] = $value; - $logger->info('✅ Precision setting updated', [ + $logger->info('Precision setting updated', [ 'new_precision' => $value, 'previous_config' => $this->config, ]); @@ -190,12 +189,12 @@ public function updateSetting(string $setting, mixed $value, ClientLogger $logge } } $this->config['allow_negative'] = $value; - $logger->info('✅ Allow negative setting updated', [ + $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').'.']; + return ['success' => true, 'message' => 'Allow negative results set to ' . ($value ? 'true' : 'false') . '.']; } } diff --git a/examples/stdio-discovery-calculator/server.php b/examples/stdio-discovery-calculator/server.php index 147d86f9..9e53fb7d 100644 --- a/examples/stdio-discovery-calculator/server.php +++ b/examples/stdio-discovery-calculator/server.php @@ -10,7 +10,7 @@ * file that was distributed with this source code. */ -require_once dirname(__DIR__).'/bootstrap.php'; +require_once dirname(__DIR__) . '/bootstrap.php'; chdir(__DIR__); use Mcp\Server; @@ -23,7 +23,6 @@ ->setInstructions('This server supports basic arithmetic operations: add, subtract, multiply, and divide. Send JSON-RPC requests to perform calculations.') ->setContainer(container()) ->setLogger(logger()) - ->enableClientLogging() // Enable Client logging capability and auto-injection! ->setDiscovery(__DIR__, ['.']) ->build(); diff --git a/examples/stdio-logging-showcase/server.php b/examples/stdio-logging-showcase/server.php index 9953aa12..e4fe54c8 100644 --- a/examples/stdio-logging-showcase/server.php +++ b/examples/stdio-logging-showcase/server.php @@ -10,7 +10,7 @@ * file that was distributed with this source code. */ -require_once dirname(__DIR__).'/bootstrap.php'; +require_once dirname(__DIR__) . '/bootstrap.php'; chdir(__DIR__); use Mcp\Server; @@ -23,7 +23,6 @@ ->setServerInfo('Stdio Logging Showcase', '1.0.0', 'Demonstration of auto-injected MCP logging in capability handlers.') ->setContainer(container()) ->setLogger(logger()) - ->enableClientLogging() // Enable MCP logging capability and auto-injection! ->setDiscovery(__DIR__, ['.']) ->build(); diff --git a/src/Capability/Discovery/SchemaGenerator.php b/src/Capability/Discovery/SchemaGenerator.php index a90203c4..bdde50be 100644 --- a/src/Capability/Discovery/SchemaGenerator.php +++ b/src/Capability/Discovery/SchemaGenerator.php @@ -806,7 +806,7 @@ private function isAutoInjectedParameter(\ReflectionParameter $parameter): bool $typeName = $type->getName(); // Auto-inject for ClientLogger or LoggerInterface types - return 'Mcp\\Capability\\Logger\\ClientLogger' === $typeName + return 'Mcp\\Capability\\Logger\\ClientLogger' === $typeName || 'Psr\\Log\\LoggerInterface' === $typeName; } } diff --git a/src/Capability/Registry.php b/src/Capability/Registry.php index 43ab1f4a..7cbd3590 100644 --- a/src/Capability/Registry.php +++ b/src/Capability/Registry.php @@ -66,9 +66,9 @@ final class Registry implements ReferenceProviderInterface, ReferenceRegistryInt private ServerCapabilities $serverCapabilities; - private bool $loggingMessageNotificationEnabled = false; + private bool $logging = true; - private ?LoggingLevel $currentLoggingMessageNotificationLevel = null; + private LoggingLevel $loggingLevel = LoggingLevel::Warning; public function __construct( private readonly ?EventDispatcherInterface $eventDispatcher = null, @@ -77,21 +77,21 @@ public function __construct( } /** - * Enables logging message notifications for this registry. + * Disable logging message notifications for this registry. */ - public function enableLoggingMessageNotification(): void + public function disableLogging(): void { - $this->loggingMessageNotificationEnabled = true; + $this->logging = false; } /** * Checks if logging message notification capability is enabled. * - * @return bool True if logging message notification capability is enabled, false otherwise + * @return bool True if logging capability is enabled, false otherwise */ - public function isLoggingMessageNotificationEnabled(): bool + public function isLoggingEnabled(): bool { - return $this->loggingMessageNotificationEnabled; + return $this->logging; } /** @@ -100,19 +100,19 @@ public function isLoggingMessageNotificationEnabled(): bool * 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 setLoggingMessageNotificationLevel(LoggingLevel $level): void + public function setLoggingLevel(LoggingLevel $level): void { - $this->currentLoggingMessageNotificationLevel = $level; + $this->loggingLevel = $level; } /** * Gets the current logging message notification level set by the client. * - * @return LoggingLevel|null The current log level, or null if not set + * @return LoggingLevel The current log level */ - public function getLoggingMessageNotificationLevel(): ?LoggingLevel + public function getLoggingLevel(): LoggingLevel { - return $this->currentLoggingMessageNotificationLevel; + return $this->loggingLevel; } public function getCapabilities(): ServerCapabilities @@ -129,7 +129,7 @@ public function getCapabilities(): ServerCapabilities resourcesListChanged: $this->eventDispatcher instanceof EventDispatcherInterface, prompts: [] !== $this->prompts, promptsListChanged: $this->eventDispatcher instanceof EventDispatcherInterface, - logging: $this->loggingMessageNotificationEnabled, + logging: $this->logging, completions: true, ); } diff --git a/src/Capability/Registry/ReferenceProviderInterface.php b/src/Capability/Registry/ReferenceProviderInterface.php index 377ee812..2f60014b 100644 --- a/src/Capability/Registry/ReferenceProviderInterface.php +++ b/src/Capability/Registry/ReferenceProviderInterface.php @@ -11,7 +11,6 @@ namespace Mcp\Capability\Registry; -use Mcp\Schema\Enum\LoggingLevel; use Mcp\Schema\Page; /** @@ -66,34 +65,4 @@ public function getResourceTemplates(?int $limit = null, ?string $cursor = null) * Checks if any elements (manual or discovered) are currently registered. */ public function hasElements(): bool; - - /** - * Enables logging message notifications for the MCP server. - * - * When enabled, the server will advertise logging capability to clients, - * indicating that it can emit structured log messages according to the MCP specification. - */ - public function enableLoggingMessageNotification(): void; - - /** - * Checks if logging message notification capability is enabled. - * - * @return bool True if logging message notification capability is enabled, false otherwise - */ - public function isLoggingMessageNotificationEnabled(): bool; - - /** - * 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 setLoggingMessageNotificationLevel(LoggingLevel $level): void; - - /** - * Gets the current logging message notification level set by the client. - * - * @return LoggingLevel|null The current log level, or null if not set - */ - public function getLoggingMessageNotificationLevel(): ?LoggingLevel; } 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/Server/Builder.php b/src/Server/Builder.php index dae1a6d6..da724265 100644 --- a/src/Server/Builder.php +++ b/src/Server/Builder.php @@ -88,7 +88,7 @@ final class Builder */ private array $notificationHandlers = []; - private bool $loggingMessageNotificationEnabled = false; + private bool $logging = true; /** * @var array{ @@ -241,15 +241,11 @@ public function addNotificationHandlers(iterable $handlers): self } /** - * Enables Client logging capability for the server. - * - * When enabled, the server will advertise logging capability to clients, - * indicating that it can emit structured log messages according to the MCP specification. - * This enables auto-injection of ClientLogger into capability handlers. + * Disables Client logging capability for the server. */ - public function enableClientLogging(): self + public function disableClientLogging(): self { - $this->loggingMessageNotificationEnabled = true; + $this->logging = false; return $this; } @@ -395,9 +391,9 @@ public function build(): Server $container = $this->container ?? new Container(); $registry = new Registry($this->eventDispatcher, $logger); - // Enable MCP logging capability if requested - if ($this->loggingMessageNotificationEnabled) { - $registry->enableLoggingMessageNotification(); + // Enable Client logging capability if requested + if (!$this->logging) { + $registry->disableLogging(); } $this->registerCapabilities($registry, $logger); diff --git a/src/Server/Handler/Notification/LoggingMessageNotificationHandler.php b/src/Server/Handler/Notification/LoggingMessageNotificationHandler.php index f269da3e..58318b2b 100644 --- a/src/Server/Handler/Notification/LoggingMessageNotificationHandler.php +++ b/src/Server/Handler/Notification/LoggingMessageNotificationHandler.php @@ -11,7 +11,7 @@ namespace Mcp\Server\Handler\Notification; -use Mcp\Capability\Registry\ReferenceProviderInterface; +use Mcp\Capability\Registry\ReferenceRegistryInterface; use Mcp\Exception\InvalidArgumentException; use Mcp\Schema\Enum\LoggingLevel; use Mcp\Schema\JsonRpc\Notification; @@ -30,7 +30,7 @@ final class LoggingMessageNotificationHandler implements NotificationHandlerInterface { public function __construct( - private readonly ReferenceProviderInterface $referenceProvider, + private readonly ReferenceRegistryInterface $registry, private readonly LoggerInterface $logger, ) { } @@ -45,7 +45,7 @@ public function handle(string $method, array $params): Notification $level = $this->getLoggingLevel($params); - if (!$this->referenceProvider->isLoggingMessageNotificationEnabled()) { + if (!$this->registry->isLoggingEnabled()) { $this->logger->debug('Logging is disabled, skipping log message'); throw new InvalidArgumentException('Logging capability is not enabled'); } @@ -90,11 +90,9 @@ private function getLoggingLevel(array $params): LoggingLevel private function validateLogLevelThreshold(LoggingLevel $level): void { - $currentLogLevel = $this->referenceProvider->getLoggingMessageNotificationLevel(); + $currentLogLevel = $this->registry->getLoggingLevel(); - // Only filter by log level if client has explicitly set one - // If no log level is set (null), send all notifications - if (null === $currentLogLevel || $this->shouldSendLogLevel($level, $currentLogLevel)) { + if ($this->shouldSendLogLevel($level, $currentLogLevel)) { return; } @@ -104,11 +102,6 @@ private function validateLogLevelThreshold(LoggingLevel $level): void throw new InvalidArgumentException('Log level is below current threshold'); } - /** - * Determines if a log message should be sent based on current log level threshold. - * - * Messages at the current level and higher (more severe) should be sent. - */ 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 index 69dcd05e..94a2996d 100644 --- a/src/Server/Handler/NotificationHandler.php +++ b/src/Server/Handler/NotificationHandler.php @@ -11,7 +11,7 @@ namespace Mcp\Server\Handler; -use Mcp\Capability\Registry\ReferenceProviderInterface; +use Mcp\Capability\Registry\ReferenceRegistryInterface; use Mcp\Exception\HandlerNotFoundException; use Mcp\Schema\JsonRpc\Notification; use Mcp\Schema\Notification\LoggingMessageNotification; @@ -48,12 +48,12 @@ public function __construct( * Creates a NotificationHandler with default handlers. */ public static function make( - ReferenceProviderInterface $referenceProvider, + ReferenceRegistryInterface $registry, LoggerInterface $logger = new NullLogger(), ): self { return new self( handlers: [ - LoggingMessageNotification::getMethod() => new LoggingMessageNotificationHandler($referenceProvider, $logger), + LoggingMessageNotification::getMethod() => new LoggingMessageNotificationHandler($registry, $logger), ], logger: $logger, ); diff --git a/src/Server/Handler/Request/SetLogLevelHandler.php b/src/Server/Handler/Request/SetLogLevelHandler.php index 7b0eafb6..ac32c16f 100644 --- a/src/Server/Handler/Request/SetLogLevelHandler.php +++ b/src/Server/Handler/Request/SetLogLevelHandler.php @@ -11,7 +11,7 @@ namespace Mcp\Server\Handler\Request; -use Mcp\Capability\Registry\ReferenceProviderInterface; +use Mcp\Capability\Registry\ReferenceRegistryInterface; use Mcp\Schema\JsonRpc\HasMethodInterface; use Mcp\Schema\JsonRpc\Response; use Mcp\Schema\Request\SetLogLevelRequest; @@ -31,7 +31,7 @@ final class SetLogLevelHandler implements MethodHandlerInterface { public function __construct( - private readonly ReferenceProviderInterface $referenceProvider, + private readonly ReferenceRegistryInterface $registry, private readonly LoggerInterface $logger, ) { } @@ -46,7 +46,7 @@ public function handle(SetLogLevelRequest|HasMethodInterface $message, SessionIn \assert($message instanceof SetLogLevelRequest); // Update the log level in the registry via the interface - $this->referenceProvider->setLoggingMessageNotificationLevel($message->level); + $this->registry->setLoggingLevel($message->level); $this->logger->debug("Log level set to: {$message->level->value}"); diff --git a/tests/Unit/Capability/Logger/ClientLoggerTest.php b/tests/Unit/Capability/Logger/ClientLoggerTest.php index a732b76d..93affff9 100644 --- a/tests/Unit/Capability/Logger/ClientLoggerTest.php +++ b/tests/Unit/Capability/Logger/ClientLoggerTest.php @@ -12,7 +12,7 @@ namespace Tests\Unit\Capability\Logger; use Mcp\Capability\Logger\ClientLogger; -use Mcp\Capability\Registry\ReferenceProviderInterface; +use Mcp\Capability\Registry\ReferenceRegistryInterface; use Mcp\Server\Handler\NotificationHandler; use Mcp\Server\NotificationSender; use PHPUnit\Framework\MockObject\MockObject; @@ -87,9 +87,9 @@ public function testHandlesMcpSendGracefully(): void private function createClientLogger(): ClientLogger { // Create minimal working NotificationSender for testing - // Using a minimal ReferenceProvider mock just to construct NotificationHandler - $referenceProvider = $this->createMock(ReferenceProviderInterface::class); - $notificationHandler = NotificationHandler::make($referenceProvider); + // 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( 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 index bf47838f..f19e28f6 100644 --- a/tests/Unit/Capability/RegistryLoggingTest.php +++ b/tests/Unit/Capability/RegistryLoggingTest.php @@ -33,37 +33,34 @@ protected function setUp(): void $this->registry = new Registry(null, $this->logger); } - public function testLoggingDisabledByDefault(): void + public function testLoggingDEnabledByDefault(): void { - $this->assertFalse($this->registry->isLoggingMessageNotificationEnabled()); + $this->assertTrue($this->registry->isLoggingEnabled()); } public function testLoggingStateEnablement(): void { // Logging starts disabled - $this->assertFalse($this->registry->isLoggingMessageNotificationEnabled()); + $this->assertTrue($this->registry->isLoggingEnabled()); // Test enabling logging - $this->registry->enableLoggingMessageNotification(); - $this->assertTrue($this->registry->isLoggingMessageNotificationEnabled()); + $this->registry->disableLogging(); + $this->assertFalse($this->registry->isLoggingEnabled()); // Enabling again should have no effect - $this->registry->enableLoggingMessageNotification(); - $this->assertTrue($this->registry->isLoggingMessageNotificationEnabled()); - - // Create new instances to test disabled state - for ($i = 0; $i < 3; ++$i) { - $newRegistry = new Registry(null, $this->logger); - $this->assertFalse($newRegistry->isLoggingMessageNotificationEnabled()); - $newRegistry->enableLoggingMessageNotification(); - $this->assertTrue($newRegistry->isLoggingMessageNotificationEnabled()); - } + $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->assertNull($this->registry->getLoggingMessageNotificationLevel()); + $this->assertEquals(LoggingLevel::Warning->value, $this->registry->getLoggingLevel()->value); // Test setting and getting each log level $levels = [ @@ -78,40 +75,17 @@ public function testLogLevelManagement(): void ]; foreach ($levels as $level) { - $this->registry->setLoggingMessageNotificationLevel($level); - $this->assertEquals($level, $this->registry->getLoggingMessageNotificationLevel()); + $this->registry->setLoggingLevel($level); + $this->assertEquals($level, $this->registry->getLoggingLevel()); // Verify enum properties are preserved - $retrievedLevel = $this->registry->getLoggingMessageNotificationLevel(); + $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->getLoggingMessageNotificationLevel()); - - // Test multiple level changes - $changeLevels = [ - LoggingLevel::Debug, - LoggingLevel::Warning, - LoggingLevel::Critical, - LoggingLevel::Info, - ]; - - foreach ($changeLevels as $level) { - $this->registry->setLoggingMessageNotificationLevel($level); - $this->assertEquals($level, $this->registry->getLoggingMessageNotificationLevel()); - } - } - - public function testGetLogLevelReturnsNullWhenNotSet(): void - { - // Verify default state - $this->assertNull($this->registry->getLoggingMessageNotificationLevel()); - - // Enable logging but don't set level - $this->registry->enableLoggingMessageNotification(); - $this->assertNull($this->registry->getLoggingMessageNotificationLevel()); + $this->assertEquals(LoggingLevel::Emergency, $this->registry->getLoggingLevel()); } public function testLoggingCapabilities(): void @@ -124,21 +98,21 @@ public function testLoggingCapabilities(): void $capabilities = $this->registry->getCapabilities(); $this->assertInstanceOf(ServerCapabilities::class, $capabilities); - $this->assertFalse($capabilities->logging); + $this->assertTrue($capabilities->logging); // Enable logging and test capabilities - $this->registry->enableLoggingMessageNotification(); + $this->registry->disableLogging(); $capabilities = $this->registry->getCapabilities(); - $this->assertTrue($capabilities->logging); + $this->assertFalse($capabilities->logging); // Test with event dispatcher /** @var EventDispatcherInterface&MockObject $eventDispatcher */ $eventDispatcher = $this->createMock(EventDispatcherInterface::class); $registryWithDispatcher = new Registry($eventDispatcher, $this->logger); - $registryWithDispatcher->enableLoggingMessageNotification(); + $registryWithDispatcher->disableLogging(); $capabilities = $registryWithDispatcher->getCapabilities(); - $this->assertTrue($capabilities->logging); + $this->assertFalse($capabilities->logging); $this->assertTrue($capabilities->toolsListChanged); $this->assertTrue($capabilities->resourcesListChanged); $this->assertTrue($capabilities->promptsListChanged); @@ -146,55 +120,29 @@ public function testLoggingCapabilities(): void public function testLoggingStateIndependentOfLevel(): void { - // Logging can be enabled without setting a level - $this->registry->enableLoggingMessageNotification(); - $this->assertTrue($this->registry->isLoggingMessageNotificationEnabled()); - $this->assertNull($this->registry->getLoggingMessageNotificationLevel()); + // 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 enabling logging - $this->registry->setLoggingMessageNotificationLevel(LoggingLevel::Info); - $this->assertTrue($this->registry->isLoggingMessageNotificationEnabled()); - $this->assertEquals(LoggingLevel::Info, $this->registry->getLoggingMessageNotificationLevel()); + // 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 enabling logging + // Level can be set on a new registry without disabling logging $newRegistry = new Registry(null, $this->logger); - $newRegistry->setLoggingMessageNotificationLevel(LoggingLevel::Info); - $this->assertFalse($newRegistry->isLoggingMessageNotificationEnabled()); - $this->assertEquals(LoggingLevel::Info, $newRegistry->getLoggingMessageNotificationLevel()); + $newRegistry->setLoggingLevel(LoggingLevel::Info); + $this->assertTrue($newRegistry->isLoggingEnabled()); + $this->assertEquals(LoggingLevel::Info, $newRegistry->getLoggingLevel()); - // Test persistence: Set level then enable logging - level should persist + // Test persistence: Set level then disable logging - level should persist $persistRegistry = new Registry(null, $this->logger); - $persistRegistry->setLoggingMessageNotificationLevel(LoggingLevel::Critical); - $this->assertEquals(LoggingLevel::Critical, $persistRegistry->getLoggingMessageNotificationLevel()); - - $persistRegistry->enableLoggingMessageNotification(); - $this->assertTrue($persistRegistry->isLoggingMessageNotificationEnabled()); - $this->assertEquals(LoggingLevel::Critical, $persistRegistry->getLoggingMessageNotificationLevel()); - } - - public function testRegistryIntegration(): void - { - // Test registry with default constructor - $defaultRegistry = new Registry(); - $this->assertFalse($defaultRegistry->isLoggingMessageNotificationEnabled()); - $this->assertNull($defaultRegistry->getLoggingMessageNotificationLevel()); + $persistRegistry->setLoggingLevel(LoggingLevel::Critical); + $this->assertEquals(LoggingLevel::Critical, $persistRegistry->getLoggingLevel()); - // Test integration with other registry functionality - $this->registry->enableLoggingMessageNotification(); - $this->registry->setLoggingMessageNotificationLevel(LoggingLevel::Error); - - // Verify logging state doesn't interfere with other functionality - $this->assertTrue($this->registry->isLoggingMessageNotificationEnabled()); - $this->assertEquals(LoggingLevel::Error, $this->registry->getLoggingMessageNotificationLevel()); - - // Basic capability check - $this->logger - ->expects($this->once()) - ->method('info') - ->with('No capabilities registered on server.'); - - $capabilities = $this->registry->getCapabilities(); - $this->assertTrue($capabilities->logging); - $this->assertTrue($capabilities->completions); + $persistRegistry->disableLogging(); + $this->assertFalse($persistRegistry->isLoggingEnabled()); + $this->assertEquals(LoggingLevel::Critical, $persistRegistry->getLoggingLevel()); } } diff --git a/tests/Unit/Server/BuilderLoggingTest.php b/tests/Unit/Server/BuilderLoggingTest.php index e2deb10a..f2a265b8 100644 --- a/tests/Unit/Server/BuilderLoggingTest.php +++ b/tests/Unit/Server/BuilderLoggingTest.php @@ -14,7 +14,6 @@ use Mcp\Server; use Mcp\Server\Builder; use PHPUnit\Framework\TestCase; -use Psr\Log\LoggerInterface; /** * Tests for MCP logging capability configuration through the Builder. @@ -26,91 +25,54 @@ */ class BuilderLoggingTest extends TestCase { - public function testLoggingDisabledByDefault(): void + public function testLoggingEnabledByDefault(): void { $builder = new Builder(); - $this->assertFalse($this->getBuilderLoggingState($builder), 'Builder should start with logging disabled'); + $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 testEnableClientLoggingConfiguresBuilder(): void + public function testDisableClientLoggingConfiguresBuilder(): void { $builder = new Builder(); - $result = $builder->enableClientLogging(); + $result = $builder->disableClientLogging(); // Test method chaining - $this->assertSame($builder, $result, 'enableClientLogging should return builder for chaining'); + $this->assertSame($builder, $result, 'disableClientLogging should return builder for chaining'); // Test internal state - $this->assertTrue($this->getBuilderLoggingState($builder), 'enableClientLogging should set internal flag'); + $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 testMultipleEnableCallsAreIdempotent(): void + public function testMultipleDisableCallsAreIdempotent(): void { $builder = new Builder(); - $builder->enableClientLogging() - ->enableClientLogging() - ->enableClientLogging(); + $builder->disableClientLogging() + ->disableClientLogging() + ->disableClientLogging(); - $this->assertTrue($this->getBuilderLoggingState($builder), 'Multiple enable calls should maintain enabled state'); + $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')->enableClientLogging(); + $builder->setServerInfo('Test Server', '1.0.0')->disableClientLogging(); $server1 = $builder->build(); $server2 = $builder->build(); // State should persist after building - $this->assertTrue($this->getBuilderLoggingState($builder), 'Builder state should persist after builds'); - $this->assertInstanceOf(Server::class, $server1); - $this->assertInstanceOf(Server::class, $server2); - } - - public function testLoggingWithOtherBuilderConfiguration(): void - { - $logger = $this->createMock(LoggerInterface::class); - \assert($logger instanceof LoggerInterface); - $builder = new Builder(); - - $server = $builder - ->setServerInfo('Test Server', '1.0.0', 'Test description') - ->setLogger($logger) - ->enableClientLogging() - ->setPaginationLimit(50) - ->addTool(fn () => 'test', 'test_tool', 'Test tool') - ->build(); - - $this->assertInstanceOf(Server::class, $server); - $this->assertTrue($this->getBuilderLoggingState($builder), 'Logging should work with other configurations'); - } - - public function testIndependentBuilderInstances(): void - { - $builderWithLogging = new Builder(); - $builderWithoutLogging = new Builder(); - - $builderWithLogging->enableClientLogging(); - // Don't enable on second builder - - $this->assertTrue($this->getBuilderLoggingState($builderWithLogging), 'First builder should have logging enabled'); - $this->assertFalse($this->getBuilderLoggingState($builderWithoutLogging), 'Second builder should have logging disabled'); - - // Both should build successfully - $server1 = $builderWithLogging->setServerInfo('Test1', '1.0.0')->build(); - $server2 = $builderWithoutLogging->setServerInfo('Test2', '1.0.0')->build(); - + $this->assertFalse($this->getBuilderLoggingState($builder), 'Builder state should persist after builds'); $this->assertInstanceOf(Server::class, $server1); $this->assertInstanceOf(Server::class, $server2); } @@ -122,7 +84,7 @@ public function testIndependentBuilderInstances(): void private function getBuilderLoggingState(Builder $builder): bool { $reflection = new \ReflectionClass($builder); - $property = $reflection->getProperty('loggingMessageNotificationEnabled'); + $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 index 1d2369f5..7d0d196c 100644 --- a/tests/Unit/Server/Handler/Notification/LoggingMessageNotificationHandlerTest.php +++ b/tests/Unit/Server/Handler/Notification/LoggingMessageNotificationHandlerTest.php @@ -11,7 +11,7 @@ namespace Mcp\Tests\Unit\Server\Handler\Notification; -use Mcp\Capability\Registry\ReferenceProviderInterface; +use Mcp\Capability\Registry\ReferenceRegistryInterface; use Mcp\Exception\InvalidArgumentException; use Mcp\Schema\Enum\LoggingLevel; use Mcp\Schema\Notification\LoggingMessageNotification; @@ -26,31 +26,31 @@ class LoggingMessageNotificationHandlerTest extends TestCase { private LoggingMessageNotificationHandler $handler; - private ReferenceProviderInterface&MockObject $referenceProvider; + private ReferenceRegistryInterface&MockObject $registry; private LoggerInterface&MockObject $logger; protected function setUp(): void { - $this->referenceProvider = $this->createMock(ReferenceProviderInterface::class); + $this->registry = $this->createMock(ReferenceRegistryInterface::class); $this->logger = $this->createMock(LoggerInterface::class); $this->handler = new LoggingMessageNotificationHandler( - $this->referenceProvider, + $this->registry, $this->logger ); } public function testHandleNotificationCreation(): void { - $this->referenceProvider - ->expects($this->exactly(3)) - ->method('isLoggingMessageNotificationEnabled') + $this->registry + ->expects($this->exactly(2)) + ->method('isLoggingEnabled') ->willReturn(true); - $this->referenceProvider - ->expects($this->exactly(3)) - ->method('getLoggingMessageNotificationLevel') - ->willReturnOnConsecutiveCalls(LoggingLevel::Info, LoggingLevel::Debug, null); + $this->registry + ->expects($this->exactly(2)) + ->method('getLoggingLevel') + ->willReturnOnConsecutiveCalls(LoggingLevel::Info, LoggingLevel::Debug); // Test with LoggingLevel enum $params1 = [ @@ -62,8 +62,8 @@ public function testHandleNotificationCreation(): void $this->assertInstanceOf(LoggingMessageNotification::class, $notification1); /* @var LoggingMessageNotification $notification1 */ $this->assertEquals(LoggingLevel::Error, $notification1->level); - $this->assertEquals('Test error message', $notification1->data); - $this->assertEquals('TestLogger', $notification1->logger); + $this->assertEquals($params1['data'], $notification1->data); + $this->assertEquals($params1['logger'], $notification1->logger); // Test with string level conversion $params2 = [ @@ -74,26 +74,8 @@ public function testHandleNotificationCreation(): void $this->assertInstanceOf(LoggingMessageNotification::class, $notification2); /* @var LoggingMessageNotification $notification2 */ $this->assertEquals(LoggingLevel::Warning, $notification2->level); - $this->assertEquals('String level test', $notification2->data); + $this->assertEquals($params2['data'], $notification2->data); $this->assertNull($notification2->logger); - - // Test with complex data and no log level threshold - $complexData = [ - 'error' => 'Connection failed', - 'details' => ['host' => 'localhost', 'port' => 5432, 'retry_count' => 3], - 'timestamp' => time(), - ]; - $params3 = [ - 'level' => LoggingLevel::Critical, - 'data' => $complexData, - 'logger' => 'DatabaseService', - ]; - $notification3 = $this->handler->handle(LoggingMessageNotification::getMethod(), $params3); - $this->assertInstanceOf(LoggingMessageNotification::class, $notification3); - /* @var LoggingMessageNotification $notification3 */ - $this->assertEquals(LoggingLevel::Critical, $notification3->level); - $this->assertEquals($complexData, $notification3->data); - $this->assertEquals('DatabaseService', $notification3->logger); } public function testValidationAndErrors(): void @@ -113,9 +95,9 @@ public function testValidateRequiredParameterData(): void public function testLoggingDisabledRejectsMessages(): void { - $this->referenceProvider + $this->registry ->expects($this->once()) - ->method('isLoggingMessageNotificationEnabled') + ->method('isLoggingEnabled') ->willReturn(false); $this->logger @@ -137,14 +119,14 @@ public function testLoggingDisabledRejectsMessages(): void public function testLogLevelFiltering(): void { // Test equal level is allowed - $this->referenceProvider + $this->registry ->expects($this->exactly(3)) - ->method('isLoggingMessageNotificationEnabled') + ->method('isLoggingEnabled') ->willReturn(true); - $this->referenceProvider + $this->registry ->expects($this->exactly(3)) - ->method('getLoggingMessageNotificationLevel') + ->method('getLoggingLevel') ->willReturnOnConsecutiveCalls(LoggingLevel::Warning, LoggingLevel::Warning, LoggingLevel::Error); // Equal level should be allowed @@ -193,15 +175,15 @@ public function testUnsupportedMethodThrowsException(): void public function testNotificationSerialization(): void { - $this->referenceProvider + $this->registry ->expects($this->once()) - ->method('isLoggingMessageNotificationEnabled') + ->method('isLoggingEnabled') ->willReturn(true); - $this->referenceProvider + $this->registry ->expects($this->once()) - ->method('getLoggingMessageNotificationLevel') - ->willReturn(null); + ->method('getLoggingLevel') + ->willReturn(LoggingLevel::Debug); $params = [ 'level' => LoggingLevel::Info, diff --git a/tests/Unit/Server/Handler/Request/SetLogLevelHandlerTest.php b/tests/Unit/Server/Handler/Request/SetLogLevelHandlerTest.php index 99eedcf7..acedd357 100644 --- a/tests/Unit/Server/Handler/Request/SetLogLevelHandlerTest.php +++ b/tests/Unit/Server/Handler/Request/SetLogLevelHandlerTest.php @@ -11,7 +11,7 @@ namespace Mcp\Tests\Unit\Server\Handler\Request; -use Mcp\Capability\Registry\ReferenceProviderInterface; +use Mcp\Capability\Registry\ReferenceRegistryInterface; use Mcp\Schema\Enum\LoggingLevel; use Mcp\Schema\JsonRpc\Request; use Mcp\Schema\JsonRpc\Response; @@ -29,18 +29,18 @@ class SetLogLevelHandlerTest extends TestCase { private SetLogLevelHandler $handler; - private ReferenceProviderInterface&MockObject $referenceProvider; + private ReferenceRegistryInterface&MockObject $registry; private LoggerInterface&MockObject $logger; private SessionInterface&MockObject $session; protected function setUp(): void { - $this->referenceProvider = $this->createMock(ReferenceProviderInterface::class); + $this->registry = $this->createMock(ReferenceRegistryInterface::class); $this->logger = $this->createMock(LoggerInterface::class); $this->session = $this->createMock(SessionInterface::class); $this->handler = new SetLogLevelHandler( - $this->referenceProvider, + $this->registry, $this->logger ); } @@ -59,27 +59,6 @@ public function testDoesNotSupportOtherRequests(): void $this->assertFalse($this->handler->supports($otherRequest)); } - public function testHandleValidLogLevel(): void - { - $request = $this->createSetLogLevelRequest(LoggingLevel::Warning); - - $this->referenceProvider - ->expects($this->once()) - ->method('setLoggingMessageNotificationLevel') - ->with(LoggingLevel::Warning); - - $this->logger - ->expects($this->once()) - ->method('debug') - ->with('Log level set to: warning'); - - $response = $this->handler->handle($request, $this->session); - - $this->assertInstanceOf(Response::class, $response); - $this->assertEquals($request->getId(), $response->id); - $this->assertInstanceOf(EmptyResult::class, $response->result); - } - public function testHandleAllLogLevelsAndSupport(): void { $logLevels = [ @@ -93,20 +72,13 @@ public function testHandleAllLogLevelsAndSupport(): void LoggingLevel::Emergency, ]; - // Test supports() method - $testRequest = $this->createSetLogLevelRequest(LoggingLevel::Info); - $this->assertTrue($this->handler->supports($testRequest)); - - $otherRequest = $this->createMock(Request::class); - $this->assertFalse($this->handler->supports($otherRequest)); - // Test handling all log levels foreach ($logLevels as $level) { $request = $this->createSetLogLevelRequest($level); - $this->referenceProvider + $this->registry ->expects($this->once()) - ->method('setLoggingMessageNotificationLevel') + ->method('setLoggingLevel') ->with($level); $this->logger @@ -129,45 +101,12 @@ public function testHandleAllLogLevelsAndSupport(): void } } - public function testHandlerReusabilityAndStatelessness(): void - { - $handler1 = new SetLogLevelHandler($this->referenceProvider, $this->logger); - $handler2 = new SetLogLevelHandler($this->referenceProvider, $this->logger); - - $request = $this->createSetLogLevelRequest(LoggingLevel::Info); - - // Both handlers should work identically - $this->assertTrue($handler1->supports($request)); - $this->assertTrue($handler2->supports($request)); - - // Test reusability with multiple requests - $requests = [ - $this->createSetLogLevelRequest(LoggingLevel::Debug), - $this->createSetLogLevelRequest(LoggingLevel::Error), - ]; - - // Configure mocks for multiple calls - $this->referenceProvider - ->expects($this->exactly(2)) - ->method('setLoggingMessageNotificationLevel'); - - $this->logger - ->expects($this->exactly(2)) - ->method('debug'); - - foreach ($requests as $req) { - $response = $this->handler->handle($req, $this->session); - $this->assertInstanceOf(Response::class, $response); - $this->assertEquals($req->getId(), $response->id); - } - } - private function createSetLogLevelRequest(LoggingLevel $level): SetLogLevelRequest { return SetLogLevelRequest::fromArray([ 'jsonrpc' => '2.0', 'method' => SetLogLevelRequest::getMethod(), - 'id' => 'test-request-'.uniqid(), + 'id' => 'test-request-' . uniqid(), 'params' => [ 'level' => $level->value, ], diff --git a/tests/Unit/Server/NotificationSenderTest.php b/tests/Unit/Server/NotificationSenderTest.php index d85eff03..2330c823 100644 --- a/tests/Unit/Server/NotificationSenderTest.php +++ b/tests/Unit/Server/NotificationSenderTest.php @@ -11,7 +11,7 @@ namespace Tests\Unit\Server; -use Mcp\Capability\Registry\ReferenceProviderInterface; +use Mcp\Capability\Registry\ReferenceRegistryInterface; use Mcp\Exception\RuntimeException; use Mcp\Schema\Enum\LoggingLevel; use Mcp\Server\Handler\NotificationHandler; @@ -30,17 +30,17 @@ final class NotificationSenderTest extends TestCase /** @var TransportInterface&MockObject */ private TransportInterface&MockObject $transport; private LoggerInterface&MockObject $logger; - private ReferenceProviderInterface&MockObject $referenceProvider; + private ReferenceRegistryInterface&MockObject $registry; private NotificationSender $sender; protected function setUp(): void { - $this->referenceProvider = $this->createMock(ReferenceProviderInterface::class); + $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->referenceProvider); + $this->notificationHandler = NotificationHandler::make($this->registry); $this->sender = new NotificationSender( $this->notificationHandler, @@ -52,12 +52,12 @@ protected function setUp(): void public function testSetTransport(): void { // Configure logging to be enabled - $this->referenceProvider - ->method('isLoggingMessageNotificationEnabled') + $this->registry + ->method('isLoggingEnabled') ->willReturn(true); - $this->referenceProvider - ->method('getLoggingMessageNotificationLevel') + $this->registry + ->method('getLoggingLevel') ->willReturn(LoggingLevel::Info); // Setting transport should not throw any exceptions @@ -83,12 +83,12 @@ public function testSendWithoutTransportThrowsException(): void public function testSendSuccessfulNotification(): void { // Configure logging to be enabled - $this->referenceProvider - ->method('isLoggingMessageNotificationEnabled') + $this->registry + ->method('isLoggingEnabled') ->willReturn(true); - $this->referenceProvider - ->method('getLoggingMessageNotificationLevel') + $this->registry + ->method('getLoggingLevel') ->willReturn(LoggingLevel::Info); $this->sender->setTransport($this->transport); @@ -102,31 +102,14 @@ public function testSendSuccessfulNotification(): void $this->sender->send('notifications/message', ['level' => 'info', 'data' => 'test']); } - public function testSendNullNotificationDoesNotCallTransport(): void - { - $this->sender->setTransport($this->transport); - - // Configure to disable logging so handler returns null - $this->referenceProvider - ->expects($this->once()) - ->method('isLoggingMessageNotificationEnabled') - ->willReturn(false); - - $this->transport - ->expects($this->never()) - ->method('send'); - - $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->referenceProvider + $this->registry ->expects($this->once()) - ->method('isLoggingMessageNotificationEnabled') + ->method('isLoggingEnabled') ->willReturn(false); // Transport should never be called when notification creation fails @@ -151,14 +134,14 @@ public function testSendTransportExceptionThrowsRuntimeException(): void $this->sender->setTransport($this->transport); // Configure successful logging - $this->referenceProvider + $this->registry ->expects($this->once()) - ->method('isLoggingMessageNotificationEnabled') + ->method('isLoggingEnabled') ->willReturn(true); - $this->referenceProvider + $this->registry ->expects($this->once()) - ->method('getLoggingMessageNotificationLevel') + ->method('getLoggingLevel') ->willReturn(LoggingLevel::Info); $this->transport @@ -179,12 +162,12 @@ public function testSendTransportExceptionThrowsRuntimeException(): void public function testConstructorWithTransport(): void { // Configure logging to be enabled - $this->referenceProvider - ->method('isLoggingMessageNotificationEnabled') + $this->registry + ->method('isLoggingEnabled') ->willReturn(true); - $this->referenceProvider - ->method('getLoggingMessageNotificationLevel') + $this->registry + ->method('getLoggingLevel') ->willReturn(LoggingLevel::Info); $sender = new NotificationSender( diff --git a/tests/Unit/ServerTest.php b/tests/Unit/ServerTest.php index 6b1d3319..d35606b1 100644 --- a/tests/Unit/ServerTest.php +++ b/tests/Unit/ServerTest.php @@ -11,7 +11,7 @@ namespace Mcp\Tests\Unit; -use Mcp\Capability\Registry\ReferenceProviderInterface; +use Mcp\Capability\Registry\ReferenceRegistryInterface; use Mcp\Server; use Mcp\Server\Builder; use Mcp\Server\Protocol; @@ -160,6 +160,8 @@ public function testRunConnectsProtocolToTransport(): void $server->run($this->transport); $referenceProvider = $this->createMock(ReferenceProviderInterface::class); $notificationHandler = NotificationHandler::make($referenceProvider); + $registry = $this->createMock(ReferenceRegistryInterface::class); + $notificationHandler = NotificationHandler::make($registry); $notificationSender = new NotificationSender($notificationHandler); $server = new Server($handler, $notificationSender); $server->run($transport); From 0eecd64ce017c25eddb3b3dff1006edb73a643cf Mon Sep 17 00:00:00 2001 From: Adam Jamiu Date: Sun, 19 Oct 2025 23:24:51 +0100 Subject: [PATCH 13/14] Resolved conflict --- .../McpElements.php | 15 +- .../stdio-discovery-calculator/server.php | 2 +- .../LoggingShowcaseHandlers.php | 4 +- examples/stdio-logging-showcase/server.php | 2 +- src/Server.php | 2 +- src/Server/Builder.php | 2 +- src/Server/Handler/JsonRpcHandler.php | 258 ------------------ .../Handler/Request/SetLogLevelHandler.php | 9 +- .../Discovery/SchemaGeneratorFixture.php | 4 +- .../Discovery/SchemaGeneratorTest.php | 6 +- .../Request/SetLogLevelHandlerTest.php | 2 +- tests/Unit/ServerTest.php | 31 +-- 12 files changed, 39 insertions(+), 298 deletions(-) delete mode 100644 src/Server/Handler/JsonRpcHandler.php diff --git a/examples/stdio-discovery-calculator/McpElements.php b/examples/stdio-discovery-calculator/McpElements.php index ace68d62..6844bde9 100644 --- a/examples/stdio-discovery-calculator/McpElements.php +++ b/examples/stdio-discovery-calculator/McpElements.php @@ -32,7 +32,8 @@ final class McpElements public function __construct( private readonly LoggerInterface $logger = new NullLogger(), - ) {} + ) { + } /** * Performs a calculation based on the operation. @@ -40,9 +41,9 @@ 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 @@ -130,8 +131,8 @@ public function getConfiguration(ClientLogger $logger): 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{ @@ -195,6 +196,6 @@ public function updateSetting(string $setting, mixed $value, ClientLogger $logge ]); // $registry->notifyResourceChanged('config://calculator/settings'); - return ['success' => true, 'message' => 'Allow negative results set to ' . ($value ? 'true' : 'false') . '.']; + return ['success' => true, 'message' => 'Allow negative results set to '.($value ? 'true' : 'false').'.']; } } diff --git a/examples/stdio-discovery-calculator/server.php b/examples/stdio-discovery-calculator/server.php index 9e53fb7d..fe223240 100644 --- a/examples/stdio-discovery-calculator/server.php +++ b/examples/stdio-discovery-calculator/server.php @@ -10,7 +10,7 @@ * file that was distributed with this source code. */ -require_once dirname(__DIR__) . '/bootstrap.php'; +require_once dirname(__DIR__).'/bootstrap.php'; chdir(__DIR__); use Mcp\Server; diff --git a/examples/stdio-logging-showcase/LoggingShowcaseHandlers.php b/examples/stdio-logging-showcase/LoggingShowcaseHandlers.php index c3e4bc60..f76bdf41 100644 --- a/examples/stdio-logging-showcase/LoggingShowcaseHandlers.php +++ b/examples/stdio-logging-showcase/LoggingShowcaseHandlers.php @@ -25,8 +25,8 @@ final class LoggingShowcaseHandlers /** * Tool that demonstrates different logging levels with auto-injected ClientLogger. * - * @param string $message The message to log - * @param string $level The logging level (debug, info, warning, error) + * @param string $message The message to log + * @param string $level The logging level (debug, info, warning, error) * @param ClientLogger $logger Auto-injected MCP logger * * @return array diff --git a/examples/stdio-logging-showcase/server.php b/examples/stdio-logging-showcase/server.php index e4fe54c8..5de71325 100644 --- a/examples/stdio-logging-showcase/server.php +++ b/examples/stdio-logging-showcase/server.php @@ -10,7 +10,7 @@ * file that was distributed with this source code. */ -require_once dirname(__DIR__) . '/bootstrap.php'; +require_once dirname(__DIR__).'/bootstrap.php'; chdir(__DIR__); use Mcp\Server; diff --git a/src/Server.php b/src/Server.php index e96a15c1..137e4db3 100644 --- a/src/Server.php +++ b/src/Server.php @@ -12,8 +12,8 @@ namespace Mcp; use Mcp\Server\Builder; -use Mcp\Server\Protocol; use Mcp\Server\NotificationSender; +use Mcp\Server\Protocol; use Mcp\Server\Transport\TransportInterface; use Psr\Log\LoggerInterface; use Psr\Log\NullLogger; diff --git a/src/Server/Builder.php b/src/Server/Builder.php index da724265..14481106 100644 --- a/src/Server/Builder.php +++ b/src/Server/Builder.php @@ -38,8 +38,8 @@ use Mcp\Schema\ToolAnnotations; use Mcp\Server; use Mcp\Server\Handler\Notification\NotificationHandlerInterface; -use Mcp\Server\Handler\Request\RequestHandlerInterface; 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; diff --git a/src/Server/Handler/JsonRpcHandler.php b/src/Server/Handler/JsonRpcHandler.php deleted file mode 100644 index 5f785bc5..00000000 --- a/src/Server/Handler/JsonRpcHandler.php +++ /dev/null @@ -1,258 +0,0 @@ - - */ -class JsonRpcHandler -{ - /** - * @param array $methodHandlers - */ - public function __construct( - private readonly array $methodHandlers, - private readonly MessageFactory $messageFactory, - private readonly SessionFactoryInterface $sessionFactory, - private readonly SessionStoreInterface $sessionStore, - private readonly LoggerInterface $logger = new NullLogger(), - ) { - } - - /** - * @return iterable}> - */ - public function process(string $input, ?Uuid $sessionId): iterable - { - $this->logger->info('Received message to process.', ['message' => $input]); - - $this->runGarbageCollection(); - - try { - $messages = iterator_to_array($this->messageFactory->create($input)); - } catch (\JsonException $e) { - $this->logger->warning('Failed to decode json message.', ['exception' => $e]); - $error = Error::forParseError($e->getMessage()); - yield [$this->encodeResponse($error), []]; - - return; - } - - $hasInitializeRequest = false; - foreach ($messages as $message) { - if ($message instanceof InitializeRequest) { - $hasInitializeRequest = true; - break; - } - } - - $session = null; - - if ($hasInitializeRequest) { - // Spec: An initialize request must not be part of a batch. - if (\count($messages) > 1) { - $error = Error::forInvalidRequest('The "initialize" request MUST NOT be part of a batch.'); - yield [$this->encodeResponse($error), []]; - - return; - } - - // Spec: An initialize request must not have a session ID. - if ($sessionId) { - $error = Error::forInvalidRequest('A session ID MUST NOT be sent with an "initialize" request.'); - yield [$this->encodeResponse($error), []]; - - return; - } - - $session = $this->sessionFactory->create($this->sessionStore); - } else { - if (!$sessionId) { - $error = Error::forInvalidRequest('A valid session id is REQUIRED for non-initialize requests.'); - yield [$this->encodeResponse($error), ['status_code' => 400]]; - - return; - } - - if (!$this->sessionStore->exists($sessionId)) { - $error = Error::forInvalidRequest('Session not found or has expired.'); - yield [$this->encodeResponse($error), ['status_code' => 404]]; - - return; - } - - $session = $this->sessionFactory->createWithId($sessionId, $this->sessionStore); - } - - foreach ($messages as $message) { - if ($message instanceof InvalidInputMessageException) { - $this->logger->warning('Failed to create message.', ['exception' => $message]); - $error = Error::forInvalidRequest($message->getMessage()); - yield [$this->encodeResponse($error), []]; - continue; - } - - $this->logger->debug(\sprintf('Decoded incoming message "%s".', $message::class), [ - 'method' => $message->getMethod(), - ]); - - $messageId = $message instanceof Request ? $message->getId() : 0; - - try { - $response = $this->handle($message, $session); - yield [$this->encodeResponse($response), ['session_id' => $session->getId()]]; - } catch (\DomainException) { - yield [null, []]; - } catch (NotFoundExceptionInterface $e) { - $this->logger->warning( - \sprintf('Failed to create response: %s', $e->getMessage()), - ['exception' => $e], - ); - - $error = Error::forMethodNotFound($e->getMessage(), $messageId); - yield [$this->encodeResponse($error), []]; - } catch (\InvalidArgumentException $e) { - $this->logger->warning(\sprintf('Invalid argument: %s', $e->getMessage()), ['exception' => $e]); - - $error = Error::forInvalidParams($e->getMessage(), $messageId); - yield [$this->encodeResponse($error), []]; - } catch (\Throwable $e) { - $this->logger->critical(\sprintf('Uncaught exception: %s', $e->getMessage()), ['exception' => $e]); - - $error = Error::forInternalError($e->getMessage(), $messageId); - yield [$this->encodeResponse($error), []]; - } - } - - $session->save(); - } - - /** - * Encodes a response to JSON, handling encoding errors gracefully. - */ - private function encodeResponse(Response|Error|null $response): ?string - { - if (null === $response) { - $this->logger->info('The handler created an empty response.'); - - return null; - } - - $this->logger->info('Encoding response.', ['response' => $response]); - - try { - if ($response instanceof Response && [] === $response->result) { - return json_encode($response, \JSON_THROW_ON_ERROR | \JSON_FORCE_OBJECT); - } - - return json_encode($response, \JSON_THROW_ON_ERROR); - } catch (\JsonException $e) { - $this->logger->error('Failed to encode response to JSON.', [ - 'message_id' => $response->getId(), - 'exception' => $e, - ]); - - $fallbackError = new Error( - id: $response->getId(), - code: Error::INTERNAL_ERROR, - message: 'Response could not be encoded to JSON' - ); - - return json_encode($fallbackError, \JSON_THROW_ON_ERROR); - } - } - - /** - * If the handler does support the message, but does not create a response, other handlers will be tried. - * - * @throws NotFoundExceptionInterface When no handler is found for the request method - * @throws ExceptionInterface When a request handler throws an exception - */ - private function handle(HasMethodInterface $message, SessionInterface $session): Response|Error|null - { - $this->logger->info(\sprintf('Handling message for method "%s".', $message::getMethod()), [ - 'message' => $message, - ]); - - $handled = false; - foreach ($this->methodHandlers as $handler) { - if (!$handler->supports($message)) { - continue; - } - - $return = $handler->handle($message, $session); - $handled = true; - - $this->logger->debug(\sprintf('Message handled by "%s".', $handler::class), [ - 'method' => $message::getMethod(), - 'response' => $return, - ]); - - if (null !== $return) { - return $return; - } - } - - if ($handled) { - return null; - } - - throw new HandlerNotFoundException(\sprintf('No handler found for method "%s".', $message::getMethod())); - } - - /** - * Run garbage collection on expired sessions. - * Uses the session store's internal TTL configuration. - */ - private function runGarbageCollection(): void - { - if (random_int(0, 100) > 1) { - return; - } - - $deletedSessions = $this->sessionStore->gc(); - if (!empty($deletedSessions)) { - $this->logger->debug('Garbage collected expired sessions.', [ - 'count' => \count($deletedSessions), - 'session_ids' => array_map(fn (Uuid $id) => $id->toRfc4122(), $deletedSessions), - ]); - } - } - - /** - * Destroy a specific session. - */ - public function destroySession(Uuid $sessionId): void - { - $this->sessionStore->destroy($sessionId); - $this->logger->info('Session destroyed.', ['session_id' => $sessionId->toRfc4122()]); - } -} diff --git a/src/Server/Handler/Request/SetLogLevelHandler.php b/src/Server/Handler/Request/SetLogLevelHandler.php index ac32c16f..6e771945 100644 --- a/src/Server/Handler/Request/SetLogLevelHandler.php +++ b/src/Server/Handler/Request/SetLogLevelHandler.php @@ -12,11 +12,10 @@ namespace Mcp\Server\Handler\Request; use Mcp\Capability\Registry\ReferenceRegistryInterface; -use Mcp\Schema\JsonRpc\HasMethodInterface; +use Mcp\Schema\JsonRpc\Request; use Mcp\Schema\JsonRpc\Response; use Mcp\Schema\Request\SetLogLevelRequest; use Mcp\Schema\Result\EmptyResult; -use Mcp\Server\Handler\MethodHandlerInterface; use Mcp\Server\Session\SessionInterface; use Psr\Log\LoggerInterface; @@ -28,7 +27,7 @@ * * @author Adam Jamiu */ -final class SetLogLevelHandler implements MethodHandlerInterface +final class SetLogLevelHandler implements RequestHandlerInterface { public function __construct( private readonly ReferenceRegistryInterface $registry, @@ -36,12 +35,12 @@ public function __construct( ) { } - public function supports(HasMethodInterface $message): bool + public function supports(Request $message): bool { return $message instanceof SetLogLevelRequest; } - public function handle(SetLogLevelRequest|HasMethodInterface $message, SessionInterface $session): Response + public function handle(Request $message, SessionInterface $session): Response { \assert($message instanceof SetLogLevelRequest); diff --git a/tests/Unit/Capability/Discovery/SchemaGeneratorFixture.php b/tests/Unit/Capability/Discovery/SchemaGeneratorFixture.php index c42cfcb4..9670dcab 100644 --- a/tests/Unit/Capability/Discovery/SchemaGeneratorFixture.php +++ b/tests/Unit/Capability/Discovery/SchemaGeneratorFixture.php @@ -416,8 +416,8 @@ public function parameterSchemaInferredType( /** * 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 + * @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 { diff --git a/tests/Unit/Capability/Discovery/SchemaGeneratorTest.php b/tests/Unit/Capability/Discovery/SchemaGeneratorTest.php index 29870837..31f10a3a 100644 --- a/tests/Unit/Capability/Discovery/SchemaGeneratorTest.php +++ b/tests/Unit/Capability/Discovery/SchemaGeneratorTest.php @@ -332,14 +332,14 @@ 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/Server/Handler/Request/SetLogLevelHandlerTest.php b/tests/Unit/Server/Handler/Request/SetLogLevelHandlerTest.php index acedd357..fde07398 100644 --- a/tests/Unit/Server/Handler/Request/SetLogLevelHandlerTest.php +++ b/tests/Unit/Server/Handler/Request/SetLogLevelHandlerTest.php @@ -106,7 +106,7 @@ private function createSetLogLevelRequest(LoggingLevel $level): SetLogLevelReque return SetLogLevelRequest::fromArray([ 'jsonrpc' => '2.0', 'method' => SetLogLevelRequest::getMethod(), - 'id' => 'test-request-' . uniqid(), + 'id' => 'test-request-'.uniqid(), 'params' => [ 'level' => $level->value, ], diff --git a/tests/Unit/ServerTest.php b/tests/Unit/ServerTest.php index d35606b1..cbb0eba2 100644 --- a/tests/Unit/ServerTest.php +++ b/tests/Unit/ServerTest.php @@ -14,14 +14,12 @@ 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; use PHPUnit\Framework\MockObject\MockObject; -use Mcp\Server\Handler\JsonRpcHandler; -use Mcp\Server\Handler\NotificationHandler; -use Mcp\Server\NotificationSender; -use Mcp\Server\Transport\InMemoryTransport; use PHPUnit\Framework\TestCase; final class ServerTest extends TestCase @@ -32,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')] @@ -78,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([ @@ -104,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'); @@ -119,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'); @@ -139,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); @@ -156,14 +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); - $referenceProvider = $this->createMock(ReferenceProviderInterface::class); - $notificationHandler = NotificationHandler::make($referenceProvider); - $registry = $this->createMock(ReferenceRegistryInterface::class); - $notificationHandler = NotificationHandler::make($registry); - $notificationSender = new NotificationSender($notificationHandler); - $server = new Server($handler, $notificationSender); - $server->run($transport); } } From e082fd39e8f54208f62394ea4cbc0bc51a53585c Mon Sep 17 00:00:00 2001 From: Adam Jamiu Date: Sun, 19 Oct 2025 23:34:48 +0100 Subject: [PATCH 14/14] Rmoved unnecessary line of codes --- README.md | 1 - src/Server/Builder.php | 1 - 2 files changed, 2 deletions(-) diff --git a/README.md b/README.md index c7c64c23..8d4aaa81 100644 --- a/README.md +++ b/README.md @@ -239,7 +239,6 @@ $server = Server::builder() **Learning:** - [Examples](docs/examples.md) - Comprehensive example walkthroughs - **External Resources:** - [Model Context Protocol documentation](https://modelcontextprotocol.io) - [Model Context Protocol specification](https://spec.modelcontextprotocol.io) diff --git a/src/Server/Builder.php b/src/Server/Builder.php index 14481106..b5531746 100644 --- a/src/Server/Builder.php +++ b/src/Server/Builder.php @@ -391,7 +391,6 @@ public function build(): Server $container = $this->container ?? new Container(); $registry = new Registry($this->eventDispatcher, $logger); - // Enable Client logging capability if requested if (!$this->logging) { $registry->disableLogging(); }