diff --git a/includes/Core/McpServer.php b/includes/Core/McpServer.php index af47fe14..559ab2a0 100644 --- a/includes/Core/McpServer.php +++ b/includes/Core/McpServer.php @@ -29,14 +29,14 @@ class McpServer { * * @var \WP\MCP\Infrastructure\ErrorHandling\Contracts\McpErrorHandlerInterface */ - public McpErrorHandlerInterface $error_handler; + private McpErrorHandlerInterface $error_handler; /** * Observability handler instance. * * @var \WP\MCP\Infrastructure\Observability\Contracts\McpObservabilityHandlerInterface */ - public McpObservabilityHandlerInterface $observability_handler; + private McpObservabilityHandlerInterface $observability_handler; /** * Server ID. diff --git a/includes/Domain/Prompts/RegisterAbilityAsMcpPrompt.php b/includes/Domain/Prompts/RegisterAbilityAsMcpPrompt.php index 180a1046..f4dca60a 100644 --- a/includes/Domain/Prompts/RegisterAbilityAsMcpPrompt.php +++ b/includes/Domain/Prompts/RegisterAbilityAsMcpPrompt.php @@ -6,6 +6,8 @@ * @package McpAdapter */ +declare( strict_types=1 ); + namespace WP\MCP\Domain\Prompts; use WP\MCP\Domain\Utils\McpNameSanitizer; diff --git a/includes/Handlers/Prompts/PromptsHandler.php b/includes/Handlers/Prompts/PromptsHandler.php index 32da9560..37fe5c97 100644 --- a/includes/Handlers/Prompts/PromptsHandler.php +++ b/includes/Handlers/Prompts/PromptsHandler.php @@ -30,21 +30,21 @@ class PromptsHandler { * * @var list */ - private static $valid_content_types = array( 'text', 'image', 'audio', 'resource_link', 'resource' ); + private static array $valid_content_types = array( 'text', 'image', 'audio', 'resource_link', 'resource' ); /** * Valid role values for PromptMessage. * * @var list */ - private static $valid_roles = array( 'user', 'assistant' ); + private static array $valid_roles = array( 'user', 'assistant' ); /** * Default role for messages when not specified. * * @var string */ - private static $default_role = 'user'; + private static string $default_role = 'user'; /** * The WordPress MCP instance. @@ -85,7 +85,7 @@ public function list_prompts(): ListPromptsResult { apply_filters( 'mcp_adapter_prompts_list', $prompts, $this->mcp ), $prompts, 'mcp_adapter_prompts_list', - $this->mcp->error_handler + $this->mcp->get_error_handler() ); return ListPromptsResult::fromArray( @@ -180,7 +180,7 @@ public function get_prompt( array $params, $request_id = 0 ) { $result = apply_filters( 'mcp_adapter_prompt_get_result', $result, $arguments, $prompt_name, $mcp_prompt, $this->mcp ); if ( is_wp_error( $result ) ) { - $this->mcp->error_handler->log( + $this->mcp->get_error_handler()->log( 'Prompt execution returned WP_Error', array( 'prompt_name' => $prompt_name, @@ -194,7 +194,7 @@ public function get_prompt( array $params, $request_id = 0 ) { return $this->normalize_result_to_dto( $result, $prompt, $prompt_name ); } catch ( \Throwable $e ) { - $this->mcp->error_handler->log( + $this->mcp->get_error_handler()->log( 'Prompt execution failed', array( 'prompt_name' => $prompt_name, @@ -278,7 +278,7 @@ private function normalize_tier1_messages( foreach ( $result['messages'] as $index => $message ) { if ( ! is_array( $message ) ) { - $this->mcp->error_handler->log( + $this->mcp->get_error_handler()->log( 'Invalid message structure in prompt result, skipping', array( 'prompt_name' => $prompt_name, @@ -450,7 +450,7 @@ private function normalize_tier5_fallback( string $prompt_name ): GetPromptResult { // Log observability event for fallback normalization. - $this->mcp->observability_handler->record_event( + $this->mcp->get_observability_handler()->record_event( 'prompt_result_fallback_normalization', array( 'prompt_name' => $prompt_name, @@ -536,7 +536,7 @@ private function validate_content_type( array $content, string $prompt_name ): a // Check if type is missing. if ( null === $type || '' === $type ) { - $this->mcp->error_handler->log( + $this->mcp->get_error_handler()->log( 'Missing content type in prompt result, defaulting to text', array( 'prompt_name' => $prompt_name, @@ -554,7 +554,7 @@ private function validate_content_type( array $content, string $prompt_name ): a // Check if type is valid. if ( ! in_array( $type, self::$valid_content_types, true ) ) { - $this->mcp->error_handler->log( + $this->mcp->get_error_handler()->log( 'Invalid content type in prompt result, converting to text', array( 'prompt_name' => $prompt_name, @@ -596,7 +596,7 @@ private function validate_role( string $role, string $prompt_name ): string { } if ( '' !== $prompt_name ) { - $this->mcp->error_handler->log( + $this->mcp->get_error_handler()->log( 'Invalid role in prompt message, defaulting to user', array( 'prompt_name' => $prompt_name, diff --git a/includes/Handlers/Resources/ResourcesHandler.php b/includes/Handlers/Resources/ResourcesHandler.php index 97312d61..61e321bb 100644 --- a/includes/Handlers/Resources/ResourcesHandler.php +++ b/includes/Handlers/Resources/ResourcesHandler.php @@ -66,7 +66,7 @@ public function list_resources(): ListResourcesResult { apply_filters( 'mcp_adapter_resources_list', $resources, $this->mcp ), $resources, 'mcp_adapter_resources_list', - $this->mcp->error_handler + $this->mcp->get_error_handler() ); return ListResourcesResult::fromArray( @@ -162,7 +162,7 @@ public function read_resource( array $params, $request_id = 0 ) { // Handle WP_Error objects returned by McpResource execution. if ( is_wp_error( $contents ) ) { - $this->mcp->error_handler->log( + $this->mcp->get_error_handler()->log( 'Resource execution returned WP_Error object', array( 'uri' => $uri, @@ -186,7 +186,7 @@ public function read_resource( array $params, $request_id = 0 ) { ) ); } catch ( \Throwable $exception ) { - $this->mcp->error_handler->log( + $this->mcp->get_error_handler()->log( 'Error reading resource', array( 'uri' => $uri, diff --git a/includes/Handlers/Tools/ToolsHandler.php b/includes/Handlers/Tools/ToolsHandler.php index b9c7afa3..e3df6c64 100644 --- a/includes/Handlers/Tools/ToolsHandler.php +++ b/includes/Handlers/Tools/ToolsHandler.php @@ -88,7 +88,7 @@ public function list_tools(): ListToolsResult { apply_filters( 'mcp_adapter_tools_list', $tools, $this->mcp ), $tools, 'mcp_adapter_tools_list', - $this->mcp->error_handler + $this->mcp->get_error_handler() ); return ListToolsResult::fromArray( @@ -134,7 +134,7 @@ public function call_tool( array $params, $request_id = 0 ) { $mcp_tool = $this->mcp->get_mcp_tool( $tool_name ); if ( ! $mcp_tool ) { - $this->mcp->error_handler->log( + $this->mcp->get_error_handler()->log( 'Tool not found', array( 'tool_name' => $tool_name, @@ -151,7 +151,7 @@ public function call_tool( array $params, $request_id = 0 ) { if ( is_wp_error( $permission ) ) { $error_message = $permission->get_error_message(); - $this->mcp->error_handler->log( + $this->mcp->get_error_handler()->log( 'Tool permission check failed', array( 'tool_name' => $tool_name, @@ -205,7 +205,7 @@ public function call_tool( array $params, $request_id = 0 ) { $result = apply_filters( 'mcp_adapter_tool_call_result', $result, $args, $tool_name, $mcp_tool, $this->mcp ); if ( is_wp_error( $result ) ) { - $this->mcp->error_handler->log( + $this->mcp->get_error_handler()->log( 'Tool execution returned WP_Error', array( 'tool_name' => $tool_name, @@ -309,7 +309,7 @@ public function call_tool( array $params, $request_id = 0 ) { ) ); } catch ( \Throwable $exception ) { - $this->mcp->error_handler->log( + $this->mcp->get_error_handler()->log( 'Error calling tool', array( 'tool' => $request_params['name'], diff --git a/includes/Transport/HttpTransport.php b/includes/Transport/HttpTransport.php index f2a4db3b..36aff3cc 100644 --- a/includes/Transport/HttpTransport.php +++ b/includes/Transport/HttpTransport.php @@ -52,7 +52,7 @@ public function __construct( McpTransportContext $transport_context ) { */ public function register_routes(): void { // Get server info from request handler's transport context - $server = $this->request_handler->transport_context->mcp_server; + $server = $this->request_handler->get_transport_context()->mcp_server; // Single endpoint for MCP communication (POST, GET reserved for SSE, DELETE for session termination). // Do not remove GET: it is part of the MCP HTTP transport shape and will be implemented (SSE) in a future iteration. @@ -78,7 +78,7 @@ public function check_permission( \WP_REST_Request $request ) { $context = new HttpRequestContext( $request ); // Check permission using callback or default - $transport_context = $this->request_handler->transport_context; + $transport_context = $this->request_handler->get_transport_context(); if ( null !== $transport_context->transport_permission_callback ) { try { @@ -91,7 +91,7 @@ public function check_permission( \WP_REST_Request $request ) { } // Log the error and deny access (fail-closed) - $this->request_handler->transport_context->error_handler->log( + $this->request_handler->get_transport_context()->error_handler->log( 'Permission callback returned WP_Error: ' . $result->get_error_message(), array( 'HttpTransport::check_permission' ) ); @@ -99,7 +99,7 @@ public function check_permission( \WP_REST_Request $request ) { return false; } catch ( \Throwable $e ) { // Log the error and deny access (fail-closed) - $this->request_handler->transport_context->error_handler->log( 'Error in transport permission callback: ' . $e->getMessage(), array( 'HttpTransport::check_permission' ) ); + $this->request_handler->get_transport_context()->error_handler->log( 'Error in transport permission callback: ' . $e->getMessage(), array( 'HttpTransport::check_permission' ) ); return false; } @@ -127,7 +127,7 @@ public function check_permission( \WP_REST_Request $request ) { if ( ! $user_has_capability ) { $user_id = get_current_user_id(); - $this->request_handler->transport_context->error_handler->log( + $this->request_handler->get_transport_context()->error_handler->log( sprintf( 'Permission denied for MCP API access. User ID %d does not have capability "%s"', $user_id, $user_capability ), array( 'HttpTransport::check_permission' ) ); diff --git a/includes/Transport/Infrastructure/HttpRequestHandler.php b/includes/Transport/Infrastructure/HttpRequestHandler.php index 39130beb..6ef776d1 100644 --- a/includes/Transport/Infrastructure/HttpRequestHandler.php +++ b/includes/Transport/Infrastructure/HttpRequestHandler.php @@ -27,7 +27,7 @@ class HttpRequestHandler { * * @var \WP\MCP\Transport\Infrastructure\McpTransportContext */ - public McpTransportContext $transport_context; + private McpTransportContext $transport_context; /** * Constructor. @@ -38,6 +38,17 @@ public function __construct( McpTransportContext $transport_context ) { $this->transport_context = $transport_context; } + /** + * Get the transport context. + * + * @since n.e.x.t + * + * @return \WP\MCP\Transport\Infrastructure\McpTransportContext + */ + public function get_transport_context(): McpTransportContext { + return $this->transport_context; + } + /** * Route HTTP request to appropriate handler. * @@ -88,7 +99,7 @@ private function handle_mcp_request( HttpRequestContext $context ): \WP_REST_Res return $this->process_mcp_messages( $context ); } catch ( \Throwable $exception ) { - $this->transport_context->mcp_server->error_handler->log( + $this->transport_context->mcp_server->get_error_handler()->log( 'Unexpected error in handle_mcp_request', array( 'transport' => static::class, diff --git a/includes/Transport/Infrastructure/McpTransportContext.php b/includes/Transport/Infrastructure/McpTransportContext.php index 84b578b4..a181ffee 100644 --- a/includes/Transport/Infrastructure/McpTransportContext.php +++ b/includes/Transport/Infrastructure/McpTransportContext.php @@ -31,18 +31,32 @@ class McpTransportContext { /** - * Initialize the transport context. + * Required property keys for the constructor array. + * + * @var list + */ + // phpcs:ignore SlevomatCodingStandard.Classes.DisallowMultiConstantDefinition -- False positive: sniff mistakes array() commas for multi-const commas (only handles short syntax). + private const REQUIRED_KEYS = array( + 'mcp_server', + 'initialize_handler', + 'tools_handler', + 'resources_handler', + 'prompts_handler', + 'system_handler', + 'observability_handler', + ); + + /** + * Optional property keys for the constructor array. * - * @param \WP\MCP\Core\McpServer $mcp_server The MCP server instance. - * @param \WP\MCP\Handlers\Initialize\InitializeHandler $initialize_handler The initialize handler. - * @param \WP\MCP\Handlers\Tools\ToolsHandler $tools_handler The tools handler. - * @param \WP\MCP\Handlers\Resources\ResourcesHandler $resources_handler The resources handler. - * @param \WP\MCP\Handlers\Prompts\PromptsHandler $prompts_handler The prompts handler. - * @param \WP\MCP\Handlers\System\SystemHandler $system_handler The system handler. - * @param string $observability_handler The observability handler class name. - * @param \WP\MCP\Transport\Infrastructure\RequestRouter|null $request_router The request router service. - * @param callable|null $transport_permission_callback Optional custom permission callback for transport-level authentication. + * @var list */ + // phpcs:ignore SlevomatCodingStandard.Classes.DisallowMultiConstantDefinition -- False positive: sniff mistakes array() commas for multi-const commas (only handles short syntax). + private const OPTIONAL_KEYS = array( + 'request_router', + 'transport_permission_callback', + 'error_handler', + ); /** * The MCP server instance. @@ -128,18 +142,66 @@ class McpTransportContext { * error_handler?: \WP\MCP\Infrastructure\ErrorHandling\Contracts\McpErrorHandlerInterface * } $properties Properties to set on the context. * Note: request_router is optional and will be auto-created if not provided. + * + * @throws \InvalidArgumentException If required keys are missing or unknown keys are present. + * + * @since n.e.x.t */ public function __construct( array $properties ) { - foreach ( $properties as $name => $value ) { - $this->$name = $value; - } + $this->validate_properties( $properties ); + + // Assign required properties. + $this->mcp_server = $properties['mcp_server']; + $this->initialize_handler = $properties['initialize_handler']; + $this->tools_handler = $properties['tools_handler']; + $this->resources_handler = $properties['resources_handler']; + $this->prompts_handler = $properties['prompts_handler']; + $this->system_handler = $properties['system_handler']; + $this->observability_handler = $properties['observability_handler']; + + // Assign optional properties (error_handler defaults to the server's handler). + $this->error_handler = $properties['error_handler'] ?? $properties['mcp_server']->get_error_handler(); - // If request_router is provided, we're done - if ( isset( $properties['request_router'] ) ) { - return; + $this->transport_permission_callback = $properties['transport_permission_callback'] ?? null; + + // Create request_router if not provided. + $this->request_router = $properties['request_router'] ?? new RequestRouter( $this ); + } + + /** + * Validate that the properties array contains all required keys and no unknown keys. + * + * @param array $properties Properties to validate. + * + * @throws \InvalidArgumentException If required keys are missing or unknown keys are present. + * + * @since n.e.x.t + */ + private function validate_properties( array $properties ): void { + $provided_keys = array_keys( $properties ); + $allowed_keys = array_merge( self::REQUIRED_KEYS, self::OPTIONAL_KEYS ); + + // Check for unknown keys. + $unknown_keys = array_diff( $provided_keys, $allowed_keys ); + if ( ! empty( $unknown_keys ) ) { + throw new \InvalidArgumentException( + sprintf( + 'Unknown properties provided to McpTransportContext: %1$s. Allowed properties: %2$s.', + esc_html( implode( ', ', $unknown_keys ) ), + esc_html( implode( ', ', $allowed_keys ) ) + ) + ); } - // Create request_router if not provided - $this->request_router = new RequestRouter( $this ); + // Check for missing required keys. + $missing_keys = array_diff( self::REQUIRED_KEYS, $provided_keys ); + if ( ! empty( $missing_keys ) ) { + throw new \InvalidArgumentException( + sprintf( + 'Missing required properties for McpTransportContext: %s.', + esc_html( implode( ', ', $missing_keys ) ) + ) + ); + } } } diff --git a/tests/Integration/ErrorHandlingIntegrationTest.php b/tests/Integration/ErrorHandlingIntegrationTest.php index 11b43d54..aea5b02f 100644 --- a/tests/Integration/ErrorHandlingIntegrationTest.php +++ b/tests/Integration/ErrorHandlingIntegrationTest.php @@ -80,8 +80,8 @@ public function test_server_instantiates_error_handler_correctly(): void { DummyObservabilityHandler::class, ); - $this->assertInstanceOf( McpErrorHandlerInterface::class, $server->error_handler ); - $this->assertInstanceOf( DummyErrorHandler::class, $server->error_handler ); + $this->assertInstanceOf( McpErrorHandlerInterface::class, $server->get_error_handler() ); + $this->assertInstanceOf( DummyErrorHandler::class, $server->get_error_handler() ); } public function test_handlers_return_consistent_error_format(): void { diff --git a/tests/Unit/Handlers/ResourcesHandlerReadTest.php b/tests/Unit/Handlers/ResourcesHandlerReadTest.php index a2c034c4..9d7cf83a 100644 --- a/tests/Unit/Handlers/ResourcesHandlerReadTest.php +++ b/tests/Unit/Handlers/ResourcesHandlerReadTest.php @@ -277,4 +277,26 @@ public function test_read_resource_wraps_non_array_result_as_json(): void { // Clean up. wp_unregister_ability( 'test/resource-object-result' ); } + + public function test_read_resource_with_throwing_result_filter_triggers_catch_block(): void { + wp_set_current_user( 1 ); + $server = $this->makeServer( array(), array( 'test/resource' ) ); + $handler = new ResourcesHandler( $server ); + + $filter = static function () { + throw new \RuntimeException( 'Filter exploded' ); + }; + add_filter( 'mcp_adapter_resource_read_result', $filter ); + + $result = $handler->read_resource( + array( + 'params' => array( 'uri' => 'WordPress://local/resource-1' ), + ) + ); + + $this->assertInstanceOf( JSONRPCErrorResponse::class, $result ); + $this->assertStringContainsString( 'Failed to read resource', $result->getError()->getMessage() ); + + remove_filter( 'mcp_adapter_resource_read_result', $filter ); + } } diff --git a/tests/Unit/McpServerTest.php b/tests/Unit/McpServerTest.php index e13aa385..4877ebfc 100644 --- a/tests/Unit/McpServerTest.php +++ b/tests/Unit/McpServerTest.php @@ -46,8 +46,8 @@ public function test_constructor_properly_sets_up_error_handler(): void { NullMcpObservabilityHandler::class, ); - $this->assertInstanceOf( \WP\MCP\Infrastructure\ErrorHandling\Contracts\McpErrorHandlerInterface::class, $server->error_handler ); - $this->assertInstanceOf( \WP\MCP\Tests\Fixtures\DummyErrorHandler::class, $server->error_handler ); + $this->assertInstanceOf( \WP\MCP\Infrastructure\ErrorHandling\Contracts\McpErrorHandlerInterface::class, $server->get_error_handler() ); + $this->assertInstanceOf( \WP\MCP\Tests\Fixtures\DummyErrorHandler::class, $server->get_error_handler() ); } public function test_constructor_falls_back_to_null_error_handler(): void { @@ -63,7 +63,7 @@ public function test_constructor_falls_back_to_null_error_handler(): void { NullMcpObservabilityHandler::class, ); - $this->assertInstanceOf( NullMcpErrorHandler::class, $server->error_handler ); + $this->assertInstanceOf( NullMcpErrorHandler::class, $server->get_error_handler() ); } public function test_constructor_without_tools_does_not_register_system_tools(): void { diff --git a/tests/Unit/Servers/DefaultServerFactoryTest.php b/tests/Unit/Servers/DefaultServerFactoryTest.php index 149bd081..1ca808db 100644 --- a/tests/Unit/Servers/DefaultServerFactoryTest.php +++ b/tests/Unit/Servers/DefaultServerFactoryTest.php @@ -234,7 +234,7 @@ public function test_create_uses_default_error_handler(): void { $server = $this->adapter->get_server( 'mcp-adapter-default-server' ); $this->assertNotNull( $server ); - $this->assertInstanceOf( ErrorLogMcpErrorHandler::class, $server->error_handler ); + $this->assertInstanceOf( ErrorLogMcpErrorHandler::class, $server->get_error_handler() ); } public function test_create_uses_default_observability_handler(): void { @@ -249,7 +249,7 @@ public function test_create_uses_default_observability_handler(): void { $server = $this->adapter->get_server( 'mcp-adapter-default-server' ); $this->assertNotNull( $server ); - $this->assertInstanceOf( NullMcpObservabilityHandler::class, $server->observability_handler ); + $this->assertInstanceOf( NullMcpObservabilityHandler::class, $server->get_observability_handler() ); } } diff --git a/tests/Unit/Transport/Infrastructure/McpTransportContextTest.php b/tests/Unit/Transport/Infrastructure/McpTransportContextTest.php new file mode 100644 index 00000000..dac389d8 --- /dev/null +++ b/tests/Unit/Transport/Infrastructure/McpTransportContextTest.php @@ -0,0 +1,244 @@ +server = $this->makeServer( + array( 'test-dummy/echo-tool' ), + ); + } + + /** + * Test that providing all required keys creates a valid context. + */ + public function test_construct_with_all_required_keys_creates_context(): void { + $properties = $this->build_required_properties(); + + $context = new McpTransportContext( $properties ); + + $this->assertInstanceOf( McpTransportContext::class, $context ); + $this->assertSame( $properties['mcp_server'], $context->mcp_server ); + $this->assertSame( $properties['initialize_handler'], $context->initialize_handler ); + $this->assertSame( $properties['tools_handler'], $context->tools_handler ); + $this->assertSame( $properties['resources_handler'], $context->resources_handler ); + $this->assertSame( $properties['prompts_handler'], $context->prompts_handler ); + $this->assertSame( $properties['system_handler'], $context->system_handler ); + $this->assertSame( $properties['observability_handler'], $context->observability_handler ); + } + + /** + * Test that request_router is auto-created when not provided. + */ + public function test_construct_without_request_router_creates_router_automatically(): void { + $properties = $this->build_required_properties(); + + $context = new McpTransportContext( $properties ); + + $this->assertInstanceOf( RequestRouter::class, $context->request_router ); + } + + /** + * Test that request_router is used when explicitly provided. + */ + public function test_construct_with_request_router_uses_provided_router(): void { + $properties = $this->build_required_properties(); + // Create a context first to get a RequestRouter instance. + $temp_context = new McpTransportContext( $properties ); + $router = $temp_context->request_router; + $properties['request_router'] = $router; + + $context = new McpTransportContext( $properties ); + + $this->assertSame( $router, $context->request_router ); + } + + /** + * Test that transport_permission_callback defaults to null when not provided. + */ + public function test_construct_without_permission_callback_defaults_to_null(): void { + $properties = $this->build_required_properties(); + + $context = new McpTransportContext( $properties ); + + $this->assertNull( $context->transport_permission_callback ); + } + + /** + * Test that transport_permission_callback is assigned when provided. + */ + public function test_construct_with_permission_callback_assigns_callback(): void { + $properties = $this->build_required_properties(); + $callback = static function () { + return true; + }; + $properties['transport_permission_callback'] = $callback; + + $context = new McpTransportContext( $properties ); + + $this->assertSame( $callback, $context->transport_permission_callback ); + } + + /** + * Test that error_handler is assigned when provided. + */ + public function test_construct_with_error_handler_assigns_handler(): void { + $properties = $this->build_required_properties(); + $error_handler = new DummyErrorHandler(); + $properties['error_handler'] = $error_handler; + + $context = new McpTransportContext( $properties ); + + $this->assertSame( $error_handler, $context->error_handler ); + } + + /** + * Test that error_handler defaults to the server's error handler when omitted. + */ + public function test_construct_without_error_handler_defaults_to_server_error_handler(): void { + $properties = $this->build_required_properties(); + + $context = new McpTransportContext( $properties ); + + $this->assertSame( $this->server->get_error_handler(), $context->error_handler ); + } + + /** + * Test that missing a single required key throws InvalidArgumentException. + */ + public function test_construct_with_missing_required_key_throws_exception(): void { + $properties = $this->build_required_properties(); + unset( $properties['tools_handler'] ); + + $this->expectException( InvalidArgumentException::class ); + $this->expectExceptionMessage( 'Missing required properties for McpTransportContext: tools_handler' ); + + new McpTransportContext( $properties ); + } + + /** + * Test that missing multiple required keys lists all missing keys. + */ + public function test_construct_with_multiple_missing_required_keys_lists_all_missing(): void { + $properties = $this->build_required_properties(); + unset( $properties['mcp_server'], $properties['system_handler'] ); + + $this->expectException( InvalidArgumentException::class ); + $this->expectExceptionMessage( 'Missing required properties for McpTransportContext: mcp_server, system_handler' ); + + new McpTransportContext( $properties ); + } + + /** + * Test that providing an unknown key throws InvalidArgumentException. + */ + public function test_construct_with_unknown_key_throws_exception(): void { + $properties = $this->build_required_properties(); + $properties['typo_server'] = 'some_value'; + + $this->expectException( InvalidArgumentException::class ); + $this->expectExceptionMessage( 'Unknown properties provided to McpTransportContext: typo_server' ); + + new McpTransportContext( $properties ); + } + + /** + * Test that providing multiple unknown keys lists all unknown keys. + */ + public function test_construct_with_multiple_unknown_keys_lists_all_unknown(): void { + $properties = $this->build_required_properties(); + $properties['foo'] = 'bar'; + $properties['baz_qux'] = 'quux'; + + $this->expectException( InvalidArgumentException::class ); + $this->expectExceptionMessage( 'Unknown properties provided to McpTransportContext: foo, baz_qux' ); + + new McpTransportContext( $properties ); + } + + /** + * Test that an empty array throws InvalidArgumentException for missing required keys. + */ + public function test_construct_with_empty_array_throws_exception(): void { + $this->expectException( InvalidArgumentException::class ); + $this->expectExceptionMessage( 'Missing required properties for McpTransportContext' ); + + new McpTransportContext( array() ); + } + + /** + * Test that all optional keys can be provided alongside required keys. + */ + public function test_construct_with_all_keys_creates_context(): void { + $properties = $this->build_required_properties(); + $properties['error_handler'] = new DummyErrorHandler(); + $properties['transport_permission_callback'] = static function () { + return true; + }; + + // Create context to get a router, then rebuild with it. + $temp_context = new McpTransportContext( $properties ); + $properties['request_router'] = $temp_context->request_router; + + $context = new McpTransportContext( $properties ); + + $this->assertInstanceOf( McpTransportContext::class, $context ); + $this->assertSame( $properties['request_router'], $context->request_router ); + $this->assertSame( $properties['error_handler'], $context->error_handler ); + $this->assertSame( $properties['transport_permission_callback'], $context->transport_permission_callback ); + } + + /** + * Build the minimal set of required properties for McpTransportContext. + * + * @return array Properties array with all required keys. + */ + private function build_required_properties(): array { + return array( + 'mcp_server' => $this->server, + 'initialize_handler' => new InitializeHandler( $this->server ), + 'tools_handler' => new ToolsHandler( $this->server ), + 'resources_handler' => new ResourcesHandler( $this->server ), + 'prompts_handler' => new PromptsHandler( $this->server ), + 'system_handler' => new SystemHandler(), + 'observability_handler' => new DummyObservabilityHandler(), + ); + } +}