diff --git a/.github/workflows/pipeline.yml b/.github/workflows/pipeline.yml index 583af12a..7e88ee9e 100644 --- a/.github/workflows/pipeline.yml +++ b/.github/workflows/pipeline.yml @@ -34,7 +34,7 @@ jobs: run: composer install --no-scripts - name: Tests - run: vendor/bin/phpunit + run: vendor/bin/phpunit --exclude-group inspector qa: runs-on: ubuntu-latest diff --git a/.gitignore b/.gitignore index 22dd1a41..3c7c26e2 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,4 @@ .php-cs-fixer.cache composer.lock vendor +examples/**/dev.log diff --git a/composer.json b/composer.json index 84463893..d4109505 100644 --- a/composer.json +++ b/composer.json @@ -22,6 +22,7 @@ "ext-fileinfo": "*", "opis/json-schema": "^2.4", "phpdocumentor/reflection-docblock": "^5.6", + "psr/container": "^2.0", "psr/event-dispatcher": "^1.0", "psr/log": "^1.0 || ^2.0 || ^3.0", "symfony/finder": "^6.4 || ^7.2", @@ -34,7 +35,9 @@ "phpunit/phpunit": "^10.5", "psr/cache": "^3.0", "symfony/console": "^6.4 || ^7.0", - "symfony/event-dispatcher": "^6.4 || ^7.0" + "symfony/dependency-injection": "^6.4 || ^7.0", + "symfony/event-dispatcher": "^6.4 || ^7.0", + "symfony/process": "^6.4 || ^7.0" }, "suggest": { "symfony/console": "To use SymfonyConsoleTransport for STDIO", @@ -47,6 +50,14 @@ }, "autoload-dev": { "psr-4": { + "Mcp\\Example\\StdioCalculatorExample\\": "examples/01-discovery-stdio-calculator/", + "Mcp\\Example\\HttpUserProfileExample\\": "examples/02-discovery-http-userprofile/", + "Mcp\\Example\\ManualStdioExample\\": "examples/03-manual-registration-stdio/", + "Mcp\\Example\\CombinedHttpExample\\": "examples/04-combined-registration-http/", + "Mcp\\Example\\StdioEnvVariables\\": "examples/05-stdio-env-variables/", + "Mcp\\Example\\DependenciesStdioExample\\": "examples/06-custom-dependencies-stdio/", + "Mcp\\Example\\ComplexSchemaHttpExample\\": "examples/07-complex-tool-schema-http/", + "Mcp\\Example\\SchemaShowcaseExample\\": "examples/08-schema-showcase-streamable/", "Mcp\\Tests\\": "tests/" } }, diff --git a/examples/01-discovery-stdio-calculator/McpElements.php b/examples/01-discovery-stdio-calculator/McpElements.php new file mode 100644 index 00000000..8f05b96c --- /dev/null +++ b/examples/01-discovery-stdio-calculator/McpElements.php @@ -0,0 +1,149 @@ + 2, + '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') + * + * @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 + { + $this->logger->info(\sprintf('Calculating: %f %s %f', $a, $operation, $b)); + + $op = strtolower($operation); + + switch ($op) { + case 'add': + $result = $a + $b; + break; + case 'subtract': + $result = $a - $b; + break; + case 'multiply': + $result = $a * $b; + break; + case 'divide': + if (0 == $b) { + return 'Error: Division by zero.'; + } + $result = $a / $b; + break; + default: + return "Error: Unknown operation '{$operation}'. Supported: add, subtract, multiply, divide."; + } + + if (!$this->config['allow_negative'] && $result < 0) { + return 'Error: Negative results are disabled.'; + } + + return round($result, $this->config['precision']); + } + + /** + * Provides the current calculator configuration. + * Can be read by clients to understand precision etc. + * + * @return Config the configuration array + */ + #[McpResource( + uri: 'config://calculator/settings', + name: 'calculator_config', + description: 'Current settings for the calculator tool (precision, allow_negative).', + mimeType: 'application/json', + )] + public function getConfiguration(): array + { + $this->logger->info('Resource config://calculator/settings read.'); + + return $this->config; + } + + /** + * 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) + * + * @return array{ + * success: bool, + * error?: string, + * message?: string + * } success message or error + */ + #[McpTool(name: 'update_setting')] + public function updateSetting(string $setting, mixed $value): array + { + $this->logger->info(\sprintf('Setting tool called: setting=%s, value=%s', $setting, var_export($value, true))); + if (!\array_key_exists($setting, $this->config)) { + return ['success' => false, 'error' => "Unknown setting '{$setting}'."]; + } + + if ('precision' === $setting) { + if (!\is_int($value) || $value < 0 || $value > 10) { + return ['success' => false, 'error' => 'Invalid precision value. Must be integer between 0 and 10.']; + } + $this->config['precision'] = $value; + + // In real app, notify subscribers of config://calculator/settings change + // $registry->notifyResourceChanged('config://calculator/settings'); + return ['success' => true, 'message' => "Precision updated to {$value}."]; + } + + if (!\is_bool($value)) { + // Attempt basic cast for flexibility + if (\in_array(strtolower((string) $value), ['true', '1', 'yes', 'on'])) { + $value = true; + } elseif (\in_array(strtolower((string) $value), ['false', '0', 'no', 'off'])) { + $value = false; + } else { + return ['success' => false, 'error' => 'Invalid allow_negative value. Must be boolean (true/false).']; + } + } + $this->config['allow_negative'] = $value; + + // $registry->notifyResourceChanged('config://calculator/settings'); + return ['success' => true, 'message' => 'Allow negative results set to '.($value ? 'true' : 'false').'.']; + } +} diff --git a/examples/01-discovery-stdio-calculator/server.php b/examples/01-discovery-stdio-calculator/server.php new file mode 100644 index 00000000..b54b6e98 --- /dev/null +++ b/examples/01-discovery-stdio-calculator/server.php @@ -0,0 +1,29 @@ +#!/usr/bin/env php +info('Starting MCP Stdio Calculator Server...'); + +Server::make() + ->withServerInfo('Stdio Calculator', '1.1.0', 'Basic Calculator over STDIO transport.') + ->withContainer(container()) + ->withLogger(logger()) + ->withDiscovery(__DIR__, ['.']) + ->build() + ->connect(new StdioTransport(logger: logger())); + +logger()->info('Server listener stopped gracefully.'); diff --git a/examples/02-discovery-http-userprofile/McpElements.php b/examples/02-discovery-http-userprofile/McpElements.php new file mode 100644 index 00000000..600f6ecc --- /dev/null +++ b/examples/02-discovery-http-userprofile/McpElements.php @@ -0,0 +1,141 @@ + ['name' => 'Alice', 'email' => 'alice@example.com', 'role' => 'admin'], + '102' => ['name' => 'Bob', 'email' => 'bob@example.com', 'role' => 'user'], + '103' => ['name' => 'Charlie', 'email' => 'charlie@example.com', 'role' => 'user'], + ]; + + public function __construct( + private LoggerInterface $logger, + ) { + $this->logger->debug('HttpUserProfileExample McpElements instantiated.'); + } + + /** + * Retrieves the profile data for a specific user. + * + * @param string $userId the ID of the user (from URI) + * + * @return array user profile data + * + * @throws McpServerException if the user is not found + */ + #[McpResourceTemplate( + uriTemplate: 'user://{userId}/profile', + name: 'user_profile', + description: 'Get profile information for a specific user ID.', + mimeType: 'application/json' + )] + public function getUserProfile( + #[CompletionProvider(values: ['101', '102', '103'])] + string $userId, + ): array { + $this->logger->info('Reading resource: user profile', ['userId' => $userId]); + if (!isset($this->users[$userId])) { + // Throwing an exception that Processor can turn into an error response + throw McpServerException::invalidParams("User profile not found for ID: {$userId}"); + } + + return $this->users[$userId]; + } + + /** + * Retrieves a list of all known user IDs. + * + * @return array list of user IDs + */ + #[McpResource( + uri: 'user://list/ids', + name: 'user_id_list', + description: 'Provides a list of all available user IDs.', + mimeType: 'application/json' + )] + public function listUserIds(): array + { + $this->logger->info('Reading resource: user ID list'); + + return array_keys($this->users); + } + + /** + * Sends a welcome message to a user. + * (This is a placeholder - in a real app, it might queue an email). + * + * @param string $userId the ID of the user to message + * @param string|null $customMessage an optional custom message part + * + * @return array status of the operation + */ + #[McpTool(name: 'send_welcome')] + public function sendWelcomeMessage(string $userId, ?string $customMessage = null): array + { + $this->logger->info('Executing tool: send_welcome', ['userId' => $userId]); + if (!isset($this->users[$userId])) { + return ['success' => false, 'error' => "User ID {$userId} not found."]; + } + $user = $this->users[$userId]; + $message = "Welcome, {$user['name']}!"; + if ($customMessage) { + $message .= ' '.$customMessage; + } + // Simulate sending + $this->logger->info("Simulated sending message to {$user['email']}: {$message}"); + + return ['success' => true, 'message_sent' => $message]; + } + + #[McpTool(name: 'test_tool_without_params')] + public function testToolWithoutParams(): array + { + return ['success' => true, 'message' => 'Test tool without params']; + } + + /** + * Generates a prompt to write a bio for a user. + * + * @param string $userId the user ID to generate the bio for + * @param string $tone Desired tone (e.g., 'formal', 'casual'). + * + * @return array prompt messages + * + * @throws McpServerException if user not found + */ + #[McpPrompt(name: 'generate_bio_prompt')] + public function generateBio( + #[CompletionProvider(provider: UserIdCompletionProvider::class)] + string $userId, + string $tone = 'professional', + ): array { + $this->logger->info('Executing prompt: generate_bio', ['userId' => $userId, 'tone' => $tone]); + if (!isset($this->users[$userId])) { + throw McpServerException::invalidParams("User not found for bio prompt: {$userId}"); + } + $user = $this->users[$userId]; + + return [ + ['role' => 'user', 'content' => "Write a short, {$tone} biography for {$user['name']} (Role: {$user['role']}, Email: {$user['email']}). Highlight their role within the system."], + ]; + } +} diff --git a/examples/02-discovery-http-userprofile/UserIdCompletionProvider.php b/examples/02-discovery-http-userprofile/UserIdCompletionProvider.php new file mode 100644 index 00000000..c11fb609 --- /dev/null +++ b/examples/02-discovery-http-userprofile/UserIdCompletionProvider.php @@ -0,0 +1,24 @@ + str_contains($userId, $currentValue)); + } +} diff --git a/examples/02-discovery-http-userprofile/server.php b/examples/02-discovery-http-userprofile/server.php new file mode 100644 index 00000000..2a76580c --- /dev/null +++ b/examples/02-discovery-http-userprofile/server.php @@ -0,0 +1,76 @@ +#!/usr/bin/env php +info('Starting MCP HTTP User Profile Server...'); + +// --- Setup DI Container for DI in McpElements class --- +$container = new Container(); +$container->set(LoggerInterface::class, logger()); + +Server::make() + ->withServerInfo('HTTP User Profiles', '1.0.0') + ->withLogger(logger()) + ->withContainer($container) + ->withDiscovery(__DIR__, ['.']) + ->withTool( + function (float $a, float $b, string $operation = 'add'): array { + $result = match ($operation) { + 'add' => $a + $b, + 'subtract' => $a - $b, + 'multiply' => $a * $b, + 'divide' => 0 != $b ? $a / $b : throw new InvalidArgumentException('Cannot divide by zero'), + default => throw new InvalidArgumentException("Unknown operation: {$operation}"), + }; + + return [ + 'operation' => $operation, + 'operands' => [$a, $b], + 'result' => $result, + ]; + }, + name: 'calculator', + description: 'Perform basic math operations (add, subtract, multiply, divide)' + ) + ->withResource( + function (): array { + $memoryUsage = memory_get_usage(true); + $memoryPeak = memory_get_peak_usage(true); + $uptime = time() - $_SERVER['REQUEST_TIME_FLOAT'] ?? time(); + $serverSoftware = $_SERVER['SERVER_SOFTWARE'] ?? 'CLI'; + + return [ + 'server_time' => date('Y-m-d H:i:s'), + 'uptime_seconds' => $uptime, + 'memory_usage_mb' => round($memoryUsage / 1024 / 1024, 2), + 'memory_peak_mb' => round($memoryPeak / 1024 / 1024, 2), + 'php_version' => \PHP_VERSION, + 'server_software' => $serverSoftware, + 'operating_system' => \PHP_OS_FAMILY, + 'status' => 'healthy', + ]; + }, + uri: 'system://status', + name: 'system_status', + description: 'Current system status and runtime information', + mimeType: 'application/json' + ) + ->build() + ->connect(new StreamableHttpServerTransport('127.0.0.1', 8080, 'mcp')); + +logger()->info('Server listener stopped gracefully.'); diff --git a/examples/03-manual-registration-stdio/SimpleHandlers.php b/examples/03-manual-registration-stdio/SimpleHandlers.php new file mode 100644 index 00000000..d646486f --- /dev/null +++ b/examples/03-manual-registration-stdio/SimpleHandlers.php @@ -0,0 +1,81 @@ +logger->info('SimpleHandlers instantiated for manual registration example.'); + } + + /** + * A manually registered tool to echo input. + * + * @param string $text the text to echo + * + * @return string the echoed text + */ + public function echoText(string $text): string + { + $this->logger->info("Manual tool 'echo_text' called.", ['text' => $text]); + + return 'Echo: '.$text; + } + + /** + * A manually registered resource providing app version. + * + * @return string the application version + */ + public function getAppVersion(): string + { + $this->logger->info("Manual resource 'app://version' read."); + + return $this->appVersion; + } + + /** + * A manually registered prompt template. + * + * @param string $userName the name of the user + * + * @return array the prompt messages + */ + public function greetingPrompt(string $userName): array + { + $this->logger->info("Manual prompt 'personalized_greeting' called.", ['userName' => $userName]); + + return [ + ['role' => 'user', 'content' => "Craft a personalized greeting for {$userName}."], + ]; + } + + /** + * A manually registered resource template. + * + * @param string $itemId the ID of the item + * + * @return array item details + */ + public function getItemDetails(string $itemId): array + { + $this->logger->info("Manual template 'item://{itemId}' resolved.", ['itemId' => $itemId]); + + return ['id' => $itemId, 'name' => "Item {$itemId}", 'description' => "Details for item {$itemId} from manual template."]; + } +} diff --git a/examples/03-manual-registration-stdio/server.php b/examples/03-manual-registration-stdio/server.php new file mode 100644 index 00000000..cea4001c --- /dev/null +++ b/examples/03-manual-registration-stdio/server.php @@ -0,0 +1,33 @@ +#!/usr/bin/env php +info('Starting MCP Manual Registration (Stdio) Server...'); + +Server::make() + ->withServerInfo('Manual Reg Server', '1.0.0') + ->withLogger(logger()) + ->withContainer(container()) + ->withTool([SimpleHandlers::class, 'echoText'], 'echo_text') + ->withResource([SimpleHandlers::class, 'getAppVersion'], 'app://version', 'application_version', mimeType: 'text/plain') + ->withPrompt([SimpleHandlers::class, 'greetingPrompt'], 'personalized_greeting') + ->withResourceTemplate([SimpleHandlers::class, 'getItemDetails'], 'item://{itemId}/details', 'get_item_details', mimeType: 'application/json') + ->build() + ->connect(new StdioTransport(logger: logger())); + +logger()->info('Server listener stopped gracefully.'); diff --git a/examples/04-combined-registration-http/DiscoveredElements.php b/examples/04-combined-registration-http/DiscoveredElements.php new file mode 100644 index 00000000..aacaf2a9 --- /dev/null +++ b/examples/04-combined-registration-http/DiscoveredElements.php @@ -0,0 +1,41 @@ +logger->info("Manual tool 'manual_greeter' called for {$user}"); + + return "Hello {$user}, from manual registration!"; + } + + /** + * Manually registered resource that overrides a discovered one. + * + * @return string content + */ + public function getPriorityConfigManual(): string + { + $this->logger->info("Manual resource 'config://priority' read."); + + return 'Manual Priority Config: HIGH (overrides discovered)'; + } +} diff --git a/examples/04-combined-registration-http/server.php b/examples/04-combined-registration-http/server.php new file mode 100644 index 00000000..4f86c409 --- /dev/null +++ b/examples/04-combined-registration-http/server.php @@ -0,0 +1,36 @@ +#!/usr/bin/env php +info('Starting MCP Combined Registration (HTTP) Server...'); + +Server::make() + ->withServerInfo('Combined HTTP Server', '1.0.0') + ->withLogger(logger()) + ->withContainer(container()) + ->withDiscovery(__DIR__, ['.']) + ->withTool([ManualHandlers::class, 'manualGreeter']) + ->withResource( + [ManualHandlers::class, 'getPriorityConfigManual'], + 'config://priority', + 'priority_config_manual', + ) + ->build() + ->connect(new HttpServerTransport('127.0.0.1', 8081, 'mcp_combined')); + +logger()->info('Server listener stopped gracefully.'); diff --git a/examples/05-stdio-env-variables/EnvToolHandler.php b/examples/05-stdio-env-variables/EnvToolHandler.php new file mode 100644 index 00000000..002f7cf0 --- /dev/null +++ b/examples/05-stdio-env-variables/EnvToolHandler.php @@ -0,0 +1,51 @@ + 'debug', + 'processed_input' => strtoupper($input), + 'message' => 'Processed in DEBUG mode.', + ]; + } elseif ('production' === $appMode) { + return [ + 'mode' => 'production', + 'processed_input_length' => \strlen($input), + 'message' => 'Processed in PRODUCTION mode (summary only).', + ]; + } else { + return [ + 'mode' => $appMode ?: 'default', + 'original_input' => $input, + 'message' => 'Processed in default mode (APP_MODE not recognized or not set).', + ]; + } + } +} diff --git a/examples/05-stdio-env-variables/server.php b/examples/05-stdio-env-variables/server.php new file mode 100644 index 00000000..134a1ae2 --- /dev/null +++ b/examples/05-stdio-env-variables/server.php @@ -0,0 +1,59 @@ +#!/usr/bin/env php +info('Starting MCP Stdio Environment Variable Example Server...'); + +Server::make() + ->withServerInfo('Env Var Server', '1.0.0') + ->withLogger(logger()) + ->withDiscovery(__DIR__, ['.']) + ->build() + ->connect(new StdioTransport(logger: logger())); + +logger()->info('Server listener stopped gracefully.'); diff --git a/examples/06-custom-dependencies-stdio/McpTaskHandlers.php b/examples/06-custom-dependencies-stdio/McpTaskHandlers.php new file mode 100644 index 00000000..ea32adb6 --- /dev/null +++ b/examples/06-custom-dependencies-stdio/McpTaskHandlers.php @@ -0,0 +1,89 @@ +logger->info('McpTaskHandlers instantiated with dependencies.'); + } + + /** + * Adds a new task for a given user. + * + * @param string $userId the ID of the user + * @param string $description the task description + * + * @return array the created task details + */ + #[McpTool(name: 'add_task')] + public function addTask(string $userId, string $description): array + { + $this->logger->info("Tool 'add_task' invoked", ['userId' => $userId]); + + return $this->taskRepo->addTask($userId, $description); + } + + /** + * Lists pending tasks for a specific user. + * + * @param string $userId the ID of the user + * + * @return array a list of tasks + */ + #[McpTool(name: 'list_user_tasks')] + public function listUserTasks(string $userId): array + { + $this->logger->info("Tool 'list_user_tasks' invoked", ['userId' => $userId]); + + return $this->taskRepo->getTasksForUser($userId); + } + + /** + * Marks a task as complete. + * + * @param int $taskId the ID of the task to complete + * + * @return array status of the operation + */ + #[McpTool(name: 'complete_task')] + public function completeTask(int $taskId): array + { + $this->logger->info("Tool 'complete_task' invoked", ['taskId' => $taskId]); + $success = $this->taskRepo->completeTask($taskId); + + return ['success' => $success, 'message' => $success ? "Task {$taskId} completed." : "Task {$taskId} not found."]; + } + + /** + * Provides current system statistics. + * + * @return array system statistics + */ + #[McpResource(uri: 'stats://system/overview', name: 'system_stats', mimeType: 'application/json')] + public function getSystemStatistics(): array + { + $this->logger->info("Resource 'stats://system/overview' invoked"); + + return $this->statsService->getSystemStats(); + } +} diff --git a/examples/06-custom-dependencies-stdio/Service/InMemoryTaskRepository.php b/examples/06-custom-dependencies-stdio/Service/InMemoryTaskRepository.php new file mode 100644 index 00000000..a6c5315c --- /dev/null +++ b/examples/06-custom-dependencies-stdio/Service/InMemoryTaskRepository.php @@ -0,0 +1,69 @@ +logger = $logger; + // Add some initial tasks + $this->addTask('user1', 'Buy groceries'); + $this->addTask('user1', 'Write MCP example'); + $this->addTask('user2', 'Review PR'); + } + + public function addTask(string $userId, string $description): array + { + $task = [ + 'id' => $this->nextTaskId++, + 'userId' => $userId, + 'description' => $description, + 'completed' => false, + 'createdAt' => date('c'), + ]; + $this->tasks[$task['id']] = $task; + $this->logger->info('Task added', ['id' => $task['id'], 'user' => $userId]); + + return $task; + } + + public function getTasksForUser(string $userId): array + { + return array_values(array_filter($this->tasks, fn ($task) => $task['userId'] === $userId && !$task['completed'])); + } + + public function getAllTasks(): array + { + return array_values($this->tasks); + } + + public function completeTask(int $taskId): bool + { + if (isset($this->tasks[$taskId])) { + $this->tasks[$taskId]['completed'] = true; + $this->logger->info('Task completed', ['id' => $taskId]); + + return true; + } + + return false; + } +} diff --git a/examples/06-custom-dependencies-stdio/Service/StatsServiceInterface.php b/examples/06-custom-dependencies-stdio/Service/StatsServiceInterface.php new file mode 100644 index 00000000..2c94c002 --- /dev/null +++ b/examples/06-custom-dependencies-stdio/Service/StatsServiceInterface.php @@ -0,0 +1,17 @@ +taskRepository = $taskRepository; + } + + public function getSystemStats(): array + { + $allTasks = $this->taskRepository->getAllTasks(); + $completed = \count(array_filter($allTasks, fn ($task) => $task['completed'])); + $pending = \count($allTasks) - $completed; + + return [ + 'total_tasks' => \count($allTasks), + 'completed_tasks' => $completed, + 'pending_tasks' => $pending, + 'server_uptime_seconds' => time() - $_SERVER['REQUEST_TIME_FLOAT'], // Approx uptime for CLI script + ]; + } +} diff --git a/examples/06-custom-dependencies-stdio/Service/TaskRepositoryInterface.php b/examples/06-custom-dependencies-stdio/Service/TaskRepositoryInterface.php new file mode 100644 index 00000000..7ae1c8a4 --- /dev/null +++ b/examples/06-custom-dependencies-stdio/Service/TaskRepositoryInterface.php @@ -0,0 +1,23 @@ +info('Starting MCP Custom Dependencies (Stdio) Server...'); + +$container = container(); + +$taskRepo = new Services\InMemoryTaskRepository(logger()); +$container->set(Services\TaskRepositoryInterface::class, $taskRepo); + +$statsService = new Services\SystemStatsService($taskRepo); +$container->set(Services\StatsServiceInterface::class, $statsService); + +Server::make() + ->withServerInfo('Task Manager Server', '1.0.0') + ->withLogger(logger()) + ->withContainer($container) + ->withDiscovery(__DIR__, ['.']) + ->build() + ->connect(new StdioTransport(logger: logger())); + +logger()->info('Server listener stopped gracefully.'); diff --git a/examples/07-complex-tool-schema-http/McpEventScheduler.php b/examples/07-complex-tool-schema-http/McpEventScheduler.php new file mode 100644 index 00000000..0b29cdeb --- /dev/null +++ b/examples/07-complex-tool-schema-http/McpEventScheduler.php @@ -0,0 +1,72 @@ +logger->info("Tool 'schedule_event' called", compact('title', 'date', 'type', 'time', 'priority', 'attendees', 'sendInvites')); + + // Simulate scheduling logic + $eventDetails = [ + 'title' => $title, + 'date' => $date, + 'type' => $type->value, // Use enum value + 'time' => $time ?? 'All day', + 'priority' => $priority->name, // Use enum name + 'attendees' => $attendees ?? [], + 'invites_will_be_sent' => ($attendees && $sendInvites), + ]; + + // In a real app, this would interact with a calendar service + $this->logger->info('Event scheduled', ['details' => $eventDetails]); + + return [ + 'success' => true, + 'message' => "Event '{$title}' scheduled successfully for {$date}.", + 'event_details' => $eventDetails, + ]; + } +} diff --git a/examples/07-complex-tool-schema-http/Model/EventPriority.php b/examples/07-complex-tool-schema-http/Model/EventPriority.php new file mode 100644 index 00000000..1ec29543 --- /dev/null +++ b/examples/07-complex-tool-schema-http/Model/EventPriority.php @@ -0,0 +1,19 @@ +info('Starting MCP Complex Schema HTTP Server...'); + +Server::make() + ->withServerInfo('Event Scheduler Server', '1.0.0') + ->withLogger(logger()) + ->withContainer(container()) + ->withDiscovery(__DIR__, ['.']) + ->build() + ->connect(new HttpServerTransport('127.0.0.1', 8082, 'mcp_scheduler')); + +logger()->info('Server listener stopped gracefully.'); diff --git a/examples/08-schema-showcase-streamable/SchemaShowcaseElements.php b/examples/08-schema-showcase-streamable/SchemaShowcaseElements.php new file mode 100644 index 00000000..3521e2e3 --- /dev/null +++ b/examples/08-schema-showcase-streamable/SchemaShowcaseElements.php @@ -0,0 +1,441 @@ + strtoupper($text), + 'lowercase' => strtolower($text), + 'title' => ucwords(strtolower($text)), + 'sentence' => ucfirst(strtolower($text)), + default => $text, + }; + + return [ + 'original' => $text, + 'formatted' => $formatted, + 'length' => \strlen($text), + 'format_applied' => $format, + ]; + } + + /** + * Performs mathematical operations with numeric constraints. + * + * Demonstrates: METHOD-LEVEL Schema + */ + #[McpTool(name: 'calculate_range')] + #[Schema( + type: 'object', + properties: [ + 'first' => [ + 'type' => 'number', + 'description' => 'First number (must be between 0 and 1000)', + 'minimum' => 0, + 'maximum' => 1000, + ], + 'second' => [ + 'type' => 'number', + 'description' => 'Second number (must be between 0 and 1000)', + 'minimum' => 0, + 'maximum' => 1000, + ], + 'operation' => [ + 'type' => 'string', + 'description' => 'Operation to perform', + 'enum' => ['add', 'subtract', 'multiply', 'divide', 'power'], + ], + 'precision' => [ + 'type' => 'integer', + 'description' => 'Decimal precision (must be multiple of 2, between 0-10)', + 'minimum' => 0, + 'maximum' => 10, + 'multipleOf' => 2, + ], + ], + required: ['first', 'second', 'operation'], + )] + public function calculateRange(float $first, float $second, string $operation, int $precision = 2): array + { + fwrite(\STDERR, "Calculate range tool called: $first $operation $second (precision: $precision)\n"); + + $result = match ($operation) { + 'add' => $first + $second, + 'subtract' => $first - $second, + 'multiply' => $first * $second, + 'divide' => 0 != $second ? $first / $second : null, + 'power' => $first ** $second, + default => null, + }; + + if (null === $result) { + return [ + 'error' => 'divide' === $operation ? 'Division by zero' : 'Invalid operation', + 'inputs' => compact('first', 'second', 'operation', 'precision'), + ]; + } + + return [ + 'result' => round($result, $precision), + 'operation' => "$first $operation $second", + 'precision' => $precision, + 'within_bounds' => $result >= 0 && $result <= 1000000, + ]; + } + + /** + * Processes user profile data with object schema validation. + * Demonstrates: object properties, required fields, additionalProperties. + */ + #[McpTool( + name: 'validate_profile', + description: 'Validates and processes user profile data with strict schema requirements.' + )] + public function validateProfile( + #[Schema( + type: 'object', + description: 'User profile information', + properties: [ + 'name' => [ + 'type' => 'string', + 'minLength' => 2, + 'maxLength' => 50, + 'description' => 'Full name', + ], + 'email' => [ + 'type' => 'string', + 'format' => 'email', + 'description' => 'Valid email address', + ], + 'age' => [ + 'type' => 'integer', + 'minimum' => 13, + 'maximum' => 120, + 'description' => 'Age in years', + ], + 'role' => [ + 'type' => 'string', + 'enum' => ['user', 'admin', 'moderator', 'guest'], + 'description' => 'User role', + ], + 'preferences' => [ + 'type' => 'object', + 'properties' => [ + 'notifications' => ['type' => 'boolean'], + 'theme' => ['type' => 'string', 'enum' => ['light', 'dark', 'auto']], + ], + 'additionalProperties' => false, + ], + ], + required: ['name', 'email', 'age'], + additionalProperties: true + )] + array $profile, + ): array { + fwrite(\STDERR, 'Validate profile tool called with: '.json_encode($profile)."\n"); + + $errors = []; + $warnings = []; + + // Additional business logic validation + if (isset($profile['age']) && $profile['age'] < 18 && ($profile['role'] ?? 'user') === 'admin') { + $errors[] = 'Admin role requires age 18 or older'; + } + + if (isset($profile['email']) && !filter_var($profile['email'], \FILTER_VALIDATE_EMAIL)) { + $errors[] = 'Invalid email format'; + } + + if (!isset($profile['role'])) { + $warnings[] = 'No role specified, defaulting to "user"'; + $profile['role'] = 'user'; + } + + return [ + 'valid' => empty($errors), + 'profile' => $profile, + 'errors' => $errors, + 'warnings' => $warnings, + 'processed_at' => date('Y-m-d H:i:s'), + ]; + } + + /** + * Manages a list of items with array constraints. + * Demonstrates: array items, minItems, maxItems, uniqueItems. + */ + #[McpTool( + name: 'manage_list', + description: 'Manages a list of items with size and uniqueness constraints.' + )] + public function manageList( + #[Schema( + type: 'array', + description: 'List of items to manage (2-10 unique strings)', + items: [ + 'type' => 'string', + 'minLength' => 1, + 'maxLength' => 30, + ], + minItems: 2, + maxItems: 10, + uniqueItems: true + )] + array $items, + + #[Schema( + type: 'string', + description: 'Action to perform on the list', + enum: ['sort', 'reverse', 'shuffle', 'deduplicate', 'filter_short', 'filter_long'] + )] + string $action = 'sort', + ): array { + fwrite(\STDERR, 'Manage list tool called with '.\count($items)." items, action: $action\n"); + + $original = $items; + $processed = $items; + + switch ($action) { + case 'sort': + sort($processed); + break; + case 'reverse': + $processed = array_reverse($processed); + break; + case 'shuffle': + shuffle($processed); + break; + case 'deduplicate': + $processed = array_unique($processed); + break; + case 'filter_short': + $processed = array_filter($processed, fn ($item) => \strlen($item) <= 10); + break; + case 'filter_long': + $processed = array_filter($processed, fn ($item) => \strlen($item) > 10); + break; + } + + return [ + 'original_count' => \count($original), + 'processed_count' => \count($processed), + 'action' => $action, + 'original' => $original, + 'processed' => array_values($processed), // Re-index array + 'stats' => [ + 'average_length' => \count($processed) > 0 ? round(array_sum(array_map('strlen', $processed)) / \count($processed), 2) : 0, + 'shortest' => \count($processed) > 0 ? min(array_map('strlen', $processed)) : 0, + 'longest' => \count($processed) > 0 ? max(array_map('strlen', $processed)) : 0, + ], + ]; + } + + /** + * Generates configuration with format validation. + * Demonstrates: format constraints (date-time, uri, etc). + */ + #[McpTool( + name: 'generate_config', + description: 'Generates configuration with format-validated inputs.' + )] + public function generateConfig( + #[Schema( + type: 'string', + description: 'Application name (alphanumeric with hyphens)', + minLength: 3, + maxLength: 20, + pattern: '^[a-zA-Z0-9\-]+$' + )] + string $appName, + + #[Schema( + type: 'string', + description: 'Valid URL for the application', + format: 'uri' + )] + string $baseUrl, + + #[Schema( + type: 'string', + description: 'Environment type', + enum: ['development', 'staging', 'production'] + )] + string $environment = 'development', + + #[Schema( + type: 'boolean', + description: 'Enable debug mode' + )] + bool $debug = true, + + #[Schema( + type: 'integer', + description: 'Port number (1024-65535)', + minimum: 1024, + maximum: 65535 + )] + int $port = 8080, + ): array { + fwrite(\STDERR, "Generate config tool called for app: $appName\n"); + + $config = [ + 'app' => [ + 'name' => $appName, + 'env' => $environment, + 'debug' => $debug, + 'url' => $baseUrl, + 'port' => $port, + ], + 'generated_at' => date('c'), // ISO 8601 format + 'version' => '1.0.0', + 'features' => [ + 'logging' => 'production' !== $environment || $debug, + 'caching' => 'production' === $environment, + 'analytics' => 'production' === $environment, + 'rate_limiting' => 'development' !== $environment, + ], + ]; + + return [ + 'success' => true, + 'config' => $config, + 'validation' => [ + 'app_name_valid' => 1 === preg_match('/^[a-zA-Z0-9\-]+$/', $appName), + 'url_valid' => false !== filter_var($baseUrl, \FILTER_VALIDATE_URL), + 'port_in_range' => $port >= 1024 && $port <= 65535, + ], + ]; + } + + /** + * Processes time-based data with date-time format validation. + * Demonstrates: date-time format, exclusiveMinimum, exclusiveMaximum. + */ + #[McpTool( + name: 'schedule_event', + description: 'Schedules an event with time validation and constraints.' + )] + public function scheduleEvent( + #[Schema( + type: 'string', + description: 'Event title (3-50 characters)', + minLength: 3, + maxLength: 50 + )] + string $title, + + #[Schema( + type: 'string', + description: 'Event start time in ISO 8601 format', + format: 'date-time' + )] + string $startTime, + + #[Schema( + type: 'number', + description: 'Duration in hours (minimum 0.5, maximum 24)', + minimum: 0.5, + maximum: 24, + multipleOf: 0.5 + )] + float $durationHours, + + #[Schema( + type: 'string', + description: 'Event priority level', + enum: ['low', 'medium', 'high', 'urgent'] + )] + string $priority = 'medium', + + #[Schema( + type: 'array', + description: 'List of attendee email addresses', + items: [ + 'type' => 'string', + 'format' => 'email', + ], + maxItems: 20 + )] + array $attendees = [], + ): array { + fwrite(\STDERR, "Schedule event tool called: $title at $startTime\n"); + + $start = \DateTime::createFromFormat(\DateTime::ISO8601, $startTime); + if (!$start) { + $start = \DateTime::createFromFormat('Y-m-d\TH:i:s\Z', $startTime); + } + + if (!$start) { + return [ + 'success' => false, + 'error' => 'Invalid date-time format. Use ISO 8601 format.', + 'example' => '2024-01-15T14:30:00Z', + ]; + } + + $end = clone $start; + $end->add(new \DateInterval('PT'.($durationHours * 60).'M')); + + $event = [ + 'id' => uniqid('event_'), + 'title' => $title, + 'start_time' => $start->format('c'), + 'end_time' => $end->format('c'), + 'duration_hours' => $durationHours, + 'priority' => $priority, + 'attendees' => $attendees, + 'created_at' => date('c'), + ]; + + return [ + 'success' => true, + 'event' => $event, + 'info' => [ + 'attendee_count' => \count($attendees), + 'is_all_day' => $durationHours >= 24, + 'is_future' => $start > new \DateTime(), + 'timezone_note' => 'Times are in UTC', + ], + ]; + } +} diff --git a/examples/08-schema-showcase-streamable/server.php b/examples/08-schema-showcase-streamable/server.php new file mode 100644 index 00000000..df348a5a --- /dev/null +++ b/examples/08-schema-showcase-streamable/server.php @@ -0,0 +1,28 @@ +#!/usr/bin/env php +info('Starting MCP Schema Showcase Server...'); + +Server::make() + ->withServerInfo('Schema Showcase', '1.0.0') + ->withLogger(logger()) + ->withDiscovery(__DIR__, ['.']) + ->build() + ->connect(new StreamableHttpServerTransport('127.0.0.1', 8080, 'mcp')); + +logger()->info('Server listener stopped gracefully.'); diff --git a/examples/cli/README.md b/examples/09-standalone-cli/README.md similarity index 50% rename from examples/cli/README.md rename to examples/09-standalone-cli/README.md index 9fc7de49..ff9b93cb 100644 --- a/examples/cli/README.md +++ b/examples/09-standalone-cli/README.md @@ -1,15 +1,13 @@ -# Example app with CLI +# Standalone example app with CLI -This is just for testing and debugging purposes. +This is just for testing and debugging purposes. Different from the other examples, this one does not use the same +autoloader, but installs the SDK via path repository and therefore has mostly decoupled dependencies. - -Install and create symlink with: +Install dependencies: ```bash -cd /path/to/your/project/examples/cli +cd /path/to/your/project/examples/09-standalone-cli composer update -rm -rf vendor/mcp/sdk/src -ln -s /path/to/your/project/src /path/to/your/project/examples/cli/vendor/mcp/sdk/src ``` Run the CLI with: diff --git a/examples/cli/composer.json b/examples/09-standalone-cli/composer.json similarity index 100% rename from examples/cli/composer.json rename to examples/09-standalone-cli/composer.json diff --git a/examples/cli/example-requests.json b/examples/09-standalone-cli/example-requests.json similarity index 100% rename from examples/cli/example-requests.json rename to examples/09-standalone-cli/example-requests.json diff --git a/examples/cli/index.php b/examples/09-standalone-cli/index.php similarity index 84% rename from examples/cli/index.php rename to examples/09-standalone-cli/index.php index 8c59cc58..f7a67423 100644 --- a/examples/cli/index.php +++ b/examples/09-standalone-cli/index.php @@ -17,7 +17,6 @@ $debug = (bool) ($_SERVER['DEBUG'] ?? false); // Setup input, output and logger -$input = new SymfonyConsole\Input\ArgvInput($argv); $output = new SymfonyConsole\Output\ConsoleOutput($debug ? OutputInterface::VERBOSITY_VERY_VERBOSE : OutputInterface::VERBOSITY_NORMAL); $logger = new SymfonyConsole\Logger\ConsoleLogger($output); @@ -31,8 +30,8 @@ // Set up the server $sever = new Mcp\Server($jsonRpcHandler, $logger); -// Create the transport layer using Symfony Console -$transport = new Mcp\Server\Transport\Stdio\SymfonyConsoleTransport($input, $output); +// Create the transport layer using Stdio +$transport = new Mcp\Server\Transport\StdioTransport(logger: $logger); // Start our application $sever->connect($transport); diff --git a/examples/cli/src/Builder.php b/examples/09-standalone-cli/src/Builder.php similarity index 100% rename from examples/cli/src/Builder.php rename to examples/09-standalone-cli/src/Builder.php diff --git a/examples/cli/src/ExamplePrompt.php b/examples/09-standalone-cli/src/ExamplePrompt.php similarity index 100% rename from examples/cli/src/ExamplePrompt.php rename to examples/09-standalone-cli/src/ExamplePrompt.php diff --git a/examples/cli/src/ExampleResource.php b/examples/09-standalone-cli/src/ExampleResource.php similarity index 100% rename from examples/cli/src/ExampleResource.php rename to examples/09-standalone-cli/src/ExampleResource.php diff --git a/examples/cli/src/ExampleTool.php b/examples/09-standalone-cli/src/ExampleTool.php similarity index 100% rename from examples/cli/src/ExampleTool.php rename to examples/09-standalone-cli/src/ExampleTool.php diff --git a/examples/README.md b/examples/README.md new file mode 100644 index 00000000..aef7d119 --- /dev/null +++ b/examples/README.md @@ -0,0 +1,30 @@ +# MCP SDK Examples + +This directory contains various examples of how to use the PHP MCP SDK. + +You can run examples 01-08 already with the dependencies installed in the root directory of the SDK, for example 09 see +README in the `examples/09-standalone-cli` directory. + +For running an example, you execute the `server.php` like this: +```bash +php examples/01-discovery-stdio-calculator/server.php +``` + +You will see debug outputs to help you understand what is happening. + +Run with Inspector: + +```bash +npx @modelcontextprotocol/inspector php examples/01-discovery-stdio-calculator/server.php +``` + +## Debugging + +You can enable debug output by setting the `DEBUG` environment variable to `1`, and additionally log to a file by +setting the `FILE_LOG` environment variable to `1` as well. A `dev.log` file gets written within the example's +directory. + +With the Inspector you can set the environment variables like this: +```bash +npx @modelcontextprotocol/inspector -e DEBUG=1 -e FILE_LOG=1 php examples/01-discovery-stdio-calculator/server.php +``` diff --git a/examples/bootstrap.php b/examples/bootstrap.php new file mode 100644 index 00000000..ca332791 --- /dev/null +++ b/examples/bootstrap.php @@ -0,0 +1,60 @@ +getMessage()."\n"); + fwrite(\STDERR, 'File: '.$t->getFile().':'.$t->getLine()."\n"); + fwrite(\STDERR, $t->getTraceAsString()."\n"); + + exit(1); +}); + +function logger(): LoggerInterface +{ + return new class extends AbstractLogger { + public function log($level, Stringable|string $message, array $context = []): void + { + $debug = $_SERVER['DEBUG'] ?? false; + + if (!$debug && 'debug' === $level) { + return; + } + + $logMessage = sprintf( + "[%s] %s %s\n", + strtoupper($level), + $message, + ([] === $context || !$debug) ? '' : json_encode($context), + ); + + if ($_SERVER['FILE_LOG'] ?? false) { + file_put_contents('dev.log', $logMessage, \FILE_APPEND); + } + + fwrite(\STDERR, $logMessage); + } + }; +} + +function container(): Container +{ + $container = new Container(); + $container->set(LoggerInterface::class, logger()); + + return $container; +} diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon new file mode 100644 index 00000000..aea6ce03 --- /dev/null +++ b/phpstan-baseline.neon @@ -0,0 +1,715 @@ +parameters: + ignoreErrors: + - + message: '#^Call to static method invalidParams\(\) on an unknown class Mcp\\Example\\HttpUserProfileExample\\McpServerException\.$#' + identifier: class.notFound + count: 2 + path: examples/02-discovery-http-userprofile/McpElements.php + + - + message: '#^Method Mcp\\Example\\HttpUserProfileExample\\McpElements\:\:generateBio\(\) return type has no value type specified in iterable type array\.$#' + identifier: missingType.iterableValue + count: 1 + path: examples/02-discovery-http-userprofile/McpElements.php + + - + message: '#^Method Mcp\\Example\\HttpUserProfileExample\\McpElements\:\:getUserProfile\(\) return type has no value type specified in iterable type array\.$#' + identifier: missingType.iterableValue + count: 1 + path: examples/02-discovery-http-userprofile/McpElements.php + + - + message: '#^Method Mcp\\Example\\HttpUserProfileExample\\McpElements\:\:listUserIds\(\) return type has no value type specified in iterable type array\.$#' + identifier: missingType.iterableValue + count: 1 + path: examples/02-discovery-http-userprofile/McpElements.php + + - + message: '#^Method Mcp\\Example\\HttpUserProfileExample\\McpElements\:\:sendWelcomeMessage\(\) return type has no value type specified in iterable type array\.$#' + identifier: missingType.iterableValue + count: 1 + path: examples/02-discovery-http-userprofile/McpElements.php + + - + message: '#^Method Mcp\\Example\\HttpUserProfileExample\\McpElements\:\:testToolWithoutParams\(\) return type has no value type specified in iterable type array\.$#' + identifier: missingType.iterableValue + count: 1 + path: examples/02-discovery-http-userprofile/McpElements.php + + - + message: '#^PHPDoc tag @throws with type Mcp\\Example\\HttpUserProfileExample\\McpServerException is not subtype of Throwable$#' + identifier: throws.notThrowable + count: 2 + path: examples/02-discovery-http-userprofile/McpElements.php + + - + message: '#^Property Mcp\\Example\\HttpUserProfileExample\\McpElements\:\:\$users type has no value type specified in iterable type array\.$#' + identifier: missingType.iterableValue + count: 1 + path: examples/02-discovery-http-userprofile/McpElements.php + + - + message: '#^Expression on left side of \?\? is not nullable\.$#' + identifier: nullCoalesce.expr + count: 1 + path: examples/02-discovery-http-userprofile/server.php + + - + message: '#^Instantiated class StreamableHttpServerTransport not found\.$#' + identifier: class.notFound + count: 1 + path: examples/02-discovery-http-userprofile/server.php + + - + message: '#^Parameter \#1 \$transport of method Mcp\\Server\:\:connect\(\) expects Mcp\\Server\\TransportInterface, StreamableHttpServerTransport given\.$#' + identifier: argument.type + count: 1 + path: examples/02-discovery-http-userprofile/server.php + + - + message: '#^Method Mcp\\Example\\ManualStdioExample\\SimpleHandlers\:\:getItemDetails\(\) return type has no value type specified in iterable type array\.$#' + identifier: missingType.iterableValue + count: 1 + path: examples/03-manual-registration-stdio/SimpleHandlers.php + + - + message: '#^Method Mcp\\Example\\ManualStdioExample\\SimpleHandlers\:\:greetingPrompt\(\) return type has no value type specified in iterable type array\.$#' + identifier: missingType.iterableValue + count: 1 + path: examples/03-manual-registration-stdio/SimpleHandlers.php + + - + message: '#^Class Mcp\\CombinedHttpExample\\Manual\\ManualHandlers not found\.$#' + identifier: class.notFound + count: 2 + path: examples/04-combined-registration-http/server.php + + - + message: '#^Instantiated class Mcp\\Server\\Transports\\HttpServerTransport not found\.$#' + identifier: class.notFound + count: 1 + path: examples/04-combined-registration-http/server.php + + - + message: '#^Parameter \#1 \$transport of method Mcp\\Server\:\:connect\(\) expects Mcp\\Server\\TransportInterface, Mcp\\Server\\Transports\\HttpServerTransport given\.$#' + identifier: argument.type + count: 1 + path: examples/04-combined-registration-http/server.php + + - + message: '#^Method Mcp\\Example\\StdioEnvVariables\\EnvToolHandler\:\:processData\(\) return type has no value type specified in iterable type array\.$#' + identifier: missingType.iterableValue + count: 1 + path: examples/05-stdio-env-variables/EnvToolHandler.php + + - + message: '#^Call to method addTask\(\) on an unknown class Mcp\\DependenciesStdioExample\\Services\\TaskRepositoryInterface\.$#' + identifier: class.notFound + count: 1 + path: examples/06-custom-dependencies-stdio/McpTaskHandlers.php + + - + message: '#^Call to method completeTask\(\) on an unknown class Mcp\\DependenciesStdioExample\\Services\\TaskRepositoryInterface\.$#' + identifier: class.notFound + count: 1 + path: examples/06-custom-dependencies-stdio/McpTaskHandlers.php + + - + message: '#^Call to method getSystemStats\(\) on an unknown class Mcp\\DependenciesStdioExample\\Services\\StatsServiceInterface\.$#' + identifier: class.notFound + count: 1 + path: examples/06-custom-dependencies-stdio/McpTaskHandlers.php + + - + message: '#^Call to method getTasksForUser\(\) on an unknown class Mcp\\DependenciesStdioExample\\Services\\TaskRepositoryInterface\.$#' + identifier: class.notFound + count: 1 + path: examples/06-custom-dependencies-stdio/McpTaskHandlers.php + + - + message: '#^Method Mcp\\Example\\DependenciesStdioExample\\McpTaskHandlers\:\:addTask\(\) return type has no value type specified in iterable type array\.$#' + identifier: missingType.iterableValue + count: 1 + path: examples/06-custom-dependencies-stdio/McpTaskHandlers.php + + - + message: '#^Method Mcp\\Example\\DependenciesStdioExample\\McpTaskHandlers\:\:completeTask\(\) return type has no value type specified in iterable type array\.$#' + identifier: missingType.iterableValue + count: 1 + path: examples/06-custom-dependencies-stdio/McpTaskHandlers.php + + - + message: '#^Method Mcp\\Example\\DependenciesStdioExample\\McpTaskHandlers\:\:getSystemStatistics\(\) return type has no value type specified in iterable type array\.$#' + identifier: missingType.iterableValue + count: 1 + path: examples/06-custom-dependencies-stdio/McpTaskHandlers.php + + - + message: '#^Method Mcp\\Example\\DependenciesStdioExample\\McpTaskHandlers\:\:listUserTasks\(\) return type has no value type specified in iterable type array\.$#' + identifier: missingType.iterableValue + count: 1 + path: examples/06-custom-dependencies-stdio/McpTaskHandlers.php + + - + message: '#^Parameter \$statsService of method Mcp\\Example\\DependenciesStdioExample\\McpTaskHandlers\:\:__construct\(\) has invalid type Mcp\\DependenciesStdioExample\\Services\\StatsServiceInterface\.$#' + identifier: class.notFound + count: 1 + path: examples/06-custom-dependencies-stdio/McpTaskHandlers.php + + - + message: '#^Parameter \$taskRepo of method Mcp\\Example\\DependenciesStdioExample\\McpTaskHandlers\:\:__construct\(\) has invalid type Mcp\\DependenciesStdioExample\\Services\\TaskRepositoryInterface\.$#' + identifier: class.notFound + count: 1 + path: examples/06-custom-dependencies-stdio/McpTaskHandlers.php + + - + message: '#^Property Mcp\\Example\\DependenciesStdioExample\\McpTaskHandlers\:\:\$statsService has unknown class Mcp\\DependenciesStdioExample\\Services\\StatsServiceInterface as its type\.$#' + identifier: class.notFound + count: 1 + path: examples/06-custom-dependencies-stdio/McpTaskHandlers.php + + - + message: '#^Property Mcp\\Example\\DependenciesStdioExample\\McpTaskHandlers\:\:\$taskRepo has unknown class Mcp\\DependenciesStdioExample\\Services\\TaskRepositoryInterface as its type\.$#' + identifier: class.notFound + count: 1 + path: examples/06-custom-dependencies-stdio/McpTaskHandlers.php + + - + message: '#^Method Mcp\\Example\\DependenciesStdioExample\\Service\\InMemoryTaskRepository\:\:addTask\(\) return type has no value type specified in iterable type array\.$#' + identifier: missingType.iterableValue + count: 1 + path: examples/06-custom-dependencies-stdio/Service/InMemoryTaskRepository.php + + - + message: '#^Method Mcp\\Example\\DependenciesStdioExample\\Service\\InMemoryTaskRepository\:\:getAllTasks\(\) return type has no value type specified in iterable type array\.$#' + identifier: missingType.iterableValue + count: 1 + path: examples/06-custom-dependencies-stdio/Service/InMemoryTaskRepository.php + + - + message: '#^Method Mcp\\Example\\DependenciesStdioExample\\Service\\InMemoryTaskRepository\:\:getTasksForUser\(\) return type has no value type specified in iterable type array\.$#' + identifier: missingType.iterableValue + count: 1 + path: examples/06-custom-dependencies-stdio/Service/InMemoryTaskRepository.php + + - + message: '#^Property Mcp\\Example\\DependenciesStdioExample\\Service\\InMemoryTaskRepository\:\:\$tasks type has no value type specified in iterable type array\.$#' + identifier: missingType.iterableValue + count: 1 + path: examples/06-custom-dependencies-stdio/Service/InMemoryTaskRepository.php + + - + message: '#^Method Mcp\\Example\\DependenciesStdioExample\\Service\\StatsServiceInterface\:\:getSystemStats\(\) return type has no value type specified in iterable type array\.$#' + identifier: missingType.iterableValue + count: 1 + path: examples/06-custom-dependencies-stdio/Service/StatsServiceInterface.php + + - + message: '#^Method Mcp\\Example\\DependenciesStdioExample\\Service\\SystemStatsService\:\:getSystemStats\(\) return type has no value type specified in iterable type array\.$#' + identifier: missingType.iterableValue + count: 1 + path: examples/06-custom-dependencies-stdio/Service/SystemStatsService.php + + - + message: '#^Method Mcp\\Example\\DependenciesStdioExample\\Service\\TaskRepositoryInterface\:\:addTask\(\) return type has no value type specified in iterable type array\.$#' + identifier: missingType.iterableValue + count: 1 + path: examples/06-custom-dependencies-stdio/Service/TaskRepositoryInterface.php + + - + message: '#^Method Mcp\\Example\\DependenciesStdioExample\\Service\\TaskRepositoryInterface\:\:getAllTasks\(\) return type has no value type specified in iterable type array\.$#' + identifier: missingType.iterableValue + count: 1 + path: examples/06-custom-dependencies-stdio/Service/TaskRepositoryInterface.php + + - + message: '#^Method Mcp\\Example\\DependenciesStdioExample\\Service\\TaskRepositoryInterface\:\:getTasksForUser\(\) return type has no value type specified in iterable type array\.$#' + identifier: missingType.iterableValue + count: 1 + path: examples/06-custom-dependencies-stdio/Service/TaskRepositoryInterface.php + + - + message: '#^Class Mcp\\DependenciesStdioExample\\Services\\StatsServiceInterface not found\.$#' + identifier: class.notFound + count: 1 + path: examples/06-custom-dependencies-stdio/server.php + + - + message: '#^Class Mcp\\DependenciesStdioExample\\Services\\TaskRepositoryInterface not found\.$#' + identifier: class.notFound + count: 1 + path: examples/06-custom-dependencies-stdio/server.php + + - + message: '#^Instantiated class Mcp\\DependenciesStdioExample\\Services\\InMemoryTaskRepository not found\.$#' + identifier: class.notFound + count: 1 + path: examples/06-custom-dependencies-stdio/server.php + + - + message: '#^Instantiated class Mcp\\DependenciesStdioExample\\Services\\SystemStatsService not found\.$#' + identifier: class.notFound + count: 1 + path: examples/06-custom-dependencies-stdio/server.php + + - + message: '#^Access to constant Normal on an unknown class Mcp\\ComplexSchemaHttpExample\\Model\\EventPriority\.$#' + identifier: class.notFound + count: 1 + path: examples/07-complex-tool-schema-http/McpEventScheduler.php + + - + message: '#^Access to property \$name on an unknown class Mcp\\ComplexSchemaHttpExample\\Model\\EventPriority\.$#' + identifier: class.notFound + count: 1 + path: examples/07-complex-tool-schema-http/McpEventScheduler.php + + - + message: '#^Access to property \$value on an unknown class Mcp\\ComplexSchemaHttpExample\\Model\\EventType\.$#' + identifier: class.notFound + count: 1 + path: examples/07-complex-tool-schema-http/McpEventScheduler.php + + - + message: '#^Method Mcp\\Example\\ComplexSchemaHttpExample\\McpEventScheduler\:\:scheduleEvent\(\) return type has no value type specified in iterable type array\.$#' + identifier: missingType.iterableValue + count: 1 + path: examples/07-complex-tool-schema-http/McpEventScheduler.php + + - + message: '#^Parameter \$priority of method Mcp\\Example\\ComplexSchemaHttpExample\\McpEventScheduler\:\:scheduleEvent\(\) has invalid type Mcp\\ComplexSchemaHttpExample\\Model\\EventPriority\.$#' + identifier: class.notFound + count: 2 + path: examples/07-complex-tool-schema-http/McpEventScheduler.php + + - + message: '#^Parameter \$type of method Mcp\\Example\\ComplexSchemaHttpExample\\McpEventScheduler\:\:scheduleEvent\(\) has invalid type Mcp\\ComplexSchemaHttpExample\\Model\\EventType\.$#' + identifier: class.notFound + count: 2 + path: examples/07-complex-tool-schema-http/McpEventScheduler.php + + - + message: '#^Instantiated class Mcp\\Server\\Transports\\HttpServerTransport not found\.$#' + identifier: class.notFound + count: 1 + path: examples/07-complex-tool-schema-http/server.php + + - + message: '#^Parameter \#1 \$transport of method Mcp\\Server\:\:connect\(\) expects Mcp\\Server\\TransportInterface, Mcp\\Server\\Transports\\HttpServerTransport given\.$#' + identifier: argument.type + count: 1 + path: examples/07-complex-tool-schema-http/server.php + + - + message: '#^Method Mcp\\Example\\SchemaShowcaseExample\\SchemaShowcaseElements\:\:calculateRange\(\) return type has no value type specified in iterable type array\.$#' + identifier: missingType.iterableValue + count: 1 + path: examples/08-schema-showcase-streamable/SchemaShowcaseElements.php + + - + message: '#^Method Mcp\\Example\\SchemaShowcaseExample\\SchemaShowcaseElements\:\:formatText\(\) return type has no value type specified in iterable type array\.$#' + identifier: missingType.iterableValue + count: 1 + path: examples/08-schema-showcase-streamable/SchemaShowcaseElements.php + + - + message: '#^Method Mcp\\Example\\SchemaShowcaseExample\\SchemaShowcaseElements\:\:generateConfig\(\) return type has no value type specified in iterable type array\.$#' + identifier: missingType.iterableValue + count: 1 + path: examples/08-schema-showcase-streamable/SchemaShowcaseElements.php + + - + message: '#^Method Mcp\\Example\\SchemaShowcaseExample\\SchemaShowcaseElements\:\:manageList\(\) has parameter \$items with no value type specified in iterable type array\.$#' + identifier: missingType.iterableValue + count: 1 + path: examples/08-schema-showcase-streamable/SchemaShowcaseElements.php + + - + message: '#^Method Mcp\\Example\\SchemaShowcaseExample\\SchemaShowcaseElements\:\:manageList\(\) return type has no value type specified in iterable type array\.$#' + identifier: missingType.iterableValue + count: 1 + path: examples/08-schema-showcase-streamable/SchemaShowcaseElements.php + + - + message: '#^Method Mcp\\Example\\SchemaShowcaseExample\\SchemaShowcaseElements\:\:scheduleEvent\(\) has parameter \$attendees with no value type specified in iterable type array\.$#' + identifier: missingType.iterableValue + count: 1 + path: examples/08-schema-showcase-streamable/SchemaShowcaseElements.php + + - + message: '#^Method Mcp\\Example\\SchemaShowcaseExample\\SchemaShowcaseElements\:\:scheduleEvent\(\) return type has no value type specified in iterable type array\.$#' + identifier: missingType.iterableValue + count: 1 + path: examples/08-schema-showcase-streamable/SchemaShowcaseElements.php + + - + message: '#^Method Mcp\\Example\\SchemaShowcaseExample\\SchemaShowcaseElements\:\:validateProfile\(\) has parameter \$profile with no value type specified in iterable type array\.$#' + identifier: missingType.iterableValue + count: 1 + path: examples/08-schema-showcase-streamable/SchemaShowcaseElements.php + + - + message: '#^Method Mcp\\Example\\SchemaShowcaseExample\\SchemaShowcaseElements\:\:validateProfile\(\) return type has no value type specified in iterable type array\.$#' + identifier: missingType.iterableValue + count: 1 + path: examples/08-schema-showcase-streamable/SchemaShowcaseElements.php + + - + message: '#^Instantiated class Mcp\\Server\\Transports\\StreamableHttpServerTransport not found\.$#' + identifier: class.notFound + count: 1 + path: examples/08-schema-showcase-streamable/server.php + + - + message: '#^Parameter \#1 \$transport of method Mcp\\Server\:\:connect\(\) expects Mcp\\Server\\TransportInterface, Mcp\\Server\\Transports\\StreamableHttpServerTransport given\.$#' + identifier: argument.type + count: 1 + path: examples/08-schema-showcase-streamable/server.php + + - + message: '#^Parameter \#1 \$registry of class Mcp\\Server\\RequestHandler\\CallToolHandler constructor expects Mcp\\Capability\\Registry, Mcp\\Capability\\ToolChain given\.$#' + identifier: argument.type + count: 1 + path: examples/09-standalone-cli/src/Builder.php + + - + message: '#^Parameter \#1 \$registry of class Mcp\\Server\\RequestHandler\\GetPromptHandler constructor expects Mcp\\Capability\\Registry, Mcp\\Capability\\PromptChain given\.$#' + identifier: argument.type + count: 1 + path: examples/09-standalone-cli/src/Builder.php + + - + message: '#^Parameter \#1 \$registry of class Mcp\\Server\\RequestHandler\\ListPromptsHandler constructor expects Mcp\\Capability\\Registry, Mcp\\Capability\\PromptChain given\.$#' + identifier: argument.type + count: 1 + path: examples/09-standalone-cli/src/Builder.php + + - + message: '#^Parameter \#1 \$registry of class Mcp\\Server\\RequestHandler\\ListResourcesHandler constructor expects Mcp\\Capability\\Registry, Mcp\\Capability\\ResourceChain given\.$#' + identifier: argument.type + count: 1 + path: examples/09-standalone-cli/src/Builder.php + + - + message: '#^Parameter \#1 \$registry of class Mcp\\Server\\RequestHandler\\ListToolsHandler constructor expects Mcp\\Capability\\Registry, Mcp\\Capability\\ToolChain given\.$#' + identifier: argument.type + count: 1 + path: examples/09-standalone-cli/src/Builder.php + + - + message: '#^Parameter \#1 \$registry of class Mcp\\Server\\RequestHandler\\ReadResourceHandler constructor expects Mcp\\Capability\\Registry, Mcp\\Capability\\ResourceChain given\.$#' + identifier: argument.type + count: 1 + path: examples/09-standalone-cli/src/Builder.php + + - + message: '#^Call to protected method formatResult\(\) of class Mcp\\Capability\\Registry\\ResourceReference\.$#' + identifier: method.protected + count: 1 + path: src/Capability/Registry.php + + - + message: '#^Cannot import type alias CallableArray\: type alias does not exist in Mcp\\Capability\\Registry\\ElementReference\.$#' + identifier: typeAlias.notFound + count: 1 + path: src/Capability/Registry.php + + - + message: '#^Method Mcp\\Capability\\Registry\:\:handleCallTool\(\) has parameter \$arguments with no value type specified in iterable type array\.$#' + identifier: missingType.iterableValue + count: 1 + path: src/Capability/Registry.php + + - + message: '#^Method Mcp\\Capability\\Registry\:\:handleCallTool\(\) return type has no value type specified in iterable type array\.$#' + identifier: missingType.iterableValue + count: 1 + path: src/Capability/Registry.php + + - + message: '#^Method Mcp\\Capability\\Registry\:\:handleGetPrompt\(\) has parameter \$arguments with no value type specified in iterable type array\.$#' + identifier: missingType.iterableValue + count: 1 + path: src/Capability/Registry.php + + - + message: '#^Method Mcp\\Capability\\Registry\:\:registerPrompt\(\) has parameter \$handler with no value type specified in iterable type array\.$#' + identifier: missingType.iterableValue + count: 1 + path: src/Capability/Registry.php + + - + message: '#^Method Mcp\\Capability\\Registry\:\:registerResource\(\) has parameter \$handler with no value type specified in iterable type array\.$#' + identifier: missingType.iterableValue + count: 1 + path: src/Capability/Registry.php + + - + message: '#^Method Mcp\\Capability\\Registry\:\:registerResourceTemplate\(\) has parameter \$handler with no value type specified in iterable type array\.$#' + identifier: missingType.iterableValue + count: 1 + path: src/Capability/Registry.php + + - + message: '#^Method Mcp\\Capability\\Registry\:\:registerTool\(\) has parameter \$handler with no value type specified in iterable type array\.$#' + identifier: missingType.iterableValue + count: 1 + path: src/Capability/Registry.php + + - + message: '#^PHPDoc tag @param for parameter \$handler with type \(callable\)\|Mcp\\Capability\\CallableArray\|string is not subtype of native type array\|\(callable\)\|string\.$#' + identifier: parameter.phpDocType + count: 4 + path: src/Capability/Registry.php + + - + message: '#^Parameter \$handler of method Mcp\\Capability\\Registry\:\:registerPrompt\(\) has invalid type Mcp\\Capability\\CallableArray\.$#' + identifier: class.notFound + count: 1 + path: src/Capability/Registry.php + + - + message: '#^Parameter \$handler of method Mcp\\Capability\\Registry\:\:registerResource\(\) has invalid type Mcp\\Capability\\CallableArray\.$#' + identifier: class.notFound + count: 1 + path: src/Capability/Registry.php + + - + message: '#^Parameter \$handler of method Mcp\\Capability\\Registry\:\:registerResourceTemplate\(\) has invalid type Mcp\\Capability\\CallableArray\.$#' + identifier: class.notFound + count: 1 + path: src/Capability/Registry.php + + - + message: '#^Parameter \$handler of method Mcp\\Capability\\Registry\:\:registerTool\(\) has invalid type Mcp\\Capability\\CallableArray\.$#' + identifier: class.notFound + count: 1 + path: src/Capability/Registry.php + + - + message: '#^Call to an undefined method Mcp\\Capability\\Registry\\ResourceTemplateReference\:\:handle\(\)\.$#' + identifier: method.notFound + count: 1 + path: src/Capability/Registry/ResourceTemplateReference.php + + - + message: '#^PHPDoc tag @return with type array is incompatible with native type object\.$#' + identifier: return.phpDocType + count: 1 + path: src/Schema/Result/EmptyResult.php + + - + message: '#^Method Mcp\\Schema\\Result\\ReadResourceResult\:\:jsonSerialize\(\) should return array\{contents\: array\\} but returns array\{contents\: array\\}\.$#' + identifier: return.type + count: 1 + path: src/Schema/Result/ReadResourceResult.php + + - + message: '#^Method Mcp\\Capability\\Registry\:\:getPrompts\(\) invoked with 2 parameters, 0 required\.$#' + identifier: arguments.count + count: 1 + path: src/Server/RequestHandler/ListPromptsHandler.php + + - + message: '#^Result of && is always false\.$#' + identifier: booleanAnd.alwaysFalse + count: 1 + path: src/Server/RequestHandler/ListPromptsHandler.php + + - + message: '#^Strict comparison using \!\=\= between null and null will always evaluate to false\.$#' + identifier: notIdentical.alwaysFalse + count: 1 + path: src/Server/RequestHandler/ListPromptsHandler.php + + - + message: '#^Method Mcp\\Capability\\Registry\:\:getResources\(\) invoked with 2 parameters, 0 required\.$#' + identifier: arguments.count + count: 1 + path: src/Server/RequestHandler/ListResourcesHandler.php + + - + message: '#^Result of && is always false\.$#' + identifier: booleanAnd.alwaysFalse + count: 1 + path: src/Server/RequestHandler/ListResourcesHandler.php + + - + message: '#^Strict comparison using \!\=\= between null and null will always evaluate to false\.$#' + identifier: notIdentical.alwaysFalse + count: 1 + path: src/Server/RequestHandler/ListResourcesHandler.php + + - + message: '#^Method Mcp\\Capability\\Registry\:\:getTools\(\) invoked with 2 parameters, 0 required\.$#' + identifier: arguments.count + count: 1 + path: src/Server/RequestHandler/ListToolsHandler.php + + - + message: '#^Result of && is always false\.$#' + identifier: booleanAnd.alwaysFalse + count: 1 + path: src/Server/RequestHandler/ListToolsHandler.php + + - + message: '#^Strict comparison using \!\=\= between null and null will always evaluate to false\.$#' + identifier: notIdentical.alwaysFalse + count: 1 + path: src/Server/RequestHandler/ListToolsHandler.php + + - + message: '#^Instantiated class Mcp\\Server\\ConfigurationException not found\.$#' + identifier: class.notFound + count: 4 + path: src/Server/ServerBuilder.php + + - + message: '#^Method Mcp\\Server\\ServerBuilder\:\:getCompletionProviders\(\) return type has no value type specified in iterable type array\.$#' + identifier: missingType.iterableValue + count: 1 + path: src/Server/ServerBuilder.php + + - + message: '#^Method Mcp\\Server\\ServerBuilder\:\:registerManualElements\(\) is unused\.$#' + identifier: method.unused + count: 1 + path: src/Server/ServerBuilder.php + + - + message: '#^Method Mcp\\Server\\ServerBuilder\:\:withDiscovery\(\) has parameter \$excludeDirs with no value type specified in iterable type array\.$#' + identifier: missingType.iterableValue + count: 1 + path: src/Server/ServerBuilder.php + + - + message: '#^Method Mcp\\Server\\ServerBuilder\:\:withDiscovery\(\) has parameter \$scanDirs with no value type specified in iterable type array\.$#' + identifier: missingType.iterableValue + count: 1 + path: src/Server/ServerBuilder.php + + - + message: '#^Method Mcp\\Server\\ServerBuilder\:\:withPrompt\(\) has parameter \$handler with no value type specified in iterable type array\.$#' + identifier: missingType.iterableValue + count: 1 + path: src/Server/ServerBuilder.php + + - + message: '#^Method Mcp\\Server\\ServerBuilder\:\:withResource\(\) has parameter \$handler with no value type specified in iterable type array\.$#' + identifier: missingType.iterableValue + count: 1 + path: src/Server/ServerBuilder.php + + - + message: '#^Method Mcp\\Server\\ServerBuilder\:\:withResourceTemplate\(\) has parameter \$handler with no value type specified in iterable type array\.$#' + identifier: missingType.iterableValue + count: 1 + path: src/Server/ServerBuilder.php + + - + message: '#^Method Mcp\\Server\\ServerBuilder\:\:withTool\(\) has parameter \$handler with no value type specified in iterable type array\.$#' + identifier: missingType.iterableValue + count: 1 + path: src/Server/ServerBuilder.php + + - + message: '#^Method Mcp\\Server\\ServerBuilder\:\:withTool\(\) has parameter \$inputSchema with no value type specified in iterable type array\.$#' + identifier: missingType.iterableValue + count: 1 + path: src/Server/ServerBuilder.php + + - + message: '#^Property Mcp\\Server\\ServerBuilder\:\:\$cache \(Psr\\SimpleCache\\CacheInterface\|null\) is never assigned Psr\\SimpleCache\\CacheInterface so it can be removed from the property type\.$#' + identifier: property.unusedType + count: 1 + path: src/Server/ServerBuilder.php + + - + message: '#^Property Mcp\\Server\\ServerBuilder\:\:\$cache has unknown class Psr\\SimpleCache\\CacheInterface as its type\.$#' + identifier: class.notFound + count: 1 + path: src/Server/ServerBuilder.php + + - + message: '#^Property Mcp\\Server\\ServerBuilder\:\:\$cache is never read, only written\.$#' + identifier: property.onlyWritten + count: 1 + path: src/Server/ServerBuilder.php + + - + message: '#^Property Mcp\\Server\\ServerBuilder\:\:\$discoveryExcludeDirs type has no value type specified in iterable type array\.$#' + identifier: missingType.iterableValue + count: 1 + path: src/Server/ServerBuilder.php + + - + message: '#^Property Mcp\\Server\\ServerBuilder\:\:\$instructions is never read, only written\.$#' + identifier: property.onlyWritten + count: 1 + path: src/Server/ServerBuilder.php + + - + message: '#^Property Mcp\\Server\\ServerBuilder\:\:\$manualPrompts has unknown class Mcp\\Server\\Closure as its type\.$#' + identifier: class.notFound + count: 1 + path: src/Server/ServerBuilder.php + + - + message: '#^Property Mcp\\Server\\ServerBuilder\:\:\$manualPrompts type has no value type specified in iterable type array\.$#' + identifier: missingType.iterableValue + count: 1 + path: src/Server/ServerBuilder.php + + - + message: '#^Property Mcp\\Server\\ServerBuilder\:\:\$manualResourceTemplates has unknown class Mcp\\Server\\Closure as its type\.$#' + identifier: class.notFound + count: 1 + path: src/Server/ServerBuilder.php + + - + message: '#^Property Mcp\\Server\\ServerBuilder\:\:\$manualResourceTemplates type has no value type specified in iterable type array\.$#' + identifier: missingType.iterableValue + count: 1 + path: src/Server/ServerBuilder.php + + - + message: '#^Property Mcp\\Server\\ServerBuilder\:\:\$manualResources has unknown class Mcp\\Server\\Closure as its type\.$#' + identifier: class.notFound + count: 1 + path: src/Server/ServerBuilder.php + + - + message: '#^Property Mcp\\Server\\ServerBuilder\:\:\$manualResources type has no value type specified in iterable type array\.$#' + identifier: missingType.iterableValue + count: 1 + path: src/Server/ServerBuilder.php + + - + message: '#^Property Mcp\\Server\\ServerBuilder\:\:\$manualTools has unknown class Mcp\\Server\\Closure as its type\.$#' + identifier: class.notFound + count: 1 + path: src/Server/ServerBuilder.php + + - + message: '#^Property Mcp\\Server\\ServerBuilder\:\:\$manualTools type has no value type specified in iterable type array\.$#' + identifier: missingType.iterableValue + count: 1 + path: src/Server/ServerBuilder.php + + - + message: '#^Property Mcp\\Server\\ServerBuilder\:\:\$paginationLimit \(int\|null\) is never assigned null so it can be removed from the property type\.$#' + identifier: property.unusedType + count: 1 + path: src/Server/ServerBuilder.php + + - + message: '#^Property Mcp\\Server\\ServerBuilder\:\:\$paginationLimit is never read, only written\.$#' + identifier: property.onlyWritten + count: 1 + path: src/Server/ServerBuilder.php + + - + message: '#^Throwing object of an unknown class Mcp\\Server\\ConfigurationException\.$#' + identifier: class.notFound + count: 4 + path: src/Server/ServerBuilder.php diff --git a/phpstan.dist.neon b/phpstan.dist.neon index 41320080..62e43112 100644 --- a/phpstan.dist.neon +++ b/phpstan.dist.neon @@ -1,3 +1,6 @@ +includes: + - phpstan-baseline.neon + parameters: level: 6 paths: diff --git a/src/Capability/Registry.php b/src/Capability/Registry.php index 003e9c44..f0db6549 100644 --- a/src/Capability/Registry.php +++ b/src/Capability/Registry.php @@ -11,56 +11,81 @@ namespace Mcp\Capability; -use Mcp\Capability\Registry\RegisteredElement; -use Mcp\Capability\Registry\RegisteredPrompt; -use Mcp\Capability\Registry\RegisteredResource; -use Mcp\Capability\Registry\RegisteredResourceTemplate; -use Mcp\Capability\Registry\RegisteredTool; +use Mcp\Capability\Registry\ElementReference; +use Mcp\Capability\Registry\PromptReference; +use Mcp\Capability\Registry\ReferenceHandler; +use Mcp\Capability\Registry\ResourceReference; +use Mcp\Capability\Registry\ResourceTemplateReference; +use Mcp\Capability\Registry\ToolReference; use Mcp\Event\PromptListChangedEvent; use Mcp\Event\ResourceListChangedEvent; use Mcp\Event\ResourceTemplateListChangedEvent; use Mcp\Event\ToolListChangedEvent; +use Mcp\Exception\InvalidArgumentException; +use Mcp\Schema\Content\PromptMessage; +use Mcp\Schema\Content\ResourceContents; use Mcp\Schema\Prompt; use Mcp\Schema\Resource; use Mcp\Schema\ResourceTemplate; +use Mcp\Schema\ServerCapabilities; use Mcp\Schema\Tool; use Psr\EventDispatcher\EventDispatcherInterface; use Psr\Log\LoggerInterface; use Psr\Log\NullLogger; /** - * @phpstan-import-type CallableArray from RegisteredElement + * @phpstan-import-type CallableArray from ElementReference * * @author Kyrian Obikwelu */ class Registry { /** - * @var array + * @var array */ private array $tools = []; /** - * @var array + * @var array */ private array $resources = []; /** - * @var array + * @var array */ private array $prompts = []; /** - * @var array + * @var array */ private array $resourceTemplates = []; public function __construct( + private readonly ReferenceHandler $referenceHandler = new ReferenceHandler(), private readonly ?EventDispatcherInterface $eventDispatcher = null, private readonly LoggerInterface $logger = new NullLogger(), ) { } + public function getCapabilities(): ServerCapabilities + { + if (!$this->hasElements()) { + $this->logger->info('No capabilities registered on server.'); + } + + return new ServerCapabilities( + tools: true, // [] !== $this->tools, + toolsListChanged: $this->eventDispatcher instanceof EventDispatcherInterface, + resources: [] !== $this->resources || [] !== $this->resourceTemplates, + resourcesSubscribe: false, + resourcesListChanged: $this->eventDispatcher instanceof EventDispatcherInterface, + prompts: [] !== $this->prompts, + promptsListChanged: $this->eventDispatcher instanceof EventDispatcherInterface, + logging: false, // true, + completions: true, + ); + } + /** * @param callable|CallableArray|string $handler */ @@ -75,7 +100,7 @@ public function registerTool(Tool $tool, callable|array|string $handler, bool $i return; } - $this->tools[$toolName] = new RegisteredTool($tool, $handler, $isManual); + $this->tools[$toolName] = new ToolReference($tool, $handler, $isManual); $this->eventDispatcher?->dispatch(new ToolListChangedEvent()); } @@ -94,7 +119,7 @@ public function registerResource(Resource $resource, callable|array|string $hand return; } - $this->resources[$uri] = new RegisteredResource($resource, $handler, $isManual); + $this->resources[$uri] = new ResourceReference($resource, $handler, $isManual); $this->eventDispatcher?->dispatch(new ResourceListChangedEvent()); } @@ -118,7 +143,7 @@ public function registerResourceTemplate( return; } - $this->resourceTemplates[$uriTemplate] = new RegisteredResourceTemplate($template, $handler, $isManual, $completionProviders); + $this->resourceTemplates[$uriTemplate] = new ResourceTemplateReference($template, $handler, $isManual, $completionProviders); $this->eventDispatcher?->dispatch(new ResourceTemplateListChangedEvent()); } @@ -142,12 +167,14 @@ public function registerPrompt( return; } - $this->prompts[$promptName] = new RegisteredPrompt($prompt, $handler, $isManual, $completionProviders); + $this->prompts[$promptName] = new PromptReference($prompt, $handler, $isManual, $completionProviders); $this->eventDispatcher?->dispatch(new PromptListChangedEvent()); } - /** Checks if any elements (manual or discovered) are currently registered. */ + /** + * Checks if any elements (manual or discovered) are currently registered. + */ public function hasElements(): bool { return !empty($this->tools) @@ -193,12 +220,42 @@ public function clear(): void } } - public function getTool(string $name): ?RegisteredTool + public function handleCallTool(string $name, array $arguments): array + { + $reference = $this->getTool($name); + + if (null === $reference) { + throw new InvalidArgumentException(\sprintf('Tool "%s" is not registered.', $name)); + } + + return $reference->formatResult( + $this->referenceHandler->handle($reference, $arguments) + ); + } + + public function getTool(string $name): ?ToolReference { return $this->tools[$name] ?? null; } - public function getResource(string $uri, bool $includeTemplates = true): RegisteredResource|RegisteredResourceTemplate|null + /** + * @return ResourceContents[] + */ + public function handleReadResource(string $uri): array + { + $reference = $this->getResource($uri); + + if (null === $reference) { + throw new InvalidArgumentException(\sprintf('Resource "%s" is not registered.', $uri)); + } + + return $reference->formatResult( + $this->referenceHandler->handle($reference, ['uri' => $uri]), + $uri, + ); + } + + public function getResource(string $uri, bool $includeTemplates = true): ResourceReference|ResourceTemplateReference|null { $registration = $this->resources[$uri] ?? null; if ($registration) { @@ -220,32 +277,54 @@ public function getResource(string $uri, bool $includeTemplates = true): Registe return null; } - public function getResourceTemplate(string $uriTemplate): ?RegisteredResourceTemplate + public function getResourceTemplate(string $uriTemplate): ?ResourceTemplateReference { return $this->resourceTemplates[$uriTemplate] ?? null; } - public function getPrompt(string $name): ?RegisteredPrompt + /** + * @return PromptMessage[] + */ + public function handleGetPrompt(string $name, ?array $arguments): array + { + $reference = $this->getPrompt($name); + + if (null === $reference) { + throw new InvalidArgumentException(\sprintf('Prompt "%s" is not registered.', $name)); + } + + return $reference->formatResult( + $this->referenceHandler->handle($reference, $arguments) + ); + } + + public function getPrompt(string $name): ?PromptReference { return $this->prompts[$name] ?? null; } - /** @return array */ + /** + * @return array + */ public function getTools(): array { - return array_map(fn ($tool) => $tool->tool, $this->tools); + return array_map(fn (ToolReference $tool) => $tool->tool, $this->tools); } - /** @return array */ + /** + * @return array + */ public function getResources(): array { - return array_map(fn ($resource) => $resource->schema, $this->resources); + return array_map(fn (ResourceReference $resource) => $resource->schema, $this->resources); } - /** @return array */ + /** + * @return array + */ public function getPrompts(): array { - return array_map(fn ($prompt) => $prompt->prompt, $this->prompts); + return array_map(fn (PromptReference $prompt) => $prompt->prompt, $this->prompts); } /** @return array */ diff --git a/src/Capability/Registry/Container.php b/src/Capability/Registry/Container.php new file mode 100644 index 00000000..92c4a908 --- /dev/null +++ b/src/Capability/Registry/Container.php @@ -0,0 +1,188 @@ + + */ +final class Container implements ContainerInterface +{ + /** + * @var array Cache for already created instances (shared singletons) + */ + private array $instances = []; + + /** + * @var array Track classes currently being resolved to detect circular dependencies + */ + private array $resolving = []; + + /** + * Finds an entry of the container by its identifier and returns it. + * + * @param string $id identifier of the entry to look for (usually a FQCN) + * + * @return mixed entry + * + * @throws NotFoundExceptionInterface no entry was found for **this** identifier + * @throws ContainerExceptionInterface Error while retrieving the entry (e.g., dependency resolution failure, circular dependency). + */ + public function get(string $id): mixed + { + // 1. Check instance cache + if (isset($this->instances[$id])) { + return $this->instances[$id]; + } + + // 2. Check if class exists + if (!class_exists($id) && !interface_exists($id)) { // Also check interface for bindings + throw new ServiceNotFoundException(\sprintf('Class, interface, or entry "%s" not found.', $id)); + } + + // 7. Circular Dependency Check + if (isset($this->resolving[$id])) { + throw new ContainerException("Circular dependency detected while resolving '{$id}'. Resolution path: ".implode(' -> ', array_keys($this->resolving))." -> {$id}"); + } + + $this->resolving[$id] = true; // Mark as currently resolving + + try { + // 3. Reflect on the class + $reflector = new \ReflectionClass($id); + + // Check if class is instantiable (abstract classes, interfaces cannot be directly instantiated) + if (!$reflector->isInstantiable()) { + // We might have an interface bound to a concrete class via set() + // This check is slightly redundant due to class_exists but good practice + throw new ContainerException("Class '{$id}' is not instantiable (e.g., abstract class or interface without explicit binding)."); + } + + // 4. Get the constructor + $constructor = $reflector->getConstructor(); + + // 5. If no constructor or constructor has no parameters, instantiate directly + if (null === $constructor || 0 === $constructor->getNumberOfParameters()) { + $instance = $reflector->newInstance(); + } else { + // 6. Constructor has parameters, attempt to resolve them + $parameters = $constructor->getParameters(); + $resolvedArgs = []; + + foreach ($parameters as $parameter) { + $resolvedArgs[] = $this->resolveParameter($parameter, $id); + } + + // Instantiate with resolved arguments + $instance = $reflector->newInstanceArgs($resolvedArgs); + } + + // Cache the instance + $this->instances[$id] = $instance; + + return $instance; + } catch (\ReflectionException $e) { + throw new ContainerException(\sprintf('Reflection failed for %s.', $id), 0, $e); + } catch (ContainerExceptionInterface $e) { // Re-throw container exceptions directly + throw $e; + } catch (\Throwable $e) { // Catch other instantiation errors + throw new ContainerException("Failed to instantiate or resolve dependencies for '{$id}': ".$e->getMessage(), (int) $e->getCode(), $e); + } finally { + // 7. Remove from resolving stack once done (success or failure) + unset($this->resolving[$id]); + } + } + + /** + * Attempts to resolve a single constructor parameter. + * + * @throws ContainerExceptionInterface if a required dependency cannot be resolved + */ + private function resolveParameter(\ReflectionParameter $parameter, string $consumerClassId): mixed + { + // Check for type hint + $type = $parameter->getType(); + + if ($type instanceof \ReflectionNamedType && !$type->isBuiltin()) { + // Type hint is a class or interface name + $typeName = $type->getName(); + try { + // Recursively get the dependency + return $this->get($typeName); + } catch (NotFoundExceptionInterface $e) { + // Dependency class not found, fail ONLY if required + if (!$parameter->isOptional() && !$parameter->allowsNull()) { + throw new ContainerException("Unresolvable dependency '{$typeName}' required by '{$consumerClassId}' constructor parameter \${$parameter->getName()}.", 0, $e); + } + // If optional or nullable, proceed (will check allowsNull/Default below) + } catch (ContainerExceptionInterface $e) { + // Dependency itself failed to resolve (e.g., its own deps, circular) + throw new ContainerException("Failed to resolve dependency '{$typeName}' for '{$consumerClassId}' parameter \${$parameter->getName()}: ".$e->getMessage(), 0, $e); + } + } + + // Check if parameter has a default value + if ($parameter->isDefaultValueAvailable()) { + return $parameter->getDefaultValue(); + } + + // Check if parameter allows null (and wasn't resolved above) + if ($parameter->allowsNull()) { + return null; + } + + // Check if it was a built-in type without a default (unresolvable by this basic container) + if ($type instanceof \ReflectionNamedType && $type->isBuiltin()) { + throw new ContainerException("Cannot auto-wire built-in type '{$type->getName()}' for required parameter \${$parameter->getName()} in '{$consumerClassId}' constructor. Provide a default value or use a more advanced container."); + } + + // Check if it was a union/intersection type without a default (also unresolvable) + if (null !== $type && !$type instanceof \ReflectionNamedType) { + throw new ContainerException("Cannot auto-wire complex type (union/intersection) for required parameter \${$parameter->getName()} in '{$consumerClassId}' constructor. Provide a default value or use a more advanced container."); + } + + // If we reach here, it's an untyped, required parameter without a default. + // Or potentially an unresolvable optional class dependency where null is not allowed (edge case). + throw new ContainerException("Cannot resolve required parameter \${$parameter->getName()} for '{$consumerClassId}' constructor (untyped or unresolvable complex type)."); + } + + /** + * Returns true if the container can return an entry for the given identifier. + * Checks explicitly set instances and if the class/interface exists. + * Does not guarantee `get()` will succeed if auto-wiring fails. + */ + public function has(string $id): bool + { + return isset($this->instances[$id]) || class_exists($id) || interface_exists($id); + } + + /** + * Adds a pre-built instance or a factory/binding to the container. + * This basic version only supports pre-built instances (singletons). + */ + public function set(string $id, object $instance): void + { + // Could add support for closures/factories later if needed + $this->instances[$id] = $instance; + } +} diff --git a/src/Capability/Registry/ElementReference.php b/src/Capability/Registry/ElementReference.php new file mode 100644 index 00000000..6425ba13 --- /dev/null +++ b/src/Capability/Registry/ElementReference.php @@ -0,0 +1,29 @@ + + */ +class ElementReference +{ + /** + * @param Handler $handler + */ + public function __construct( + public readonly \Closure|array|string $handler, + public readonly bool $isManual = false, + ) { + } +} diff --git a/src/Capability/Registry/RegisteredPrompt.php b/src/Capability/Registry/PromptReference.php similarity index 81% rename from src/Capability/Registry/RegisteredPrompt.php rename to src/Capability/Registry/PromptReference.php index 0d2213f9..50b2dd55 100644 --- a/src/Capability/Registry/RegisteredPrompt.php +++ b/src/Capability/Registry/PromptReference.php @@ -26,65 +26,25 @@ use Psr\Container\ContainerInterface; /** - * @phpstan-import-type CallableArray from RegisteredElement + * @phpstan-import-type Handler from ElementReference * * @author Kyrian Obikwelu */ -class RegisteredPrompt extends RegisteredElement +class PromptReference extends ElementReference { /** - * @param callable|CallableArray|string $handler + * @param Handler $handler * @param array $completionProviders */ public function __construct( public readonly Prompt $prompt, - callable|array|string $handler, + \Closure|array|string $handler, bool $isManual = false, public readonly array $completionProviders = [], ) { parent::__construct($handler, $isManual); } - /** - * @param array $data - */ - public static function fromArray(array $data): self|false - { - try { - if (!isset($data['schema']) || !isset($data['handler'])) { - return false; - } - - $completionProviders = []; - foreach ($data['completionProviders'] ?? [] as $argument => $provider) { - $completionProviders[$argument] = unserialize($provider); - } - - return new self( - Prompt::fromArray($data['schema']), - $data['handler'], - $data['isManual'] ?? false, - $completionProviders, - ); - } catch (\Throwable) { - return false; - } - } - - /** - * Gets the prompt messages. - * - * @param array $arguments - * - * @return PromptMessage[] - */ - public function get(ContainerInterface $container, array $arguments): array - { - $result = $this->handle($container, $arguments); - - return $this->formatResult($result); - } - public function complete(ContainerInterface $container, string $argument, string $value): CompletionCompleteResult { $providerClassOrInstance = $this->completionProviders[$argument] ?? null; @@ -122,14 +82,14 @@ public function complete(ContainerInterface $container, string $argument, string * @throws \RuntimeException if the result cannot be formatted * @throws \JsonException if JSON encoding fails */ - protected function formatResult(mixed $promptGenerationResult): array + public function formatResult(mixed $promptGenerationResult): array { if ($promptGenerationResult instanceof PromptMessage) { return [$promptGenerationResult]; } if (!\is_array($promptGenerationResult)) { - throw new \RuntimeException('Prompt generator method must return an array of messages.'); + throw new RuntimeException('Prompt generator method must return an array of messages.'); } if (empty($promptGenerationResult)) { @@ -199,7 +159,7 @@ protected function formatResult(mixed $promptGenerationResult): array return $formattedMessages; } - throw new \RuntimeException('Invalid prompt generation result format.'); + throw new RuntimeException('Invalid prompt generation result format.'); } /** @@ -210,12 +170,12 @@ private function formatMessage(mixed $message, ?int $index = null): PromptMessag $indexStr = null !== $index ? " at index {$index}" : ''; if (!\is_array($message) || !\array_key_exists('role', $message) || !\array_key_exists('content', $message)) { - throw new \RuntimeException("Invalid message format{$indexStr}. Expected an array with 'role' and 'content' keys."); + throw new RuntimeException("Invalid message format{$indexStr}. Expected an array with 'role' and 'content' keys."); } $role = $message['role'] instanceof Role ? $message['role'] : Role::tryFrom($message['role']); if (null === $role) { - throw new \RuntimeException("Invalid role '{$message['role']}' in prompt message{$indexStr}. Only 'user' or 'assistant' are supported."); + throw new RuntimeException("Invalid role '{$message['role']}' in prompt message{$indexStr}. Only 'user' or 'assistant' are supported."); } $content = $this->formatContent($message['content'], $index); @@ -237,7 +197,7 @@ private function formatContent(mixed $content, ?int $index = null): TextContent| ) { return $content; } - throw new \RuntimeException("Invalid Content type{$indexStr}. PromptMessage only supports TextContent, ImageContent, AudioContent, or EmbeddedResource."); + throw new RuntimeException("Invalid Content type{$indexStr}. PromptMessage only supports TextContent, ImageContent, AudioContent, or EmbeddedResource."); } if (\is_string($content)) { @@ -274,7 +234,7 @@ private function formatTypedContent(array $content, ?int $index = null): TextCon 'image' => $this->formatImageContent($content, $indexStr), 'audio' => $this->formatAudioContent($content, $indexStr), 'resource' => $this->formatResourceContent($content, $indexStr), - default => throw new \RuntimeException("Invalid content type '{$type}'{$indexStr}."), + default => throw new RuntimeException("Invalid content type '{$type}'{$indexStr}."), }; } @@ -348,21 +308,4 @@ private function formatResourceContent(array $content, string $indexStr): Embedd return new EmbeddedResource($resourceObj); } - - /** - * @return array - */ - public function jsonSerialize(): array - { - $completionProviders = []; - foreach ($this->completionProviders as $argument => $provider) { - $completionProviders[$argument] = serialize($provider); - } - - return [ - 'schema' => $this->prompt, - 'completionProviders' => $completionProviders, - ...parent::jsonSerialize(), - ]; - } } diff --git a/src/Capability/Registry/RegisteredElement.php b/src/Capability/Registry/ReferenceHandler.php similarity index 82% rename from src/Capability/Registry/RegisteredElement.php rename to src/Capability/Registry/ReferenceHandler.php index 1195c91a..d4af3169 100644 --- a/src/Capability/Registry/RegisteredElement.php +++ b/src/Capability/Registry/ReferenceHandler.php @@ -16,77 +16,71 @@ use Psr\Container\ContainerInterface; /** - * @phpstan-type CallableArray array{0: object|string, 1: string} - * * @author Kyrian Obikwelu */ -class RegisteredElement implements \JsonSerializable +class ReferenceHandler { - /** - * @var callable|CallableArray|string - */ - public readonly mixed $handler; - public readonly bool $isManual; - - /** - * @param callable|CallableArray|string $handler - */ public function __construct( - callable|array|string $handler, - bool $isManual = false, + private readonly ?ContainerInterface $container = null, ) { - $this->handler = $handler; - $this->isManual = $isManual; } /** * @param array $arguments */ - public function handle(ContainerInterface $container, array $arguments): mixed + public function handle(ElementReference $reference, array $arguments): mixed { - if (\is_string($this->handler)) { - if (class_exists($this->handler) && method_exists($this->handler, '__invoke')) { - $reflection = new \ReflectionMethod($this->handler, '__invoke'); + if (\is_string($reference->handler)) { + if (class_exists($reference->handler) && method_exists($reference->handler, '__invoke')) { + $reflection = new \ReflectionMethod($reference->handler, '__invoke'); + $instance = $this->getClassInstance($reference->handler); $arguments = $this->prepareArguments($reflection, $arguments); - $instance = $container->get($this->handler); return \call_user_func($instance, ...$arguments); } - if (\function_exists($this->handler)) { - $reflection = new \ReflectionFunction($this->handler); + if (\function_exists($reference->handler)) { + $reflection = new \ReflectionFunction($reference->handler); $arguments = $this->prepareArguments($reflection, $arguments); - return \call_user_func($this->handler, ...$arguments); + return \call_user_func($reference->handler, ...$arguments); } } - if (\is_callable($this->handler)) { - $reflection = $this->getReflectionForCallable($this->handler); + if (\is_callable($reference->handler)) { + $reflection = $this->getReflectionForCallable($reference->handler); $arguments = $this->prepareArguments($reflection, $arguments); - return \call_user_func($this->handler, ...$arguments); + return \call_user_func($reference->handler, ...$arguments); } - if (\is_array($this->handler)) { - [$className, $methodName] = $this->handler; + if (\is_array($reference->handler)) { + [$className, $methodName] = $reference->handler; $reflection = new \ReflectionMethod($className, $methodName); + $instance = $this->getClassInstance($className); $arguments = $this->prepareArguments($reflection, $arguments); - $instance = $container->get($className); - return \call_user_func([$instance, $methodName], ...$arguments); } throw new InvalidArgumentException('Invalid handler type'); } + private function getClassInstance(string $className): object + { + if (null !== $this->container && $this->container->has($className)) { + return $this->container->get($className); + } + + return new $className(); + } + /** * @param array $arguments * * @return array */ - protected function prepareArguments(\ReflectionFunctionAbstract $reflection, array $arguments): array + private function prepareArguments(\ReflectionFunctionAbstract $reflection, array $arguments): array { $finalArgs = []; @@ -272,18 +266,4 @@ private function castToArray(mixed $argument): array throw new InvalidArgumentException('Cannot cast value to array. Expected array.'); } - - /** - * @return array{ - * handler: callable|CallableArray|string, - * isManual: bool, - * } - */ - public function jsonSerialize(): array - { - return [ - 'handler' => $this->handler, - 'isManual' => $this->isManual, - ]; - } } diff --git a/src/Capability/Registry/RegisteredResource.php b/src/Capability/Registry/ResourceReference.php similarity index 85% rename from src/Capability/Registry/RegisteredResource.php rename to src/Capability/Registry/ResourceReference.php index e6175f63..c93d5a14 100644 --- a/src/Capability/Registry/RegisteredResource.php +++ b/src/Capability/Registry/ResourceReference.php @@ -17,17 +17,16 @@ use Mcp\Schema\Content\ResourceContents; use Mcp\Schema\Content\TextResourceContents; use Mcp\Schema\Resource; -use Psr\Container\ContainerInterface; /** - * @phpstan-import-type CallableArray from RegisteredElement + * @phpstan-import-type Handler from ElementReference * * @author Kyrian Obikwelu */ -class RegisteredResource extends RegisteredElement +class ResourceReference extends ElementReference { /** - * @param callable|CallableArray|string $handler + * @param Handler $handler */ public function __construct( public readonly Resource $schema, @@ -37,38 +36,6 @@ public function __construct( parent::__construct($handler, $isManual); } - /** - * @param array $data - */ - public static function fromArray(array $data): self|false - { - try { - if (!isset($data['schema']) || !isset($data['handler'])) { - return false; - } - - return new self( - Resource::fromArray($data['schema']), - $data['handler'], - $data['isManual'] ?? false, - ); - } catch (\Throwable) { - return false; - } - } - - /** - * Reads the resource content. - * - * @return ResourceContents[] array of ResourceContents objects - */ - public function read(ContainerInterface $container, string $uri): array - { - $result = $this->handle($container, ['uri' => $uri]); - - return $this->formatResult($result, $uri, $this->schema->mimeType); - } - /** * Formats the raw result of a resource read operation into MCP ResourceContent items. * @@ -91,7 +58,7 @@ public function read(ContainerInterface $container, string $uri): array * - array: Converted to JSON if MIME type is application/json or contains 'json' * For other MIME types, will try to convert to JSON with a warning */ - protected function formatResult(mixed $readResult, string $uri, ?string $mimeType): array + public function formatResult(mixed $readResult, string $uri, ?string $mimeType = null): array { if ($readResult instanceof ResourceContents) { return [$readResult]; @@ -237,12 +204,4 @@ private function guessMimeTypeFromString(string $content): string return 'text/plain'; } - - public function jsonSerialize(): array - { - return [ - 'schema' => $this->schema, - ...parent::jsonSerialize(), - ]; - } } diff --git a/src/Capability/Registry/RegisteredResourceTemplate.php b/src/Capability/Registry/ResourceTemplateReference.php similarity index 86% rename from src/Capability/Registry/RegisteredResourceTemplate.php rename to src/Capability/Registry/ResourceTemplateReference.php index 8bc4a738..3ef1d6cb 100644 --- a/src/Capability/Registry/RegisteredResourceTemplate.php +++ b/src/Capability/Registry/ResourceTemplateReference.php @@ -21,24 +21,26 @@ use Psr\Container\ContainerInterface; /** - * @phpstan-import-type CallableArray from RegisteredElement + * @phpstan-import-type Handler from ElementReference * * @author Kyrian Obikwelu */ -class RegisteredResourceTemplate extends RegisteredElement +class ResourceTemplateReference extends ElementReference { /** * @var array */ - protected array $variableNames; + private array $variableNames; + /** * @var array */ - protected array $uriVariables; - protected string $uriTemplateRegex; + private array $uriVariables; + + private string $uriTemplateRegex; /** - * @param callable|CallableArray|string $handler + * @param Handler $handler * @param array $completionProviders */ public function __construct( @@ -52,32 +54,6 @@ public function __construct( $this->compileTemplate(); } - /** - * @param array $data - */ - public static function fromArray(array $data): self|false - { - try { - if (!isset($data['schema']) || !isset($data['handler'])) { - return false; - } - - $completionProviders = []; - foreach ($data['completionProviders'] ?? [] as $argument => $provider) { - $completionProviders[$argument] = unserialize($provider); - } - - return new self( - ResourceTemplate::fromArray($data['schema']), - $data['handler'], - $data['isManual'] ?? false, - $completionProviders, - ); - } catch (\Throwable) { - return false; - } - } - /** * Gets the resource template. * @@ -186,7 +162,7 @@ private function compileTemplate(): void * - array: Converted to JSON if MIME type is application/json or contains 'json' * For other MIME types, will try to convert to JSON with a warning */ - protected function formatResult(mixed $readResult, string $uri, ?string $mimeType): array + protected function formatResult(mixed $readResult, string $uri, ?string $mimeType = null): array { if ($readResult instanceof ResourceContents) { return [$readResult]; @@ -332,26 +308,4 @@ private function guessMimeTypeFromString(string $content): string return 'text/plain'; } - - /** - * @return array{ - * schema: ResourceTemplate, - * completionProviders: array, - * handler: callable|CallableArray|string, - * isManual: bool, - * } - */ - public function jsonSerialize(): array - { - $completionProviders = []; - foreach ($this->completionProviders as $argument => $provider) { - $completionProviders[$argument] = serialize($provider); - } - - return [ - 'schema' => $this->resourceTemplate, - 'completionProviders' => $completionProviders, - ...parent::jsonSerialize(), - ]; - } } diff --git a/src/Capability/Registry/RegisteredTool.php b/src/Capability/Registry/ToolReference.php similarity index 70% rename from src/Capability/Registry/RegisteredTool.php rename to src/Capability/Registry/ToolReference.php index eed8872f..e5b5a7df 100644 --- a/src/Capability/Registry/RegisteredTool.php +++ b/src/Capability/Registry/ToolReference.php @@ -14,17 +14,16 @@ use Mcp\Schema\Content\Content; use Mcp\Schema\Content\TextContent; use Mcp\Schema\Tool; -use Psr\Container\ContainerInterface; /** - * @phpstan-import-type CallableArray from RegisteredElement + * @phpstan-import-type Handler from ElementReference * * @author Kyrian Obikwelu */ -class RegisteredTool extends RegisteredElement +class ToolReference extends ElementReference { /** - * @param callable|CallableArray|string $handler + * @param Handler $handler */ public function __construct( public readonly Tool $tool, @@ -34,40 +33,6 @@ public function __construct( parent::__construct($handler, $isManual); } - /** - * @param array $data - */ - public static function fromArray(array $data): self|false - { - try { - if (!isset($data['schema']) || !isset($data['handler'])) { - return false; - } - - return new self( - Tool::fromArray($data['schema']), - $data['handler'], - $data['isManual'] ?? false, - ); - } catch (\Throwable) { - return false; - } - } - - /** - * Calls the underlying handler for this tool. - * - * @param array $arguments - * - * @return Content[] the content items for CallToolResult - */ - public function call(ContainerInterface $container, array $arguments): array - { - $result = $this->handle($container, $arguments); - - return $this->formatResult($result); - } - /** * Formats the result of a tool execution into an array of MCP Content items. * @@ -87,7 +52,7 @@ public function call(ContainerInterface $container, array $arguments): array * * @throws \JsonException if JSON encoding fails for non-Content array/object results */ - private function formatResult(mixed $toolExecutionResult): array + public function formatResult(mixed $toolExecutionResult): array { if ($toolExecutionResult instanceof Content) { return [$toolExecutionResult]; @@ -146,19 +111,4 @@ private function formatResult(mixed $toolExecutionResult): array return [new TextContent($jsonResult)]; } - - /** - * @return array{ - * schema: Tool, - * handler: callable|CallableArray|string, - * isManual: bool, - * } - */ - public function jsonSerialize(): array - { - return [ - 'schema' => $this->tool, - ...parent::jsonSerialize(), - ]; - } } diff --git a/src/Exception/ContainerException.php b/src/Exception/ContainerException.php new file mode 100644 index 00000000..0a56987d --- /dev/null +++ b/src/Exception/ContainerException.php @@ -0,0 +1,18 @@ + + */ +class Exception extends \Exception implements ExceptionInterface +{ +} diff --git a/src/Exception/ServiceNotFoundException.php b/src/Exception/ServiceNotFoundException.php new file mode 100644 index 00000000..2d22708a --- /dev/null +++ b/src/Exception/ServiceNotFoundException.php @@ -0,0 +1,18 @@ +methodHandlers = $methodHandlers instanceof \Traversable ? iterator_to_array($methodHandlers) : $methodHandlers; } + public static function make( + Registry $registry, + Implementation $implementation, + LoggerInterface $logger = new NullLogger(), + ): self { + return new self( + MessageFactory::make(), + [ + new NotificationHandler\InitializedHandler(), + new RequestHandler\InitializeHandler($registry->getCapabilities(), $implementation), + new RequestHandler\PingHandler(), + new RequestHandler\ListPromptsHandler($registry), + new RequestHandler\GetPromptHandler($registry), + new RequestHandler\ListResourcesHandler($registry), + new RequestHandler\ReadResourceHandler($registry), + new RequestHandler\CallToolHandler($registry, $logger), + new RequestHandler\ListToolsHandler($registry), + ], + $logger, + ); + } + /** * @return iterable * @@ -53,12 +79,12 @@ public function __construct( */ public function process(string $input): iterable { - $this->logger->info('Received message to process', ['message' => $input]); + $this->logger->info('Received message to process.', ['message' => $input]); try { $messages = $this->messageFactory->create($input); } catch (\JsonException $e) { - $this->logger->warning('Failed to decode json message', ['exception' => $e]); + $this->logger->warning('Failed to decode json message.', ['exception' => $e]); yield $this->encodeResponse(Error::forParseError($e->getMessage())); @@ -67,12 +93,14 @@ public function process(string $input): iterable foreach ($messages as $message) { if ($message instanceof InvalidInputMessageException) { - $this->logger->warning('Failed to create message', ['exception' => $message]); + $this->logger->warning('Failed to create message.', ['exception' => $message]); yield $this->encodeResponse(Error::forInvalidRequest($message->getMessage(), 0)); continue; } - $this->logger->info('Decoded incoming message', ['message' => $message]); + $this->logger->debug(\sprintf('Decoded incoming message "%s".', $message::class), [ + 'method' => $message->getMethod(), + ]); try { yield $this->encodeResponse($this->handle($message)); @@ -105,7 +133,7 @@ private function encodeResponse(Response|Error|null $response): ?string return null; } - $this->logger->info('Encoding response', ['response' => $response]); + $this->logger->info('Encoding response.', ['response' => $response]); if ($response instanceof Response && [] === $response->result) { return json_encode($response, \JSON_THROW_ON_ERROR | \JSON_FORCE_OBJECT); @@ -122,12 +150,21 @@ private function encodeResponse(Response|Error|null $response): ?string */ private function handle(HasMethodInterface $message): 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)) { $return = $handler->handle($message); $handled = true; + $this->logger->debug(\sprintf('Message handled by "%s".', $handler::class), [ + 'method' => $message::getMethod(), + 'response' => $return, + ]); + if (null !== $return) { return $return; } diff --git a/src/Schema/Implementation.php b/src/Schema/Implementation.php index 7cde41b9..6fc51242 100644 --- a/src/Schema/Implementation.php +++ b/src/Schema/Implementation.php @@ -23,6 +23,7 @@ class Implementation implements \JsonSerializable public function __construct( public readonly string $name = 'app', public readonly string $version = 'dev', + public readonly ?string $description = null, ) { } @@ -41,7 +42,7 @@ public static function fromArray(array $data): self throw new InvalidArgumentException('Invalid or missing "version" in Implementation data.'); } - return new self($data['name'], $data['version']); + return new self($data['name'], $data['version'], $data['description'] ?? null); } /** @@ -52,9 +53,15 @@ public static function fromArray(array $data): self */ public function jsonSerialize(): array { - return [ + $data = [ 'name' => $this->name, 'version' => $this->version, ]; + + if (null !== $this->description) { + $data['description'] = $this->description; + } + + return $data; } } diff --git a/src/Schema/JsonRpc/HasMethodInterface.php b/src/Schema/JsonRpc/HasMethodInterface.php index 18286b72..cbaca8e0 100644 --- a/src/Schema/JsonRpc/HasMethodInterface.php +++ b/src/Schema/JsonRpc/HasMethodInterface.php @@ -1,7 +1,5 @@ |null */ - protected ?array $meta; + protected ?array $meta = null; abstract public static function getMethod(): string; diff --git a/src/Schema/JsonRpc/Request.php b/src/Schema/JsonRpc/Request.php index bad6e2e5..a4d0c0f4 100644 --- a/src/Schema/JsonRpc/Request.php +++ b/src/Schema/JsonRpc/Request.php @@ -29,7 +29,7 @@ abstract class Request implements HasMethodInterface, MessageInterface /** * @var array|null */ - protected ?array $meta; + protected ?array $meta = null; abstract public static function getMethod(): string; diff --git a/src/Schema/Request/CreateSamplingMessageRequest.php b/src/Schema/Request/CreateSamplingMessageRequest.php index 131e44e4..ae9ea39c 100644 --- a/src/Schema/Request/CreateSamplingMessageRequest.php +++ b/src/Schema/Request/CreateSamplingMessageRequest.php @@ -11,6 +11,7 @@ namespace Mcp\Schema\Request; +use Mcp\Exception\InvalidArgumentException; use Mcp\Schema\Content\SamplingMessage; use Mcp\Schema\JsonRpc\Request; use Mcp\Schema\ModelPreferences; @@ -61,11 +62,11 @@ public static function getMethod(): string protected static function fromParams(?array $params): Request { if (!isset($params['messages']) || !\is_array($params['messages'])) { - throw new \InvalidArgumentException('Missing or invalid "messages" parameter for sampling/createMessage.'); + throw new InvalidArgumentException('Missing or invalid "messages" parameter for sampling/createMessage.'); } if (!isset($params['maxTokens']) || !\is_int($params['maxTokens'])) { - throw new \InvalidArgumentException('Missing or invalid "maxTokens" parameter for sampling/createMessage.'); + throw new InvalidArgumentException('Missing or invalid "maxTokens" parameter for sampling/createMessage.'); } $preferences = null; diff --git a/src/Schema/Result/EmptyResult.php b/src/Schema/Result/EmptyResult.php index 67080cc1..0570b212 100644 --- a/src/Schema/Result/EmptyResult.php +++ b/src/Schema/Result/EmptyResult.php @@ -35,8 +35,8 @@ public static function fromArray(): self /** * @return array{} */ - public function jsonSerialize(): array + public function jsonSerialize(): object { - return []; + return new \stdClass(); } } diff --git a/src/Schema/Result/InitializeResult.php b/src/Schema/Result/InitializeResult.php index c6e753bc..9b0087ec 100644 --- a/src/Schema/Result/InitializeResult.php +++ b/src/Schema/Result/InitializeResult.php @@ -82,7 +82,7 @@ public static function fromArray(array $data): self public function jsonSerialize(): array { $data = [ - 'protocolVersion' => MessageInterface::JSONRPC_VERSION, + 'protocolVersion' => MessageInterface::PROTOCOL_VERSION, 'capabilities' => $this->capabilities, 'serverInfo' => $this->serverInfo, ]; diff --git a/src/Schema/Result/ListPromptsResult.php b/src/Schema/Result/ListPromptsResult.php index 7c22dd3a..3a2fa1f0 100644 --- a/src/Schema/Result/ListPromptsResult.php +++ b/src/Schema/Result/ListPromptsResult.php @@ -64,7 +64,7 @@ public static function fromArray(array $data): self public function jsonSerialize(): array { $result = [ - 'prompts' => $this->prompts, + 'prompts' => array_values($this->prompts), ]; if ($this->nextCursor) { diff --git a/src/Schema/Result/ListResourceTemplatesResult.php b/src/Schema/Result/ListResourceTemplatesResult.php index 186e1894..97a37322 100644 --- a/src/Schema/Result/ListResourceTemplatesResult.php +++ b/src/Schema/Result/ListResourceTemplatesResult.php @@ -64,7 +64,7 @@ public static function fromArray(array $data): self public function jsonSerialize(): array { $result = [ - 'resourceTemplates' => $this->resourceTemplates, + 'resourceTemplates' => array_values($this->resourceTemplates), ]; if ($this->nextCursor) { diff --git a/src/Schema/Result/ListResourcesResult.php b/src/Schema/Result/ListResourcesResult.php index 3b4fc6e5..a6be7559 100644 --- a/src/Schema/Result/ListResourcesResult.php +++ b/src/Schema/Result/ListResourcesResult.php @@ -64,7 +64,7 @@ public static function fromArray(array $data): self public function jsonSerialize(): array { $result = [ - 'resources' => $this->resources, + 'resources' => array_values($this->resources), ]; if (null !== $this->nextCursor) { diff --git a/src/Schema/Result/ListRootsResult.php b/src/Schema/Result/ListRootsResult.php index 3a04fa3f..ab6c5c94 100644 --- a/src/Schema/Result/ListRootsResult.php +++ b/src/Schema/Result/ListRootsResult.php @@ -42,7 +42,7 @@ public function __construct( public function jsonSerialize(): array { $result = [ - 'roots' => $this->roots, + 'roots' => array_values($this->roots), ]; if (null !== $this->_meta) { diff --git a/src/Schema/Result/ListToolsResult.php b/src/Schema/Result/ListToolsResult.php index 6dde692e..176c86a9 100644 --- a/src/Schema/Result/ListToolsResult.php +++ b/src/Schema/Result/ListToolsResult.php @@ -64,7 +64,7 @@ public static function fromArray(array $data): self public function jsonSerialize(): array { $result = [ - 'tools' => $this->tools, + 'tools' => array_values($this->tools), ]; if ($this->nextCursor) { diff --git a/src/Schema/Result/ReadResourceResult.php b/src/Schema/Result/ReadResourceResult.php index aa2d32af..7fd80009 100644 --- a/src/Schema/Result/ReadResourceResult.php +++ b/src/Schema/Result/ReadResourceResult.php @@ -13,8 +13,8 @@ use Mcp\Exception\InvalidArgumentException; use Mcp\Schema\Content\BlobResourceContents; +use Mcp\Schema\Content\ResourceContents; use Mcp\Schema\Content\TextResourceContents; -use Mcp\Schema\JsonRpc\Response; use Mcp\Schema\JsonRpc\ResultInterface; /** @@ -30,7 +30,7 @@ class ReadResourceResult implements ResultInterface /** * Create a new ReadResourceResult. * - * @param array $contents The contents of the resource + * @param ResourceContents[] $contents The contents of the resource */ public function __construct( public readonly array $contents, diff --git a/src/Server.php b/src/Server.php index 26a27783..fc81382d 100644 --- a/src/Server.php +++ b/src/Server.php @@ -12,6 +12,7 @@ namespace Mcp; use Mcp\JsonRpc\Handler; +use Mcp\Server\ServerBuilder; use Mcp\Server\TransportInterface; use Psr\Log\LoggerInterface; use Psr\Log\NullLogger; @@ -27,10 +28,17 @@ public function __construct( ) { } + public static function make(): ServerBuilder + { + return new ServerBuilder(); + } + public function connect(TransportInterface $transport): void { $transport->initialize(); - $this->logger->info('Transport initialized'); + $this->logger->info('Transport initialized.', [ + 'transport' => $transport::class, + ]); while ($transport->isConnected()) { foreach ($transport->receive() as $message) { @@ -47,7 +55,7 @@ public function connect(TransportInterface $transport): void $transport->send($response); } } catch (\JsonException $e) { - $this->logger->error('Failed to encode response to JSON', [ + $this->logger->error('Failed to encode response to JSON.', [ 'message' => $message, 'exception' => $e, ]); diff --git a/src/Server/RequestHandler/CallToolHandler.php b/src/Server/RequestHandler/CallToolHandler.php index 64851d7d..a4dfebd0 100644 --- a/src/Server/RequestHandler/CallToolHandler.php +++ b/src/Server/RequestHandler/CallToolHandler.php @@ -11,13 +11,16 @@ namespace Mcp\Server\RequestHandler; -use Mcp\Capability\Tool\ToolExecutorInterface; +use Mcp\Capability\Registry; use Mcp\Exception\ExceptionInterface; use Mcp\Schema\JsonRpc\Error; use Mcp\Schema\JsonRpc\HasMethodInterface; use Mcp\Schema\JsonRpc\Response; use Mcp\Schema\Request\CallToolRequest; +use Mcp\Schema\Result\CallToolResult; use Mcp\Server\MethodHandlerInterface; +use Psr\Log\LoggerInterface; +use Psr\Log\NullLogger; /** * @author Christopher Hertel @@ -26,7 +29,8 @@ final class CallToolHandler implements MethodHandlerInterface { public function __construct( - private readonly ToolExecutorInterface $toolExecutor, + private readonly Registry $registry, + private readonly LoggerInterface $logger = new NullLogger(), ) { } @@ -40,11 +44,16 @@ public function handle(CallToolRequest|HasMethodInterface $message): Response|Er \assert($message instanceof CallToolRequest); try { - $result = $this->toolExecutor->call($message); - } catch (ExceptionInterface) { + $content = $this->registry->handleCallTool($message->name, $message->arguments); + } catch (ExceptionInterface $exception) { + $this->logger->error(\sprintf('Error while executing tool "%s": "%s".', $message->name, $exception->getMessage()), [ + 'tool' => $message->name, + 'arguments' => $message->arguments, + ]); + return Error::forInternalError('Error while executing tool', $message->getId()); } - return new Response($message->getId(), $result); + return new Response($message->getId(), new CallToolResult($content)); } } diff --git a/src/Server/RequestHandler/GetPromptHandler.php b/src/Server/RequestHandler/GetPromptHandler.php index 8ac82842..c74044d8 100644 --- a/src/Server/RequestHandler/GetPromptHandler.php +++ b/src/Server/RequestHandler/GetPromptHandler.php @@ -11,12 +11,13 @@ namespace Mcp\Server\RequestHandler; -use Mcp\Capability\Prompt\PromptGetterInterface; +use Mcp\Capability\Registry; use Mcp\Exception\ExceptionInterface; use Mcp\Schema\JsonRpc\Error; use Mcp\Schema\JsonRpc\HasMethodInterface; use Mcp\Schema\JsonRpc\Response; use Mcp\Schema\Request\GetPromptRequest; +use Mcp\Schema\Result\GetPromptResult; use Mcp\Server\MethodHandlerInterface; /** @@ -25,7 +26,7 @@ final class GetPromptHandler implements MethodHandlerInterface { public function __construct( - private readonly PromptGetterInterface $getter, + private readonly Registry $registry, ) { } @@ -39,11 +40,11 @@ public function handle(GetPromptRequest|HasMethodInterface $message): Response|E \assert($message instanceof GetPromptRequest); try { - $result = $this->getter->get($message); + $messages = $this->registry->handleGetPrompt($message->name, $message->arguments); } catch (ExceptionInterface) { return Error::forInternalError('Error while handling prompt', $message->getId()); } - return new Response($message->getId(), $result); + return new Response($message->getId(), new GetPromptResult($messages)); } } diff --git a/src/Server/RequestHandler/ListPromptsHandler.php b/src/Server/RequestHandler/ListPromptsHandler.php index d58a92f9..942550a0 100644 --- a/src/Server/RequestHandler/ListPromptsHandler.php +++ b/src/Server/RequestHandler/ListPromptsHandler.php @@ -11,11 +11,9 @@ namespace Mcp\Server\RequestHandler; -use Mcp\Capability\Prompt\CollectionInterface; +use Mcp\Capability\Registry; use Mcp\Schema\JsonRpc\HasMethodInterface; use Mcp\Schema\JsonRpc\Response; -use Mcp\Schema\Prompt; -use Mcp\Schema\PromptArgument; use Mcp\Schema\Request\ListPromptsRequest; use Mcp\Schema\Result\ListPromptsResult; use Mcp\Server\MethodHandlerInterface; @@ -26,7 +24,7 @@ final class ListPromptsHandler implements MethodHandlerInterface { public function __construct( - private readonly CollectionInterface $collection, + private readonly Registry $registry, private readonly int $pageSize = 20, ) { } @@ -41,19 +39,7 @@ public function handle(ListPromptsRequest|HasMethodInterface $message): Response \assert($message instanceof ListPromptsRequest); $cursor = null; - $prompts = []; - - $metadataList = $this->collection->getMetadata($this->pageSize, $message->cursor); - - foreach ($metadataList as $metadata) { - $cursor = $metadata->getName(); - $prompts[] = new Prompt( - $metadata->getName(), - $metadata->getDescription(), - array_map(fn (array $data) => PromptArgument::fromArray($data), $metadata->getArguments()), - ); - } - + $prompts = $this->registry->getPrompts($this->pageSize, $message->cursor); $nextCursor = (null !== $cursor && \count($prompts) === $this->pageSize) ? $cursor : null; return new Response( diff --git a/src/Server/RequestHandler/ListResourcesHandler.php b/src/Server/RequestHandler/ListResourcesHandler.php index 2be0a86d..75804d84 100644 --- a/src/Server/RequestHandler/ListResourcesHandler.php +++ b/src/Server/RequestHandler/ListResourcesHandler.php @@ -11,11 +11,10 @@ namespace Mcp\Server\RequestHandler; -use Mcp\Capability\Resource\CollectionInterface; +use Mcp\Capability\Registry; use Mcp\Schema\JsonRpc\HasMethodInterface; use Mcp\Schema\JsonRpc\Response; use Mcp\Schema\Request\ListResourcesRequest; -use Mcp\Schema\Resource; use Mcp\Schema\Result\ListResourcesResult; use Mcp\Server\MethodHandlerInterface; @@ -25,7 +24,7 @@ final class ListResourcesHandler implements MethodHandlerInterface { public function __construct( - private readonly CollectionInterface $collection, + private readonly Registry $registry, private readonly int $pageSize = 20, ) { } @@ -40,22 +39,7 @@ public function handle(ListResourcesRequest|HasMethodInterface $message): Respon \assert($message instanceof ListResourcesRequest); $cursor = null; - $resources = []; - - $metadataList = $this->collection->getMetadata($this->pageSize, $message->cursor); - - foreach ($metadataList as $metadata) { - $cursor = $metadata->getUri(); - $resources[] = new Resource( - $metadata->getUri(), - $metadata->getName(), - $metadata->getDescription(), - $metadata->getMimeType(), - null, - $metadata->getSize(), - ); - } - + $resources = $this->registry->getResources($this->pageSize, $message->cursor); $nextCursor = (null !== $cursor && \count($resources) === $this->pageSize) ? $cursor : null; return new Response( diff --git a/src/Server/RequestHandler/ListToolsHandler.php b/src/Server/RequestHandler/ListToolsHandler.php index bbbd8e7e..ef35fa8d 100644 --- a/src/Server/RequestHandler/ListToolsHandler.php +++ b/src/Server/RequestHandler/ListToolsHandler.php @@ -11,12 +11,11 @@ namespace Mcp\Server\RequestHandler; -use Mcp\Capability\Tool\CollectionInterface; +use Mcp\Capability\Registry; use Mcp\Schema\JsonRpc\HasMethodInterface; use Mcp\Schema\JsonRpc\Response; use Mcp\Schema\Request\ListToolsRequest; use Mcp\Schema\Result\ListToolsResult; -use Mcp\Schema\Tool; use Mcp\Server\MethodHandlerInterface; /** @@ -26,7 +25,7 @@ final class ListToolsHandler implements MethodHandlerInterface { public function __construct( - private readonly CollectionInterface $collection, + private readonly Registry $registry, private readonly int $pageSize = 20, ) { } @@ -41,24 +40,7 @@ public function handle(ListToolsRequest|HasMethodInterface $message): Response \assert($message instanceof ListToolsRequest); $cursor = null; - $tools = []; - - $metadataList = $this->collection->getMetadata($this->pageSize, $message->cursor); - - foreach ($metadataList as $tool) { - $cursor = $tool->getName(); - $inputSchema = $tool->getInputSchema(); - $tools[] = new Tool( - $tool->getName(), - [] === $inputSchema ? [ - 'type' => 'object', - '$schema' => 'http://json-schema.org/draft-07/schema#', - ] : $inputSchema, - $tool->getDescription(), - null, - ); - } - + $tools = $this->registry->getTools($this->pageSize, $message->cursor); $nextCursor = (null !== $cursor && \count($tools) === $this->pageSize) ? $cursor : null; return new Response( diff --git a/src/Server/RequestHandler/ReadResourceHandler.php b/src/Server/RequestHandler/ReadResourceHandler.php index 42b73681..40746a6e 100644 --- a/src/Server/RequestHandler/ReadResourceHandler.php +++ b/src/Server/RequestHandler/ReadResourceHandler.php @@ -11,13 +11,14 @@ namespace Mcp\Server\RequestHandler; -use Mcp\Capability\Resource\ResourceReaderInterface; +use Mcp\Capability\Registry; use Mcp\Exception\ExceptionInterface; use Mcp\Exception\ResourceNotFoundException; use Mcp\Schema\JsonRpc\Error; use Mcp\Schema\JsonRpc\HasMethodInterface; use Mcp\Schema\JsonRpc\Response; use Mcp\Schema\Request\ReadResourceRequest; +use Mcp\Schema\Result\ReadResourceResult; use Mcp\Server\MethodHandlerInterface; /** @@ -26,7 +27,7 @@ final class ReadResourceHandler implements MethodHandlerInterface { public function __construct( - private readonly ResourceReaderInterface $reader, + private readonly Registry $registry, ) { } @@ -40,13 +41,13 @@ public function handle(ReadResourceRequest|HasMethodInterface $message): Respons \assert($message instanceof ReadResourceRequest); try { - $result = $this->reader->read($message); + $contents = $this->registry->handleReadResource($message->uri); } catch (ResourceNotFoundException $e) { return new Error($message->getId(), Error::RESOURCE_NOT_FOUND, $e->getMessage()); } catch (ExceptionInterface) { return Error::forInternalError('Error while reading resource', $message->getId()); } - return new Response($message->getId(), $result); + return new Response($message->getId(), new ReadResourceResult($contents)); } } diff --git a/src/Server/ServerBuilder.php b/src/Server/ServerBuilder.php new file mode 100644 index 00000000..2b257873 --- /dev/null +++ b/src/Server/ServerBuilder.php @@ -0,0 +1,418 @@ + + */ +final class ServerBuilder +{ + private ?Implementation $serverInfo = null; + + private ?LoggerInterface $logger = null; + + private ?CacheInterface $cache = null; + + private ?EventDispatcherInterface $eventDispatcher = null; + + private ?ContainerInterface $container = null; + + private ?int $paginationLimit = 50; + + private ?string $instructions = null; + + /** @var array< + * array{handler: array|string|Closure, + * name: string|null, + * description: string|null, + * annotations: ToolAnnotations|null} + * > */ + private array $manualTools = []; + + /** @var array< + * array{handler: array|string|Closure, + * uri: string, + * name: string|null, + * description: string|null, + * mimeType: string|null, + * size: int|null, + * annotations: Annotations|null} + * > */ + private array $manualResources = []; + + /** @var array< + * array{handler: array|string|Closure, + * uriTemplate: string, + * name: string|null, + * description: string|null, + * mimeType: string|null, + * annotations: Annotations|null} + * > */ + private array $manualResourceTemplates = []; + /** @var array< + * array{handler: array|string|Closure, + * name: string|null, + * description: string|null} + * > */ + private array $manualPrompts = []; + private ?string $discoveryBasePath = null; + /** + * @var array|string[] + */ + private array $discoveryScanDirs = []; + private array $discoveryExcludeDirs = []; + + /** + * Sets the server's identity. Required. + */ + public function withServerInfo(string $name, string $version, ?string $description = null): self + { + $this->serverInfo = new Implementation(trim($name), trim($version), $description); + + return $this; + } + + /** + * Configures the server's pagination limit. + */ + public function withPaginationLimit(int $paginationLimit): self + { + $this->paginationLimit = $paginationLimit; + + return $this; + } + + /** + * Configures the instructions describing how to use the server and its features. + * + * This can be used by clients to improve the LLM's understanding of available tools, resources, + * etc. It can be thought of like a "hint" to the model. For example, this information MAY + * be added to the system prompt. + */ + public function withInstructions(?string $instructions): self + { + $this->instructions = $instructions; + + return $this; + } + + /** + * Provides a PSR-3 logger instance. Defaults to NullLogger. + */ + public function withLogger(LoggerInterface $logger): self + { + $this->logger = $logger; + + return $this; + } + + public function withEventDispatcher(EventDispatcherInterface $eventDispatcher): self + { + $this->eventDispatcher = $eventDispatcher; + + return $this; + } + + /** + * Provides a PSR-11 DI container, primarily for resolving user-defined handler classes. + * Defaults to a basic internal container. + */ + public function withContainer(ContainerInterface $container): self + { + $this->container = $container; + + return $this; + } + + public function withDiscovery( + string $basePath, + array $scanDirs = ['.', 'src'], + array $excludeDirs = [], + ): self { + $this->discoveryBasePath = $basePath; + $this->discoveryScanDirs = $scanDirs; + $this->discoveryExcludeDirs = $excludeDirs; + + return $this; + } + + /** + * Manually registers a tool handler. + */ + public function withTool(callable|array|string $handler, ?string $name = null, ?string $description = null, ?ToolAnnotations $annotations = null, ?array $inputSchema = null): self + { + $this->manualTools[] = compact('handler', 'name', 'description', 'annotations', 'inputSchema'); + + return $this; + } + + /** + * Manually registers a resource handler. + */ + public function withResource(callable|array|string $handler, string $uri, ?string $name = null, ?string $description = null, ?string $mimeType = null, ?int $size = null, ?Annotations $annotations = null): self + { + $this->manualResources[] = compact('handler', 'uri', 'name', 'description', 'mimeType', 'size', 'annotations'); + + return $this; + } + + /** + * Manually registers a resource template handler. + */ + public function withResourceTemplate(callable|array|string $handler, string $uriTemplate, ?string $name = null, ?string $description = null, ?string $mimeType = null, ?Annotations $annotations = null): self + { + $this->manualResourceTemplates[] = compact('handler', 'uriTemplate', 'name', 'description', 'mimeType', 'annotations'); + + return $this; + } + + /** + * Manually registers a prompt handler. + */ + public function withPrompt(callable|array|string $handler, ?string $name = null, ?string $description = null): self + { + $this->manualPrompts[] = compact('handler', 'name', 'description'); + + return $this; + } + + /** + * Builds the fully configured Server instance. + */ + public function build(): Server + { + $container = $this->container ?? new Container(); + $registry = new Registry(new ReferenceHandler($container), $this->eventDispatcher, $this->logger); + + if (null !== $this->discoveryBasePath) { + $discovery = new Discoverer($registry, $this->logger); + $discovery->discover($this->discoveryBasePath, $this->discoveryScanDirs, $this->discoveryExcludeDirs); + } + + return new Server( + Handler::make($registry, $this->serverInfo, $this->logger), + $this->logger, + ); + } + + /** + * Helper to perform the actual registration based on stored data. + * Moved into the builder. + */ + private function registerManualElements(Registry $registry, LoggerInterface $logger): void + { + if (empty($this->manualTools) && empty($this->manualResources) && empty($this->manualResourceTemplates) && empty($this->manualPrompts)) { + return; + } + + $docBlockParser = new DocBlockParser(logger: $logger); + $schemaGenerator = new SchemaGenerator($docBlockParser); + + // Register Tools + foreach ($this->manualTools as $data) { + try { + $reflection = HandlerResolver::resolve($data['handler']); + + if ($reflection instanceof \ReflectionFunction) { + $name = $data['name'] ?? 'closure_tool_'.spl_object_id($data['handler']); + $description = $data['description'] ?? null; + } else { + $classShortName = $reflection->getDeclaringClass()->getShortName(); + $methodName = $reflection->getName(); + $docBlock = $docBlockParser->parseDocBlock($reflection->getDocComment() ?? null); + + $name = $data['name'] ?? ('__invoke' === $methodName ? $classShortName : $methodName); + $description = $data['description'] ?? $docBlockParser->getSummary($docBlock) ?? null; + } + + $inputSchema = $data['inputSchema'] ?? $schemaGenerator->generate($reflection); + + $tool = new Tool($name, $inputSchema, $description, $data['annotations']); + $registry->registerTool($tool, $data['handler'], true); + + $handlerDesc = $data['handler'] instanceof \Closure ? 'Closure' : (\is_array($data['handler']) ? implode('::', $data['handler']) : $data['handler']); + $logger->debug("Registered manual tool {$name} from handler {$handlerDesc}"); + } catch (\Throwable $e) { + $logger->error('Failed to register manual tool', ['handler' => $data['handler'], 'name' => $data['name'], 'exception' => $e]); + throw new ConfigurationException("Error registering manual tool '{$data['name']}': {$e->getMessage()}", 0, $e); + } + } + + // Register Resources + foreach ($this->manualResources as $data) { + try { + $reflection = HandlerResolver::resolve($data['handler']); + + if ($reflection instanceof \ReflectionFunction) { + $name = $data['name'] ?? 'closure_resource_'.spl_object_id($data['handler']); + $description = $data['description'] ?? null; + } else { + $classShortName = $reflection->getDeclaringClass()->getShortName(); + $methodName = $reflection->getName(); + $docBlock = $docBlockParser->parseDocBlock($reflection->getDocComment() ?? null); + + $name = $data['name'] ?? ('__invoke' === $methodName ? $classShortName : $methodName); + $description = $data['description'] ?? $docBlockParser->getSummary($docBlock) ?? null; + } + + $uri = $data['uri']; + $mimeType = $data['mimeType']; + $size = $data['size']; + $annotations = $data['annotations']; + + $resource = new Resource($uri, $name, $description, $mimeType, $annotations, $size); + $registry->registerResource($resource, $data['handler'], true); + + $handlerDesc = $data['handler'] instanceof \Closure ? 'Closure' : (\is_array($data['handler']) ? implode('::', $data['handler']) : $data['handler']); + $logger->debug("Registered manual resource {$name} from handler {$handlerDesc}"); + } catch (\Throwable $e) { + $logger->error('Failed to register manual resource', ['handler' => $data['handler'], 'uri' => $data['uri'], 'exception' => $e]); + throw new ConfigurationException("Error registering manual resource '{$data['uri']}': {$e->getMessage()}", 0, $e); + } + } + + // Register Templates + foreach ($this->manualResourceTemplates as $data) { + try { + $reflection = HandlerResolver::resolve($data['handler']); + + if ($reflection instanceof \ReflectionFunction) { + $name = $data['name'] ?? 'closure_template_'.spl_object_id($data['handler']); + $description = $data['description'] ?? null; + } else { + $classShortName = $reflection->getDeclaringClass()->getShortName(); + $methodName = $reflection->getName(); + $docBlock = $docBlockParser->parseDocBlock($reflection->getDocComment() ?? null); + + $name = $data['name'] ?? ('__invoke' === $methodName ? $classShortName : $methodName); + $description = $data['description'] ?? $docBlockParser->getSummary($docBlock) ?? null; + } + + $uriTemplate = $data['uriTemplate']; + $mimeType = $data['mimeType']; + $annotations = $data['annotations']; + + $template = new ResourceTemplate($uriTemplate, $name, $description, $mimeType, $annotations); + $completionProviders = $this->getCompletionProviders($reflection); + $registry->registerResourceTemplate($template, $data['handler'], $completionProviders, true); + + $handlerDesc = $data['handler'] instanceof \Closure ? 'Closure' : (\is_array($data['handler']) ? implode('::', $data['handler']) : $data['handler']); + $logger->debug("Registered manual template {$name} from handler {$handlerDesc}"); + } catch (\Throwable $e) { + $logger->error('Failed to register manual template', ['handler' => $data['handler'], 'uriTemplate' => $data['uriTemplate'], 'exception' => $e]); + throw new ConfigurationException("Error registering manual resource template '{$data['uriTemplate']}': {$e->getMessage()}", 0, $e); + } + } + + // Register Prompts + foreach ($this->manualPrompts as $data) { + try { + $reflection = HandlerResolver::resolve($data['handler']); + + if ($reflection instanceof \ReflectionFunction) { + $name = $data['name'] ?? 'closure_prompt_'.spl_object_id($data['handler']); + $description = $data['description'] ?? null; + } else { + $classShortName = $reflection->getDeclaringClass()->getShortName(); + $methodName = $reflection->getName(); + $docBlock = $docBlockParser->parseDocBlock($reflection->getDocComment() ?? null); + + $name = $data['name'] ?? ('__invoke' === $methodName ? $classShortName : $methodName); + $description = $data['description'] ?? $docBlockParser->getSummary($docBlock) ?? null; + } + + $arguments = []; + $paramTags = $reflection instanceof \ReflectionMethod ? $docBlockParser->getParamTags($docBlockParser->parseDocBlock($reflection->getDocComment() ?? null)) : []; + foreach ($reflection->getParameters() as $param) { + $reflectionType = $param->getType(); + + // Basic DI check (heuristic) + if ($reflectionType instanceof \ReflectionNamedType && !$reflectionType->isBuiltin()) { + continue; + } + + $paramTag = $paramTags['$'.$param->getName()] ?? null; + $arguments[] = new PromptArgument( + $param->getName(), + $paramTag ? trim((string) $paramTag->getDescription()) : null, + !$param->isOptional() && !$param->isDefaultValueAvailable() + ); + } + + $prompt = new Prompt($name, $description, $arguments); + $completionProviders = $this->getCompletionProviders($reflection); + $registry->registerPrompt($prompt, $data['handler'], $completionProviders, true); + + $handlerDesc = $data['handler'] instanceof \Closure ? 'Closure' : (\is_array($data['handler']) ? implode('::', $data['handler']) : $data['handler']); + $logger->debug("Registered manual prompt {$name} from handler {$handlerDesc}"); + } catch (\Throwable $e) { + $logger->error('Failed to register manual prompt', ['handler' => $data['handler'], 'name' => $data['name'], 'exception' => $e]); + throw new ConfigurationException("Error registering manual prompt '{$data['name']}': {$e->getMessage()}", 0, $e); + } + } + + $logger->debug('Manual element registration complete.'); + } + + private function getCompletionProviders(\ReflectionMethod|\ReflectionFunction $reflection): array + { + $completionProviders = []; + foreach ($reflection->getParameters() as $param) { + $reflectionType = $param->getType(); + if ($reflectionType instanceof \ReflectionNamedType && !$reflectionType->isBuiltin()) { + continue; + } + + $completionAttributes = $param->getAttributes(CompletionProvider::class, \ReflectionAttribute::IS_INSTANCEOF); + if (!empty($completionAttributes)) { + $attributeInstance = $completionAttributes[0]->newInstance(); + + if ($attributeInstance->provider) { + $completionProviders[$param->getName()] = $attributeInstance->provider; + } elseif ($attributeInstance->providerClass) { + $completionProviders[$param->getName()] = $attributeInstance->providerClass; + } elseif ($attributeInstance->values) { + $completionProviders[$param->getName()] = new ListCompletionProvider($attributeInstance->values); + } elseif ($attributeInstance->enum) { + $completionProviders[$param->getName()] = new EnumCompletionProvider($attributeInstance->enum); + } + } + } + + return $completionProviders; + } +} diff --git a/src/Server/Transport/Stdio/SymfonyConsoleTransport.php b/src/Server/Transport/StdioTransport.php similarity index 60% rename from src/Server/Transport/Stdio/SymfonyConsoleTransport.php rename to src/Server/Transport/StdioTransport.php index 17e5a371..309683ab 100644 --- a/src/Server/Transport/Stdio/SymfonyConsoleTransport.php +++ b/src/Server/Transport/StdioTransport.php @@ -9,23 +9,27 @@ * file that was distributed with this source code. */ -namespace Mcp\Server\Transport\Stdio; +namespace Mcp\Server\Transport; use Mcp\Server\TransportInterface; -use Symfony\Component\Console\Input\InputInterface; -use Symfony\Component\Console\Input\StreamableInputInterface; -use Symfony\Component\Console\Output\OutputInterface; +use Psr\Log\LoggerInterface; +use Psr\Log\NullLogger; /** * Heavily inspired by https://jolicode.com/blog/mcp-the-open-protocol-that-turns-llm-chatbots-into-intelligent-agents. */ -final class SymfonyConsoleTransport implements TransportInterface +class StdioTransport implements TransportInterface { private string $buffer = ''; + /** + * @param resource $input + * @param resource $output + */ public function __construct( - private readonly InputInterface $input, - private readonly OutputInterface $output, + private $input = \STDIN, + private $output = \STDOUT, + private readonly LoggerInterface $logger = new NullLogger(), ) { } @@ -40,12 +44,16 @@ public function isConnected(): bool public function receive(): \Generator { - $stream = $this->input instanceof StreamableInputInterface ? $this->input->getStream() ?? \STDIN : \STDIN; - $line = fgets($stream); + $line = fgets($this->input); + + $this->logger->debug('Received message on StdioTransport.', [ + 'line' => $line, + ]); + if (false === $line) { return; } - $this->buffer .= \STDIN === $stream ? rtrim($line).\PHP_EOL : $line; + $this->buffer .= rtrim($line).\PHP_EOL; if (str_contains($this->buffer, \PHP_EOL)) { $lines = explode(\PHP_EOL, $this->buffer); $this->buffer = array_pop($lines); @@ -56,7 +64,9 @@ public function receive(): \Generator public function send(string $data): void { - $this->output->writeln($data); + $this->logger->debug('Sending data to client via StdioTransport.', ['data' => $data]); + + fwrite($this->output, $data.\PHP_EOL); } public function close(): void diff --git a/tests/Capability/Discovery/DiscoveryTest.php b/tests/Capability/Discovery/DiscoveryTest.php index dc88f17c..c6ab3e8a 100644 --- a/tests/Capability/Discovery/DiscoveryTest.php +++ b/tests/Capability/Discovery/DiscoveryTest.php @@ -15,10 +15,10 @@ use Mcp\Capability\Prompt\Completion\EnumCompletionProvider; use Mcp\Capability\Prompt\Completion\ListCompletionProvider; use Mcp\Capability\Registry; -use Mcp\Capability\Registry\RegisteredPrompt; -use Mcp\Capability\Registry\RegisteredResource; -use Mcp\Capability\Registry\RegisteredResourceTemplate; -use Mcp\Capability\Registry\RegisteredTool; +use Mcp\Capability\Registry\PromptReference; +use Mcp\Capability\Registry\ResourceReference; +use Mcp\Capability\Registry\ResourceTemplateReference; +use Mcp\Capability\Registry\ToolReference; use Mcp\Tests\Capability\Attribute\CompletionProviderFixture; use Mcp\Tests\Capability\Discovery\Fixtures\DiscoverableToolHandler; use Mcp\Tests\Capability\Discovery\Fixtures\InvocablePromptFixture; @@ -46,7 +46,7 @@ public function testDiscoversAllElementTypesCorrectlyFromFixtureFiles() $this->assertCount(4, $tools); $greetUserTool = $this->registry->getTool('greet_user'); - $this->assertInstanceOf(RegisteredTool::class, $greetUserTool); + $this->assertInstanceOf(ToolReference::class, $greetUserTool); $this->assertFalse($greetUserTool->isManual); $this->assertEquals('greet_user', $greetUserTool->tool->name); $this->assertEquals('Greets a user by name.', $greetUserTool->tool->description); @@ -54,13 +54,13 @@ public function testDiscoversAllElementTypesCorrectlyFromFixtureFiles() $this->assertArrayHasKey('name', $greetUserTool->tool->inputSchema['properties'] ?? []); $repeatActionTool = $this->registry->getTool('repeatAction'); - $this->assertInstanceOf(RegisteredTool::class, $repeatActionTool); + $this->assertInstanceOf(ToolReference::class, $repeatActionTool); $this->assertEquals('A tool with more complex parameters and inferred name/description.', $repeatActionTool->tool->description); $this->assertTrue($repeatActionTool->tool->annotations->readOnlyHint); $this->assertEquals(['count', 'loudly', 'mode'], array_keys($repeatActionTool->tool->inputSchema['properties'] ?? [])); $invokableCalcTool = $this->registry->getTool('InvokableCalculator'); - $this->assertInstanceOf(RegisteredTool::class, $invokableCalcTool); + $this->assertInstanceOf(ToolReference::class, $invokableCalcTool); $this->assertFalse($invokableCalcTool->isManual); $this->assertEquals([InvocableToolFixture::class, '__invoke'], $invokableCalcTool->handler); @@ -72,13 +72,13 @@ public function testDiscoversAllElementTypesCorrectlyFromFixtureFiles() $this->assertCount(3, $resources); $appVersionRes = $this->registry->getResource('app://info/version'); - $this->assertInstanceOf(RegisteredResource::class, $appVersionRes); + $this->assertInstanceOf(ResourceReference::class, $appVersionRes); $this->assertFalse($appVersionRes->isManual); $this->assertEquals('app_version', $appVersionRes->schema->name); $this->assertEquals('text/plain', $appVersionRes->schema->mimeType); $invokableStatusRes = $this->registry->getResource('invokable://config/status'); - $this->assertInstanceOf(RegisteredResource::class, $invokableStatusRes); + $this->assertInstanceOf(ResourceReference::class, $invokableStatusRes); $this->assertFalse($invokableStatusRes->isManual); $this->assertEquals([InvocableResourceFixture::class, '__invoke'], $invokableStatusRes->handler); @@ -86,22 +86,22 @@ public function testDiscoversAllElementTypesCorrectlyFromFixtureFiles() $this->assertCount(4, $prompts); $storyPrompt = $this->registry->getPrompt('creative_story_prompt'); - $this->assertInstanceOf(RegisteredPrompt::class, $storyPrompt); + $this->assertInstanceOf(PromptReference::class, $storyPrompt); $this->assertFalse($storyPrompt->isManual); $this->assertCount(2, $storyPrompt->prompt->arguments); $this->assertEquals(CompletionProviderFixture::class, $storyPrompt->completionProviders['genre']); $simplePrompt = $this->registry->getPrompt('simpleQuestionPrompt'); - $this->assertInstanceOf(RegisteredPrompt::class, $simplePrompt); + $this->assertInstanceOf(PromptReference::class, $simplePrompt); $this->assertFalse($simplePrompt->isManual); $invokableGreeter = $this->registry->getPrompt('InvokableGreeterPrompt'); - $this->assertInstanceOf(RegisteredPrompt::class, $invokableGreeter); + $this->assertInstanceOf(PromptReference::class, $invokableGreeter); $this->assertFalse($invokableGreeter->isManual); $this->assertEquals([InvocablePromptFixture::class, '__invoke'], $invokableGreeter->handler); $contentCreatorPrompt = $this->registry->getPrompt('content_creator'); - $this->assertInstanceOf(RegisteredPrompt::class, $contentCreatorPrompt); + $this->assertInstanceOf(PromptReference::class, $contentCreatorPrompt); $this->assertFalse($contentCreatorPrompt->isManual); $this->assertCount(3, $contentCreatorPrompt->completionProviders); @@ -109,14 +109,14 @@ public function testDiscoversAllElementTypesCorrectlyFromFixtureFiles() $this->assertCount(4, $templates); $productTemplate = $this->registry->getResourceTemplate('product://{region}/details/{productId}'); - $this->assertInstanceOf(RegisteredResourceTemplate::class, $productTemplate); + $this->assertInstanceOf(ResourceTemplateReference::class, $productTemplate); $this->assertFalse($productTemplate->isManual); $this->assertEquals('product_details_template', $productTemplate->resourceTemplate->name); $this->assertEquals(CompletionProviderFixture::class, $productTemplate->completionProviders['region']); $this->assertEqualsCanonicalizing(['region', 'productId'], $productTemplate->getVariableNames()); $invokableUserTemplate = $this->registry->getResourceTemplate('invokable://user-profile/{userId}'); - $this->assertInstanceOf(RegisteredResourceTemplate::class, $invokableUserTemplate); + $this->assertInstanceOf(ResourceTemplateReference::class, $invokableUserTemplate); $this->assertFalse($invokableUserTemplate->isManual); $this->assertEquals([InvocableResourceTemplateFixture::class, '__invoke'], $invokableUserTemplate->handler); } @@ -124,7 +124,7 @@ public function testDiscoversAllElementTypesCorrectlyFromFixtureFiles() public function testDoesNotDiscoverElementsFromExcludedDirectories() { $this->discoverer->discover(__DIR__, ['Fixtures']); - $this->assertInstanceOf(RegisteredTool::class, $this->registry->getTool('hidden_subdir_tool')); + $this->assertInstanceOf(ToolReference::class, $this->registry->getTool('hidden_subdir_tool')); $this->registry->clear(); @@ -160,7 +160,7 @@ public function testDiscoversEnhancedCompletionProvidersWithValuesAndEnumAttribu $this->discoverer->discover(__DIR__, ['Fixtures']); $contentPrompt = $this->registry->getPrompt('content_creator'); - $this->assertInstanceOf(RegisteredPrompt::class, $contentPrompt); + $this->assertInstanceOf(PromptReference::class, $contentPrompt); $this->assertCount(3, $contentPrompt->completionProviders); $typeProvider = $contentPrompt->completionProviders['type']; @@ -173,7 +173,7 @@ public function testDiscoversEnhancedCompletionProvidersWithValuesAndEnumAttribu $this->assertInstanceOf(EnumCompletionProvider::class, $priorityProvider); $contentTemplate = $this->registry->getResourceTemplate('content://{category}/{slug}'); - $this->assertInstanceOf(RegisteredResourceTemplate::class, $contentTemplate); + $this->assertInstanceOf(ResourceTemplateReference::class, $contentTemplate); $this->assertCount(1, $contentTemplate->completionProviders); $categoryProvider = $contentTemplate->completionProviders['category']; diff --git a/tests/Example/InspectorSnapshotTestCase.php b/tests/Example/InspectorSnapshotTestCase.php new file mode 100644 index 00000000..fae80d51 --- /dev/null +++ b/tests/Example/InspectorSnapshotTestCase.php @@ -0,0 +1,68 @@ +getServerScript(), $method) + )->mustRun(); + + $output = $process->getOutput(); + $snapshotFile = $this->getSnapshotFilePath($method); + + if (!file_exists($snapshotFile)) { + file_put_contents($snapshotFile, $output.\PHP_EOL); + $this->markTestIncomplete("Snapshot created at $snapshotFile, please re-run tests."); + } + + $expected = file_get_contents($snapshotFile); + + $this->assertJsonStringEqualsJsonString($expected, $output); + } + + /** + * List of methods to test. + * + * @return array + */ + abstract public static function provideMethods(): array; + + abstract protected function getServerScript(): string; + + /** + * @return array + */ + protected static function provideListMethods(): array + { + return [ + 'Prompt Listing' => ['method' => 'prompts/list'], + 'Resource Listing' => ['method' => 'resources/list'], + // 'Resource Template Listing' => ['method' => 'resources/templates/list'], + 'Tool Listing' => ['method' => 'tools/list'], + ]; + } + + private function getSnapshotFilePath(string $method): string + { + $className = substr(static::class, strrpos(static::class, '\\') + 1); + + return __DIR__.'/snapshots/'.$className.'-'.str_replace('/', '_', $method).'.json'; + } +} diff --git a/tests/Example/StdioCalculatorExampleTest.php b/tests/Example/StdioCalculatorExampleTest.php new file mode 100644 index 00000000..e9012888 --- /dev/null +++ b/tests/Example/StdioCalculatorExampleTest.php @@ -0,0 +1,30 @@ +handle($request); - - $this->assertInstanceOf(ListPromptsResult::class, $response->result); - $this->assertEquals(1, $response->getId()); - $this->assertEquals([], $response->result->prompts); - } - - public function testHandleReturnAll() - { - $item = self::createMetadataItem(); - $handler = new ListPromptsHandler(new PromptChain([$item])); - $request = new ListPromptsRequest(); - NSA::setProperty($request, 'id', 1); - $response = $handler->handle($request); - - $this->assertInstanceOf(ListPromptsResult::class, $response->result); - $this->assertCount(1, $response->result->prompts); - $this->assertNull($response->result->nextCursor); - } - - public function testHandlePagination() - { - $item = self::createMetadataItem(); - $handler = new ListPromptsHandler(new PromptChain([$item, $item]), 2); - $request = new ListPromptsRequest(); - NSA::setProperty($request, 'id', 1); - $response = $handler->handle($request); - - $this->assertInstanceOf(ListPromptsResult::class, $response->result); - $this->assertCount(2, $response->result->prompts); - $this->assertNotNull($response->result->nextCursor); - } - - private static function createMetadataItem(): MetadataInterface - { - return new class implements MetadataInterface { - public function getName(): string - { - return 'greet'; - } - - public function getDescription(): string - { - return 'Greet a person with a nice message'; - } - - public function getArguments(): array - { - return [ - [ - 'name' => 'first name', - 'description' => 'The name of the person to greet', - 'required' => false, - ], - ]; - } - }; - } -} diff --git a/tests/Server/RequestHandler/ResourceListHandlerTest.php b/tests/Server/RequestHandler/ResourceListHandlerTest.php deleted file mode 100644 index 3ac2d451..00000000 --- a/tests/Server/RequestHandler/ResourceListHandlerTest.php +++ /dev/null @@ -1,126 +0,0 @@ -getMockBuilder(CollectionInterface::class) - ->disableOriginalConstructor() - ->onlyMethods(['getMetadata']) - ->getMock(); - $collection->expects($this->once())->method('getMetadata')->willReturn([]); - - $handler = new ListResourcesHandler($collection); - $request = new ListResourcesRequest(); - NSA::setProperty($request, 'id', 1); - $response = $handler->handle($request); - - $this->assertInstanceOf(ListResourcesResult::class, $response->result); - $this->assertEquals(1, $response->id); - $this->assertEquals([], $response->result->resources); - } - - /** - * @param iterable $metadataList - */ - #[DataProvider('metadataProvider')] - public function testHandleReturnAll(iterable $metadataList) - { - $collection = $this->getMockBuilder(CollectionInterface::class) - ->disableOriginalConstructor() - ->onlyMethods(['getMetadata']) - ->getMock(); - $collection->expects($this->once())->method('getMetadata')->willReturn($metadataList); - - $handler = new ListResourcesHandler($collection); - $request = new ListResourcesRequest(); - NSA::setProperty($request, 'id', 1); - $response = $handler->handle($request); - - $this->assertInstanceOf(ListResourcesResult::class, $response->result); - $this->assertCount(1, $response->result->resources); - $this->assertNull($response->result->nextCursor); - } - - /** - * @return array> - */ - public static function metadataProvider(): array - { - $item = self::createMetadataItem(); - - return [ - 'array' => [[$item]], - 'generator' => [(function () use ($item) { yield $item; })()], - ]; - } - - public function testHandlePagination() - { - $item = self::createMetadataItem(); - $collection = $this->getMockBuilder(CollectionInterface::class) - ->disableOriginalConstructor() - ->onlyMethods(['getMetadata']) - ->getMock(); - $collection->expects($this->once())->method('getMetadata')->willReturn([$item, $item]); - - $handler = new ListResourcesHandler($collection, 2); - $request = new ListResourcesRequest(); - NSA::setProperty($request, 'id', 1); - $response = $handler->handle($request); - - $this->assertInstanceOf(ListResourcesResult::class, $response->result); - $this->assertCount(2, $response->result->resources); - $this->assertNotNull($response->result->nextCursor); - } - - private static function createMetadataItem(): MetadataInterface - { - return new class implements MetadataInterface { - public function getUri(): string - { - return 'file:///src/SomeFile.php'; - } - - public function getName(): string - { - return 'SomeFile'; - } - - public function getDescription(): string - { - return 'File src/SomeFile.php'; - } - - public function getMimeType(): string - { - return 'text/plain'; - } - - public function getSize(): int - { - return 1024; - } - }; - } -} diff --git a/tests/Server/RequestHandler/ToolListHandlerTest.php b/tests/Server/RequestHandler/ToolListHandlerTest.php deleted file mode 100644 index 72347107..00000000 --- a/tests/Server/RequestHandler/ToolListHandlerTest.php +++ /dev/null @@ -1,114 +0,0 @@ -getMockBuilder(CollectionInterface::class) - ->disableOriginalConstructor() - ->onlyMethods(['getMetadata']) - ->getMock(); - $collection->expects($this->once())->method('getMetadata')->willReturn([]); - - $handler = new ListToolsHandler($collection); - $request = new ListToolsRequest(); - NSA::setProperty($request, 'id', 1); - $response = $handler->handle($request); - - $this->assertInstanceOf(ListToolsResult::class, $response->result); - $this->assertSame(1, $response->id); - $this->assertSame([], $response->result->tools); - } - - /** - * @param iterable $metadataList - */ - #[DataProvider('metadataProvider')] - public function testHandleReturnAll(iterable $metadataList) - { - $collection = $this->getMockBuilder(CollectionInterface::class) - ->disableOriginalConstructor() - ->onlyMethods(['getMetadata']) - ->getMock(); - $collection->expects($this->once())->method('getMetadata')->willReturn($metadataList); - $handler = new ListToolsHandler($collection); - $request = new ListToolsRequest(); - NSA::setProperty($request, 'id', 1); - $response = $handler->handle($request); - - $this->assertInstanceOf(ListToolsResult::class, $response->result); - $this->assertCount(1, $response->result->tools); - $this->assertNull($response->result->nextCursor); - } - - /** - * @return array> - */ - public static function metadataProvider(): array - { - $item = self::createMetadataItem(); - - return [ - 'array' => [[$item]], - 'generator' => [(function () use ($item) { yield $item; })()], - ]; - } - - public function testHandlePagination() - { - $item = self::createMetadataItem(); - $collection = $this->getMockBuilder(CollectionInterface::class) - ->disableOriginalConstructor() - ->onlyMethods(['getMetadata']) - ->getMock(); - $collection->expects($this->once())->method('getMetadata')->willReturn([$item, $item]); - $handler = new ListToolsHandler($collection, 2); - $request = new ListToolsRequest(); - NSA::setProperty($request, 'id', 1); - $response = $handler->handle($request); - - $this->assertInstanceOf(ListToolsResult::class, $response->result); - $this->assertCount(2, $response->result->tools); - $this->assertNotNull($response->result->nextCursor); - } - - private static function createMetadataItem(): MetadataInterface - { - return new class implements MetadataInterface { - public function getName(): string - { - return 'test_tool'; - } - - public function getDescription(): string - { - return 'A test tool'; - } - - public function getInputSchema(): array - { - return ['type' => 'object']; - } - }; - } -}