diff --git a/README.md b/README.md index 6ed59a009..a274c07fe 100644 --- a/README.md +++ b/README.md @@ -61,6 +61,25 @@ Our Alpine-based Docker images are perfect for CI systems, while also offering n For more detailed information on QIT and how to use it, refer to the [documentation](https://qit.woo.com/docs/). +## MCP Server + +QIT includes a read-only Model Context Protocol server for agentic tools that support stdio MCP servers. The server exposes structured reporting and debugging context for existing QIT runs; it does not start tests, change environments, upload packages, or run arbitrary CLI commands. + +Example client configuration: + +```json +{ + "mcpServers": { + "qit": { + "command": "qit", + "args": ["mcp"] + } + } +} +``` + +Available tools include run metadata, decoded result payloads, failure summaries, last local run context, running environments, and report/artifact locations. Report URLs and known secrets are redacted by default. +

Qit Quick Demo

diff --git a/src/qit-cli.php b/src/qit-cli.php index 5ff4a7109..ccf4594db 100755 --- a/src/qit-cli.php +++ b/src/qit-cli.php @@ -12,6 +12,8 @@ require_once __DIR__ . '/vendor/autoload.php'; require_once __DIR__ . '/src/helpers.php'; + $is_mcp_mode = \QIT_CLI\is_mcp_command_argv( $_SERVER['argv'] ?? [] ); + // Normalize option aliases in argv before Symfony Console processes them. // Long forms are canonical (--php_version, --wordpress_version, --woocommerce_version). // Short forms (--php, --wp, --woo) are user-friendly aliases that get normalized to long forms. @@ -77,7 +79,12 @@ // Handle CLI request. exit( $application->run() ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -} catch ( \Exception $e ) { +} catch ( \Throwable $e ) { + if ( ! empty( $is_mcp_mode ) ) { + fwrite( STDERR, $e->getMessage() . "\n" ); + exit( 1 ); + } + $io = new SymfonyStyle( App::make( \QIT_CLI\IO\Input::class ), App::make( Output::class ) ); $io->error( $e->getMessage() ); exit( 1 ); diff --git a/src/src/Commands/McpCommand.php b/src/src/Commands/McpCommand.php new file mode 100644 index 000000000..214f6dc08 --- /dev/null +++ b/src/src/Commands/McpCommand.php @@ -0,0 +1,35 @@ +server = $server; + $this->transport = $transport; + + parent::__construct( static::$defaultName ); // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase + } + + protected function configure(): void { + $this + ->setDescription( 'Start the read-only QIT MCP server over stdio.' ) + ->setHelp( 'Starts a Model Context Protocol server for structured QIT run reporting and debugging context.' ); + } + + protected function execute( InputInterface $input, OutputInterface $output ): int { + unset( $input, $output ); + + return $this->transport->run( $this->server ); + } +} diff --git a/src/src/MCP/McpServer.php b/src/src/MCP/McpServer.php new file mode 100644 index 000000000..94c94f51e --- /dev/null +++ b/src/src/MCP/McpServer.php @@ -0,0 +1,191 @@ +tools = $tools; + } + + /** + * @param array $message + * @return array|null + */ + public function handle( array $message ): ?array { + $id = $message['id'] ?? null; + $has_id = array_key_exists( 'id', $message ); + $method = $message['method'] ?? null; + $is_notification = ! $has_id; + + if ( $is_notification ) { + if ( is_string( $method ) && $method !== '' ) { + $this->handle_notification( $method ); + } + + return null; + } + + if ( ! is_string( $method ) || $method === '' ) { + return $this->error_response( $id, -32600, 'Invalid Request' ); + } + + switch ( $method ) { + case 'initialize': + return $this->success_response( $id, [ + 'protocolVersion' => '2025-06-18', + 'capabilities' => [ + 'tools' => new \stdClass(), + ], + 'serverInfo' => [ + 'name' => 'qit', + 'title' => 'Quality Insights Toolkit', + 'version' => App::getVar( 'CLI_VERSION', 'dev' ), + ], + ] ); + case 'ping': + return $this->success_response( $id, new \stdClass() ); + case 'tools/list': + return $this->success_response( $id, [ + 'tools' => $this->tools->list_tools(), + ] ); + case 'tools/call': + return $this->handle_tool_call( $id, $message['params'] ?? [] ); + case 'shutdown': + $this->should_exit = true; + return $this->success_response( $id, null ); + default: + return $this->error_response( $id, -32601, 'Method not found', [ + 'method' => $method, + ] ); + } + } + + public function should_exit(): bool { + return $this->should_exit; + } + + /** + * @param mixed $id + * @param mixed $result + * @return array + */ + private function success_response( $id, $result ): array { + return [ + 'jsonrpc' => '2.0', + 'id' => $id, + 'result' => $result, + ]; + } + + /** + * @param mixed $id + * @param array $params + * @return array + */ + private function handle_tool_call( $id, array $params ): array { + $name = $params['name'] ?? null; + $arguments = $params['arguments'] ?? []; + + if ( ! is_string( $name ) || $name === '' ) { + return $this->error_response( $id, -32602, 'Invalid params', [ + 'message' => 'tools/call requires a string params.name.', + ] ); + } + + if ( ! is_array( $arguments ) ) { + return $this->error_response( $id, -32602, 'Invalid params', [ + 'message' => 'tools/call params.arguments must be an object.', + ] ); + } + + try { + $result = $this->tools->call( $name, $arguments ); + + return $this->success_response( $id, [ + 'content' => [ + [ + 'type' => 'text', + 'text' => $this->encode_json_for_text( $result ), + ], + ], + 'structuredContent' => $result, + ] ); + } catch ( McpToolException $e ) { + $payload = [ + 'error' => $e->getMessage(), + 'details' => $e->get_details(), + ]; + + return $this->success_response( $id, [ + 'isError' => true, + 'content' => [ + [ + 'type' => 'text', + 'text' => $this->encode_json_for_text( $payload ), + ], + ], + ] ); + } catch ( \Throwable $e ) { + $payload = [ + 'error' => $e->getMessage(), + ]; + + return $this->success_response( $id, [ + 'isError' => true, + 'content' => [ + [ + 'type' => 'text', + 'text' => $this->encode_json_for_text( $payload ), + ], + ], + ] ); + } + } + + private function handle_notification( string $method ): void { + if ( $method === 'exit' ) { + $this->should_exit = true; + } + } + + /** + * @param mixed $id + * @param int $code + * @param string $message + * @param array $data + * @return array + */ + public function error_response( $id, int $code, string $message, array $data = [] ): array { + $error = [ + 'code' => $code, + 'message' => $message, + ]; + + if ( ! empty( $data ) ) { + $error['data'] = $data; + } + + return [ + 'jsonrpc' => '2.0', + 'id' => $id, + 'error' => $error, + ]; + } + + /** + * @param mixed $value + */ + private function encode_json_for_text( $value ): string { + $json = json_encode( + $value, + JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_INVALID_UTF8_SUBSTITUTE | JSON_PARTIAL_OUTPUT_ON_ERROR + ); + + return is_string( $json ) ? $json : '{}'; + } +} diff --git a/src/src/MCP/McpToolException.php b/src/src/MCP/McpToolException.php new file mode 100644 index 000000000..8f2b9e7cd --- /dev/null +++ b/src/src/MCP/McpToolException.php @@ -0,0 +1,24 @@ + */ + private array $details; + + /** + * @param string $message + * @param array $details + */ + public function __construct( string $message, array $details = [], int $code = 0, ?\Throwable $previous = null ) { + parent::__construct( $message, $code, $previous ); + $this->details = $details; + } + + /** + * @return array + */ + public function get_details(): array { + return $this->details; + } +} diff --git a/src/src/MCP/QitReportingService.php b/src/src/MCP/QitReportingService.php new file mode 100644 index 000000000..2b1d5f303 --- /dev/null +++ b/src/src/MCP/QitReportingService.php @@ -0,0 +1,717 @@ +environment_monitor = $environment_monitor; + $this->auth = $auth; + } + + /** + * @return array + */ + public function get_run( int $test_run_id, bool $include_sensitive_urls = false ): array { + $run = $this->fetch_run( $test_run_id ); + + return $this->normalize_run( $run, $include_sensitive_urls ); + } + + /** + * @return array + */ + public function get_results( int $test_run_id, string $format = 'auto' ): array { + if ( ! in_array( $format, [ 'auto', 'ctrf', 'legacy' ], true ) ) { + throw new McpToolException( 'Invalid results format.', [ + 'format' => $format, + 'allowed' => [ 'auto', 'ctrf', 'legacy' ], + ] ); + } + + $run = $this->fetch_run( $test_run_id ); + + if ( $format !== 'legacy' && ! empty( $run['ctrf_json'] ) && is_array( $run['ctrf_json'] ) ) { + return [ + 'test_run_id' => $test_run_id, + 'source' => 'ctrf_json', + 'results' => $this->redact_value( $run['ctrf_json'], false ), + ]; + } + + if ( $format !== 'ctrf' && ! empty( $run['test_result_json'] ) && is_array( $run['test_result_json'] ) ) { + return [ + 'test_run_id' => $test_run_id, + 'source' => 'test_result_json', + 'results' => $this->redact_value( $run['test_result_json'], false ), + ]; + } + + throw new McpToolException( 'No test results are available for this run.', [ + 'test_run_id' => $test_run_id, + 'format' => $format, + 'status' => $run['status'] ?? null, + ] ); + } + + /** + * @return array + */ + public function get_failures( int $test_run_id, bool $include_debug_log = true, int $max_debug_log_lines = 100 ): array { + if ( $max_debug_log_lines < 0 ) { + throw new McpToolException( 'max_debug_log_lines must be zero or greater.' ); + } + + $run = $this->fetch_run( $test_run_id ); + $failures = $this->extract_failures( $run ); + $debug_signals = $include_debug_log ? $this->extract_debug_signals( $run['debug_log'] ?? null, $max_debug_log_lines ) : []; + $next_steps = $this->build_next_steps( $run, $failures, $debug_signals ); + $result_url = isset( $run['test_results_manager_url'] ) ? $this->redact_url( (string) $run['test_results_manager_url'], false ) : null; + + return [ + 'test_run_id' => $test_run_id, + 'status' => $run['status'] ?? null, + 'update_complete' => $run['update_complete'] ?? null, + 'test_type' => $run['test_type'] ?? null, + 'summary' => $run['test_summary'] ?? null, + 'result_url' => $result_url, + 'failures' => $this->redact_value( $failures, false ), + 'debug_signals' => $this->redact_value( $debug_signals, false ), + 'next_steps' => $next_steps, + ]; + } + + /** + * @return array + */ + public function get_last_local_run_context( bool $include_sensitive_urls = false ): array { + $path = $this->last_run_path(); + + if ( ! file_exists( $path ) ) { + return [ + 'found' => false, + 'path' => $path, + ]; + } + + $data = $this->read_json_file( $path ); + + return [ + 'found' => true, + 'path' => $path, + 'context' => $this->redact_value( $data, $include_sensitive_urls ), + ]; + } + + /** + * @return array + */ + public function list_environments( ?string $env_id = null ): array { + $running = $this->environment_monitor->get(); + + if ( $env_id !== null ) { + if ( ! isset( $running[ $env_id ] ) ) { + throw new McpToolException( 'Environment not found.', [ + 'env_id' => $env_id, + ] ); + } + $running = [ $env_id => $running[ $env_id ] ]; + } + + return [ + 'environments' => array_values( $this->redact_value( $running, false ) ), + ]; + } + + /** + * @return array + */ + public function get_artifacts( ?int $test_run_id, ?string $source, bool $include_sensitive_urls = false ): array { + if ( $source !== null && ! in_array( $source, [ 'last_local_run', 'manager_run' ], true ) ) { + throw new McpToolException( 'Invalid artifact source.', [ + 'source' => $source, + 'allowed' => [ 'last_local_run', 'manager_run' ], + ] ); + } + + if ( $test_run_id !== null && $test_run_id > 0 ) { + if ( $source === 'last_local_run' ) { + throw new McpToolException( 'Conflicting artifact arguments.', [ + 'test_run_id' => $test_run_id, + 'source' => $source, + 'message' => 'source=last_local_run cannot be combined with test_run_id. Omit test_run_id or use source=manager_run.', + ] ); + } + + $source = 'manager_run'; + } + + if ( $source === null ) { + $source = 'last_local_run'; + } + + if ( $source === 'manager_run' ) { + if ( $test_run_id === null || $test_run_id <= 0 ) { + throw new McpToolException( 'test_run_id is required when source is manager_run.' ); + } + + $run = $this->fetch_run( $test_run_id ); + + return [ + 'source' => 'manager_run', + 'test_run_id' => $test_run_id, + 'artifacts' => $this->redact_value( $this->collect_run_artifacts( $run ), $include_sensitive_urls ), + ]; + } + + $context = $this->get_last_local_run_context( $include_sensitive_urls ); + + if ( empty( $context['found'] ) ) { + return [ + 'source' => 'last_local_run', + 'found' => false, + 'artifacts' => [], + ]; + } + + $local_context = is_array( $context['context'] ) ? $context['context'] : []; + + return [ + 'source' => 'last_local_run', + 'found' => true, + 'artifacts' => $this->collect_local_artifacts( $local_context ), + ]; + } + + /** + * @return array + */ + private function fetch_run( int $test_run_id ): array { + if ( $test_run_id <= 0 ) { + throw new McpToolException( 'test_run_id must be a positive integer.' ); + } + + try { + $json = ( new RequestBuilder( get_manager_url() . '/wp-json/cd/v1/get-single' ) ) + ->with_method( 'POST' ) + ->with_post_body( [ + 'test_run_id' => $test_run_id, + ] ) + ->with_retry( 3 ) + ->request(); + } catch ( \Throwable $e ) { + throw new McpToolException( 'Unable to fetch QIT test run.', [ + 'test_run_id' => $test_run_id, + 'message' => $e->getMessage(), + ], 0, $e ); + } + + $data = json_decode( $json, true ); + if ( ! is_array( $data ) ) { + throw new McpToolException( 'Manager returned invalid JSON for test run.', [ + 'test_run_id' => $test_run_id, + 'json_error' => json_last_error_msg(), + ] ); + } + + foreach ( [ 'ctrf_json', 'test_result_json', 'debug_log' ] as $field ) { + if ( array_key_exists( $field, $data ) ) { + $data[ $field ] = $this->decode_json_value( $data[ $field ] ); + } + } + + return $data; + } + + /** + * @param array $run + * @return array + */ + private function normalize_run( array $run, bool $include_sensitive_urls ): array { + return [ + 'test_run_id' => $run['test_run_id'] ?? $run['run_id'] ?? null, + 'run_id' => $run['run_id'] ?? null, + 'status' => $run['status'] ?? null, + 'update_complete' => $run['update_complete'] ?? null, + 'test_type' => $run['test_type'] ?? null, + 'test_type_display' => $run['test_type_display'] ?? null, + 'versions' => [ + 'wordpress' => $run['wordpress_version'] ?? null, + 'woocommerce' => $run['woocommerce_version'] ?? null, + 'php' => $run['php_version'] ?? null, + ], + 'extension' => $this->redact_value( $run['woo_extension'] ?? null, $include_sensitive_urls ), + 'summary' => $run['test_summary'] ?? null, + 'result_url' => isset( $run['test_results_manager_url'] ) ? $this->redact_url( (string) $run['test_results_manager_url'], $include_sensitive_urls ) : null, + 'created_at' => $run['created_at'] ?? null, + 'results' => [ + 'ctrf_json' => $this->redact_value( $run['ctrf_json'] ?? null, $include_sensitive_urls ), + 'test_result_json' => $this->redact_value( $run['test_result_json'] ?? null, $include_sensitive_urls ), + 'debug_log' => $this->redact_value( $run['debug_log'] ?? null, $include_sensitive_urls ), + ], + 'artifacts' => $this->redact_value( $this->collect_run_artifacts( $run ), $include_sensitive_urls ), + ]; + } + + /** + * @param array $run + * @return array> + */ + private function extract_failures( array $run ): array { + $failures = []; + + if ( ! empty( $run['ctrf_json'] ) && is_array( $run['ctrf_json'] ) ) { + $failures = array_merge( $failures, $this->extract_ctrf_failures( $run['ctrf_json'] ) ); + } + + if ( empty( $failures ) && ! empty( $run['test_result_json'] ) && is_array( $run['test_result_json'] ) ) { + $failures = array_merge( $failures, $this->extract_legacy_failures( $run['test_result_json'] ) ); + } + + return $failures; + } + + /** + * @param array $ctrf + * @return array> + */ + private function extract_ctrf_failures( array $ctrf ): array { + $tests = $ctrf['results']['tests'] ?? []; + if ( ! is_array( $tests ) ) { + return []; + } + + $failures = []; + foreach ( $tests as $test ) { + if ( ! is_array( $test ) ) { + continue; + } + + $status = strtolower( (string) ( $test['status'] ?? '' ) ); + if ( ! in_array( $status, [ 'failed', 'failure', 'error', 'timedout', 'timed_out' ], true ) ) { + continue; + } + + $failures[] = [ + 'source' => 'ctrf', + 'name' => $test['name'] ?? null, + 'status' => $test['status'] ?? null, + 'message' => $test['message'] ?? $test['reason'] ?? null, + 'trace' => $test['trace'] ?? null, + 'file' => $test['filePath'] ?? $test['file'] ?? null, + 'line' => $test['line'] ?? null, + 'duration' => $test['duration'] ?? null, + ]; + } + + return $failures; + } + + /** + * @param array $results + * @return array> + */ + private function extract_legacy_failures( array $results ): array { + $failures = []; + + if ( isset( $results['files'] ) && is_array( $results['files'] ) ) { + foreach ( $results['files'] as $file => $file_result ) { + if ( ! is_array( $file_result ) || empty( $file_result['messages'] ) || ! is_array( $file_result['messages'] ) ) { + continue; + } + + foreach ( $file_result['messages'] as $message ) { + if ( ! is_array( $message ) ) { + continue; + } + + $failures[] = [ + 'source' => 'legacy_files', + 'file' => $file, + 'line' => $message['line'] ?? null, + 'column' => $message['column'] ?? null, + 'status' => strtolower( (string) ( $message['type'] ?? 'warning' ) ), + 'message' => $message['message'] ?? null, + 'rule' => $message['source'] ?? null, + 'severity' => $message['severity'] ?? null, + ]; + } + } + } + + if ( isset( $results['testResults'] ) && is_array( $results['testResults'] ) ) { + foreach ( $results['testResults'] as $result ) { + if ( ! is_array( $result ) || empty( $result['tests'] ) || ! is_array( $result['tests'] ) ) { + continue; + } + + foreach ( $result['tests'] as $suite => $tests ) { + if ( ! is_array( $tests ) ) { + continue; + } + + foreach ( $tests as $test ) { + if ( ! is_array( $test ) ) { + continue; + } + + $status = strtolower( (string) ( $test['status'] ?? '' ) ); + if ( ! in_array( $status, [ 'failed', 'failure', 'error', 'timedout', 'timed_out' ], true ) ) { + continue; + } + + $failures[] = [ + 'source' => 'legacy_test_results', + 'suite' => is_string( $suite ) ? $suite : null, + 'name' => $test['title'] ?? $test['name'] ?? null, + 'status' => $test['status'] ?? null, + 'message' => $test['message'] ?? $test['error'] ?? null, + 'trace' => $test['trace'] ?? null, + ]; + } + } + } + } + + return $failures; + } + + /** + * @param mixed $debug_log + * @return array + */ + private function extract_debug_signals( $debug_log, int $max_lines ): array { + $lines = $this->debug_log_to_lines( $debug_log ); + + if ( $max_lines === 0 ) { + return [ + 'total_lines' => count( $lines ), + 'matching_lines' => 0, + 'lines' => [], + ]; + } + + $hits = []; + + foreach ( $lines as $line ) { + if ( preg_match( '/fatal error|parse error|warning|notice|deprecated|uncaught|exception/i', $line ) ) { + $hits[] = $line; + } + } + + $matching_lines = count( $hits ); + + if ( $max_lines > 0 && $matching_lines > $max_lines ) { + $hits = array_slice( $hits, -1 * $max_lines ); + } + + return [ + 'total_lines' => count( $lines ), + 'matching_lines' => $matching_lines, + 'lines' => $hits, + ]; + } + + /** + * @param mixed $debug_log + * @return array + */ + private function debug_log_to_lines( $debug_log ): array { + if ( empty( $debug_log ) ) { + return []; + } + + if ( is_array( $debug_log ) ) { + $lines = []; + foreach ( $debug_log as $key => $value ) { + if ( $key === 'debug_log' ) { + $lines = array_merge( $lines, $this->debug_log_to_lines( $value ) ); + continue; + } + if ( is_array( $value ) || is_object( $value ) ) { + $encoded = json_encode( $value, JSON_UNESCAPED_SLASHES ); + if ( is_string( $encoded ) ) { + $lines[] = $encoded; + } + } elseif ( is_scalar( $value ) ) { + $lines[] = (string) $value; + } + } + + return array_values( array_filter( $lines, function ( string $line ): bool { + return $line !== ''; + } ) ); + } + + if ( ! is_string( $debug_log ) ) { + return []; + } + + $decoded = json_decode( $debug_log, true ); + if ( json_last_error() === JSON_ERROR_NONE && is_array( $decoded ) ) { + return $this->debug_log_to_lines( $decoded ); + } + + return preg_split( '/\r\n|\r|\n/', $debug_log ) ?: []; + } + + /** + * @param array $run + * @param array> $failures + * @param array $debug_signals + * @return array + */ + private function build_next_steps( array $run, array $failures, array $debug_signals ): array { + $steps = []; + + if ( ( $run['update_complete'] ?? null ) !== true ) { + $steps[] = 'The run does not appear to be complete yet. Re-check the run after QIT finishes updating results.'; + } + + if ( ! empty( $failures ) ) { + $steps[] = 'Start with the listed failed/error test entries; they are the most direct signals from the result payload.'; + } + + if ( ! empty( $debug_signals['lines'] ) ) { + $steps[] = 'Inspect the debug log signals, especially fatal errors or uncaught exceptions, before chasing downstream test assertions.'; + } + + if ( ! empty( $run['test_results_manager_url'] ) ) { + $steps[] = 'Open the QIT report URL for screenshots, traces, and full logs if the structured payload is not enough.'; + } + + if ( empty( $steps ) ) { + $steps[] = 'No obvious failed tests or debug log signals were found in the available payload. Inspect the full report for runner-level issues or missing artifacts.'; + } + + return $steps; + } + + /** + * @param array $run + * @return array> + */ + private function collect_run_artifacts( array $run ): array { + $artifacts = []; + + foreach ( [ + 'test_results_manager_url' => 'qit_report', + 'test_result_aws_url' => 'remote_result', + ] as $field => $type ) { + if ( ! empty( $run[ $field ] ) && is_string( $run[ $field ] ) ) { + $artifacts[] = [ + 'type' => $type, + 'url' => $run[ $field ], + 'field' => $field, + ]; + } + } + + if ( ! empty( $run['test_media'] ) && is_array( $run['test_media'] ) ) { + foreach ( $run['test_media'] as $media ) { + $artifacts[] = [ + 'type' => 'test_media', + 'value' => $media, + ]; + } + } + + return $artifacts; + } + + /** + * @param array $context + * @return array> + */ + private function collect_local_artifacts( array $context ): array { + $artifacts = []; + + if ( ! empty( $context['remote_report'] ) && is_string( $context['remote_report'] ) ) { + $artifacts[] = [ + 'type' => 'remote_report', + 'url' => $context['remote_report'], + ]; + } + + if ( ! empty( $context['artifacts']['reports'] ) && is_array( $context['artifacts']['reports'] ) ) { + foreach ( $context['artifacts']['reports'] as $report ) { + if ( is_array( $report ) ) { + $artifacts[] = $report; + } + } + } + + return $artifacts; + } + + /** + * @param mixed $value + * @return mixed + */ + private function decode_json_value( $value ) { + if ( ! is_string( $value ) || trim( $value ) === '' ) { + return $value; + } + + $decoded = json_decode( $value, true ); + if ( json_last_error() === JSON_ERROR_NONE ) { + return $decoded; + } + + $base64 = base64_decode( $value, true ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_decode -- QIT result payloads may be gzipped/base64 encoded. + if ( is_string( $base64 ) ) { + $uncompressed = @gzuncompress( $base64 ); + if ( is_string( $uncompressed ) ) { + $decoded = json_decode( $uncompressed, true ); + if ( json_last_error() === JSON_ERROR_NONE ) { + return $decoded; + } + } + + $decoded = json_decode( $base64, true ); + if ( json_last_error() === JSON_ERROR_NONE ) { + return $decoded; + } + } + + return $value; + } + + private function last_run_path(): string { + return rtrim( Config::get_qit_dir(), '/' ) . '/last-run.json'; + } + + /** + * @return array + */ + private function read_json_file( string $path ): array { + $contents = file_get_contents( $path ); + if ( $contents === false ) { + throw new McpToolException( 'Unable to read JSON file.', [ + 'path' => $path, + ] ); + } + + $data = json_decode( $contents, true ); + if ( ! is_array( $data ) ) { + throw new McpToolException( 'JSON file is malformed.', [ + 'path' => $path, + 'json_error' => json_last_error_msg(), + ] ); + } + + return $data; + } + + /** + * @param mixed $value + * @return mixed + */ + private function redact_value( $value, bool $include_sensitive_urls ) { + if ( is_array( $value ) ) { + $redacted = []; + foreach ( $value as $key => $child ) { + if ( $this->is_sensitive_key( (string) $key ) ) { + $redacted[ $key ] = '[REDACTED]'; + continue; + } + $redacted[ $key ] = $this->redact_value( $child, $include_sensitive_urls ); + } + + return $redacted; + } + + if ( is_object( $value ) ) { + if ( $value instanceof \JsonSerializable ) { + return $this->redact_value( $value->jsonSerialize(), $include_sensitive_urls ); + } + + return $this->redact_value( get_object_vars( $value ), $include_sensitive_urls ); + } + + if ( is_string( $value ) ) { + $value = $this->redact_known_secrets( $value ); + + return $this->redact_urls_in_text( $value, $include_sensitive_urls ); + } + + return $value; + } + + private function is_sensitive_key( string $key ): bool { + return preg_match( '/secret|token|password|app_pass|manager_secret|partner_app_pass|authorization/i', $key ) === 1; + } + + private function redact_known_secrets( string $value ): string { + $secrets = []; + + $manager_secret = $this->auth->get_manager_secret(); + if ( is_string( $manager_secret ) && $manager_secret !== '' ) { + $secrets[] = $manager_secret; + } + + $partner_auth = $this->auth->get_partner_auth(); + if ( is_string( $partner_auth ) && $partner_auth !== '' ) { + $secrets[] = $partner_auth; + $decoded = base64_decode( $partner_auth, true ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_decode -- Partner auth is base64 user:token and must be redacted when present. + if ( is_string( $decoded ) && $decoded !== '' ) { + $secrets[] = $decoded; + } + } + + foreach ( $secrets as $secret ) { + $value = str_replace( $secret, '[REDACTED]', $value ); + $value = str_replace( rawurlencode( $secret ), '[REDACTED]', $value ); + } + + return $value; + } + + private function redact_urls_in_text( string $value, bool $include_sensitive_urls ): string { + $redacted = preg_replace_callback( + '#https?://\S+#i', + function ( array $matches ) use ( $include_sensitive_urls ): string { + return $this->redact_url( $matches[0], $include_sensitive_urls ); + }, + $value + ); + + return is_string( $redacted ) ? $redacted : $value; + } + + private function redact_url( string $url, bool $include_sensitive_urls ): string { + if ( $include_sensitive_urls ) { + return $this->redact_known_secrets( $url ); + } + + $parts = parse_url( $url ); + if ( ! is_array( $parts ) || empty( $parts['scheme'] ) || empty( $parts['host'] ) ) { + return $this->redact_known_secrets( $url ); + } + + $path = $parts['path'] ?? ''; + if ( strpos( $path, '/results/' ) !== false ) { + $path = preg_replace( '#/results/[^/]+#', '/results/[REDACTED]', $path, 1 ) ?? $path; + } + + $redacted = $parts['scheme'] . '://' . $parts['host']; + if ( isset( $parts['port'] ) ) { + $redacted .= ':' . $parts['port']; + } + $redacted .= $path; + + if ( isset( $parts['query'] ) ) { + $redacted .= '?[REDACTED]'; + } + + return $redacted; + } +} diff --git a/src/src/MCP/StdioTransport.php b/src/src/MCP/StdioTransport.php new file mode 100644 index 000000000..346e012dd --- /dev/null +++ b/src/src/MCP/StdioTransport.php @@ -0,0 +1,108 @@ +input = $input ?? STDIN; + $this->output = $output ?? STDOUT; + $this->error_output = $error_output ?? STDERR; + } + + public function run( McpServer $server ): int { + while ( ! feof( $this->input ) ) { + $line = fgets( $this->input ); + + if ( $line === false ) { + break; + } + + $line = trim( $line ); + if ( $line === '' ) { + continue; + } + + $message = json_decode( $line, true ); + if ( json_last_error() !== JSON_ERROR_NONE || ! is_array( $message ) ) { + $this->write( $server->error_response( null, -32700, 'Parse error', [ + 'json_error' => json_last_error_msg(), + ] ) ); + continue; + } + + try { + $response = $server->handle( $message ); + if ( is_array( $response ) ) { + $this->write( $response ); + } + } catch ( \Throwable $e ) { + $this->write_error( $e->getMessage() ); + $this->write( $server->error_response( $message['id'] ?? null, -32603, 'Internal error', [ + 'message' => $e->getMessage(), + ] ) ); + } + + if ( $server->should_exit() ) { + break; + } + } + + return 0; + } + + /** + * @param array $message + */ + private function write( array $message ): void { + $json = json_encode( $message, self::JSON_ENCODE_FLAGS ); + + if ( ! is_string( $json ) ) { + $this->write_error( sprintf( 'Failed to encode MCP response: %s', json_last_error_msg() ) ); + + $id = $message['id'] ?? null; + if ( ! is_int( $id ) && ! is_string( $id ) && $id !== null ) { + $id = null; + } + + $json = json_encode( [ + 'jsonrpc' => '2.0', + 'id' => $id, + 'error' => [ + 'code' => -32603, + 'message' => 'Internal error', + 'data' => [ + 'message' => 'Failed to encode MCP response.', + ], + ], + ], self::JSON_ENCODE_FLAGS ); + } + + if ( ! is_string( $json ) ) { + $json = '{"jsonrpc":"2.0","id":null,"error":{"code":-32603,"message":"Internal error"}}'; + } + + fwrite( $this->output, $json . "\n" ); + } + + private function write_error( string $message ): void { + fwrite( $this->error_output, $message . "\n" ); + } +} diff --git a/src/src/MCP/ToolRegistry.php b/src/src/MCP/ToolRegistry.php new file mode 100644 index 000000000..b5f3c116a --- /dev/null +++ b/src/src/MCP/ToolRegistry.php @@ -0,0 +1,218 @@ +reporting = $reporting; + } + + /** + * @return array> + */ + public function list_tools(): array { + return [ + $this->tool( 'qit_get_run', 'Get normalized QIT test run metadata and decoded result fields.', [ + 'test_run_id' => [ + 'type' => 'integer', + 'minimum' => 1, + ], + 'include_sensitive_urls' => [ + 'type' => 'boolean', + 'default' => false, + ], + ], [ 'test_run_id' ] ), + $this->tool( 'qit_get_results', 'Get CTRF results when available, otherwise legacy QIT result JSON.', [ + 'test_run_id' => [ + 'type' => 'integer', + 'minimum' => 1, + ], + 'format' => [ + 'type' => 'string', + 'enum' => [ 'auto', 'ctrf', 'legacy' ], + 'default' => 'auto', + ], + ], [ 'test_run_id' ] ), + $this->tool( 'qit_get_failures', 'Summarize failed/error tests, debug signals, and next inspection steps for a QIT run.', [ + 'test_run_id' => [ + 'type' => 'integer', + 'minimum' => 1, + ], + 'include_debug_log' => [ + 'type' => 'boolean', + 'default' => true, + ], + 'max_debug_log_lines' => [ + 'type' => 'integer', + 'minimum' => 0, + 'maximum' => 1000, + 'default' => 100, + ], + ], [ 'test_run_id' ] ), + $this->tool( 'qit_get_last_local_run_context', 'Read the last local QIT run context from last-run.json.', [ + 'include_sensitive_urls' => [ + 'type' => 'boolean', + 'default' => false, + ], + ], [] ), + $this->tool( 'qit_list_environments', 'List running local QIT environments.', [ + 'env_id' => [ + 'type' => 'string', + ], + ], [] ), + $this->tool( 'qit_get_artifacts', 'List known QIT report URLs and local artifact paths for a run or the last local run.', [ + 'test_run_id' => [ + 'type' => 'integer', + 'minimum' => 1, + ], + 'source' => [ + 'type' => 'string', + 'enum' => [ 'last_local_run', 'manager_run' ], + ], + 'include_sensitive_urls' => [ + 'type' => 'boolean', + 'default' => false, + ], + ], [] ), + ]; + } + + /** + * @param string $name + * @param array $arguments + * @return array + */ + public function call( string $name, array $arguments ): array { + switch ( $name ) { + case 'qit_get_run': + return $this->reporting->get_run( + $this->required_int( $arguments, 'test_run_id' ), + $this->bool_arg( $arguments, 'include_sensitive_urls', false ) + ); + case 'qit_get_results': + return $this->reporting->get_results( + $this->required_int( $arguments, 'test_run_id' ), + $this->string_arg( $arguments, 'format', 'auto' ) + ); + case 'qit_get_failures': + return $this->reporting->get_failures( + $this->required_int( $arguments, 'test_run_id' ), + $this->bool_arg( $arguments, 'include_debug_log', true ), + $this->int_arg( $arguments, 'max_debug_log_lines', 100 ) + ); + case 'qit_get_last_local_run_context': + return $this->reporting->get_last_local_run_context( + $this->bool_arg( $arguments, 'include_sensitive_urls', false ) + ); + case 'qit_list_environments': + return $this->reporting->list_environments( + $this->nullable_string_arg( $arguments, 'env_id' ) + ); + case 'qit_get_artifacts': + return $this->reporting->get_artifacts( + isset( $arguments['test_run_id'] ) ? $this->int_arg( $arguments, 'test_run_id', 0 ) : null, + $this->nullable_string_arg( $arguments, 'source' ), + $this->bool_arg( $arguments, 'include_sensitive_urls', false ) + ); + default: + throw new McpToolException( sprintf( 'Unknown tool "%s".', $name ) ); + } + } + + /** + * @param string $name + * @param string $description + * @param array $properties + * @param array $required + * @return array + */ + private function tool( string $name, string $description, array $properties, array $required ): array { + return [ + 'name' => $name, + 'description' => $description, + 'inputSchema' => [ + 'type' => 'object', + 'properties' => $properties, + 'required' => $required, + 'additionalProperties' => false, + ], + ]; + } + + /** + * @param array $arguments + */ + private function required_int( array $arguments, string $key ): int { + if ( ! isset( $arguments[ $key ] ) ) { + throw new McpToolException( sprintf( 'Missing required argument "%s".', $key ) ); + } + + return $this->int_arg( $arguments, $key, 0 ); + } + + /** + * @param array $arguments + */ + private function int_arg( array $arguments, string $key, int $default_value ): int { + if ( ! isset( $arguments[ $key ] ) ) { + return $default_value; + } + + if ( is_int( $arguments[ $key ] ) ) { + return $arguments[ $key ]; + } + + if ( is_numeric( $arguments[ $key ] ) && intval( $arguments[ $key ] ) == $arguments[ $key ] ) { // phpcs:ignore WordPress.PHP.StrictComparisons.LooseComparison,Universal.Operators.StrictComparisons.LooseEqual + return (int) $arguments[ $key ]; + } + + throw new McpToolException( sprintf( 'Argument "%s" must be an integer.', $key ) ); + } + + /** + * @param array $arguments + */ + private function bool_arg( array $arguments, string $key, bool $default_value ): bool { + if ( ! isset( $arguments[ $key ] ) ) { + return $default_value; + } + + if ( is_bool( $arguments[ $key ] ) ) { + return $arguments[ $key ]; + } + + throw new McpToolException( sprintf( 'Argument "%s" must be a boolean.', $key ) ); + } + + /** + * @param array $arguments + */ + private function string_arg( array $arguments, string $key, string $default_value ): string { + if ( ! isset( $arguments[ $key ] ) ) { + return $default_value; + } + + if ( is_string( $arguments[ $key ] ) ) { + return $arguments[ $key ]; + } + + throw new McpToolException( sprintf( 'Argument "%s" must be a string.', $key ) ); + } + + /** + * @param array $arguments + */ + private function nullable_string_arg( array $arguments, string $key ): ?string { + if ( ! array_key_exists( $key, $arguments ) || $arguments[ $key ] === null || $arguments[ $key ] === '' ) { + return null; + } + + if ( is_string( $arguments[ $key ] ) ) { + return $arguments[ $key ]; + } + + throw new McpToolException( sprintf( 'Argument "%s" must be a string.', $key ) ); + } +} diff --git a/src/src/bootstrap.php b/src/src/bootstrap.php index 9708f8bd2..06d184de8 100644 --- a/src/src/bootstrap.php +++ b/src/src/bootstrap.php @@ -27,6 +27,7 @@ use QIT_CLI\Commands\Group\GroupFetchCommand; use QIT_CLI\Commands\Group\RunGroupCommand; use QIT_CLI\Commands\ListCommand; +use QIT_CLI\Commands\McpCommand; use QIT_CLI\Commands\OpenCommand; use QIT_CLI\Commands\Partner\AddPartner; use QIT_CLI\Commands\Partner\RemovePartner; @@ -124,6 +125,14 @@ public function getDefaultCommands() { $application->configureIO( $container->make( Input::class ), $container->make( Output::class ) ); +$is_mcp_mode = \QIT_CLI\is_mcp_command_argv( $GLOBALS['argv'] ?? null ); + +if ( $is_mcp_mode ) { + $application->add( $container->make( McpCommand::class ) ); + + return $application; +} + require_once __DIR__ . '/json-filter.php'; if ( in_array( '--json', $GLOBALS['argv'], true ) ) { diff --git a/src/src/helpers.php b/src/src/helpers.php index 55aebcf38..6d0a06fcd 100644 --- a/src/src/helpers.php +++ b/src/src/helpers.php @@ -241,6 +241,40 @@ function is_option_explicitly_provided( InputInterface $input, string $option_na return false; } +/** + * Detect whether the current argv targets the MCP command. + * + * @param array|null $argv The argv array to inspect. + */ +function is_mcp_command_argv( ?array $argv ): bool { + if ( empty( $argv ) ) { + return false; + } + + array_shift( $argv ); + + $count = count( $argv ); + for ( $index = 0; $index < $count; $index++ ) { + $arg = $argv[ $index ]; + + if ( $arg === '' ) { + continue; + } + + if ( strpos( $arg, '--' ) === 0 ) { + continue; + } + + if ( strpos( $arg, '-' ) === 0 ) { + continue; + } + + return $arg === 'mcp'; + } + + return false; +} + /** * Write debug output only when verbose mode is enabled * diff --git a/src/tests/unit/McpServerTest.php b/src/tests/unit/McpServerTest.php new file mode 100644 index 000000000..1a5cd199a --- /dev/null +++ b/src/tests/unit/McpServerTest.php @@ -0,0 +1,511 @@ + */ + private array $created_dirs = []; + + protected function tearDown(): void { + foreach ( $this->created_dirs as $dir ) { + $this->recursive_rmdir( $dir ); + } + $this->created_dirs = []; + parent::tearDown(); + } + + public function test_initialize_returns_server_info(): void { + $response = App::make( McpServer::class )->handle( [ + 'jsonrpc' => '2.0', + 'id' => 1, + 'method' => 'initialize', + 'params' => [], + ] ); + + $this->assertSame( '2.0', $response['jsonrpc'] ); + $this->assertSame( 1, $response['id'] ); + $this->assertSame( 'qit', $response['result']['serverInfo']['name'] ); + $this->assertArrayHasKey( 'tools', $response['result']['capabilities'] ); + } + + public function test_tools_list_includes_read_only_qit_tools(): void { + $response = App::make( McpServer::class )->handle( [ + 'jsonrpc' => '2.0', + 'id' => 2, + 'method' => 'tools/list', + ] ); + + $names = array_column( $response['result']['tools'], 'name' ); + + $this->assertContains( 'qit_get_run', $names ); + $this->assertContains( 'qit_get_results', $names ); + $this->assertContains( 'qit_get_failures', $names ); + $this->assertContains( 'qit_get_last_local_run_context', $names ); + $this->assertContains( 'qit_list_environments', $names ); + $this->assertContains( 'qit_get_artifacts', $names ); + $this->assertNotContains( 'qit_run_test', $names ); + } + + public function test_unknown_tool_returns_structured_tool_error(): void { + $response = $this->call_tool_response( 'qit_does_not_exist', [] ); + + $this->assertTrue( $response['result']['isError'] ); + $this->assertStringContainsString( 'Unknown tool', $response['result']['content'][0]['text'] ); + } + + public function test_invalid_notification_does_not_return_error_response(): void { + $server = App::make( McpServer::class ); + + $this->assertNull( $server->handle( [ + 'jsonrpc' => '2.0', + 'method' => '', + ] ) ); + $this->assertNull( $server->handle( [ + 'jsonrpc' => '2.0', + 'method' => [ 'not-a-string' ], + ] ) ); + $this->assertNull( $server->handle( [ + 'jsonrpc' => '2.0', + ] ) ); + } + + public function test_stdio_transport_writes_only_protocol_messages(): void { + $input = fopen( 'php://temp', 'r+' ); + $output = fopen( 'php://temp', 'r+' ); + $error = fopen( 'php://temp', 'r+' ); + + fwrite( $input, json_encode( [ + 'jsonrpc' => '2.0', + 'id' => 1, + 'method' => 'initialize', + 'params' => [], + ] ) . "\n" ); + rewind( $input ); + + ( new StdioTransport( $input, $output, $error ) )->run( App::make( McpServer::class ) ); + + rewind( $output ); + $lines = array_values( array_filter( explode( "\n", stream_get_contents( $output ) ), 'strlen' ) ); + + $this->assertCount( 1, $lines ); + $this->assertSame( '2.0', json_decode( $lines[0], true )['jsonrpc'] ); + } + + public function test_stdio_transport_substitutes_invalid_utf8_in_response(): void { + $input = fopen( 'php://temp', 'r+' ); + $output = fopen( 'php://temp', 'r+' ); + $error = fopen( 'php://temp', 'r+' ); + + fwrite( $input, json_encode( [ + 'jsonrpc' => '2.0', + 'id' => 7, + 'method' => 'tools/call', + 'params' => [ + 'name' => 'invalid_utf8_fixture', + 'arguments' => new stdClass(), + ], + ] ) . "\n" ); + rewind( $input ); + + $registry = new class() extends ToolRegistry { + public function __construct() {} + + public function call( string $name, array $arguments ): array { + return [ + 'debug_log' => "Fatal error before invalid byte \xB1 after invalid byte", + ]; + } + + public function list_tools(): array { + return []; + } + }; + + ( new StdioTransport( $input, $output, $error ) )->run( new McpServer( $registry ) ); + + rewind( $output ); + $line = trim( stream_get_contents( $output ) ); + + $this->assertNotSame( '', $line ); + $this->assertStringContainsString( '\\ufffd', $line ); + + $response = json_decode( $line, true ); + $this->assertSame( JSON_ERROR_NONE, json_last_error() ); + $this->assertSame( 7, $response['id'] ); + $this->assertArrayHasKey( 'result', $response ); + $this->assertStringContainsString( '\\ufffd', $response['result']['content'][0]['text'] ); + } + + public function test_mcp_mode_only_matches_command_token(): void { + $this->assertTrue( is_mcp_command_argv( [ 'qit', 'mcp' ] ) ); + $this->assertTrue( is_mcp_command_argv( [ 'qit', '--no-interaction', 'mcp' ] ) ); + $this->assertFalse( is_mcp_command_argv( [ 'qit', 'run:e2e', 'mcp' ] ) ); + $this->assertFalse( is_mcp_command_argv( [ 'qit', 'run:e2e', '--filter', 'mcp' ] ) ); + $this->assertFalse( is_mcp_command_argv( [ 'qit', 'get', 'mcp', '--json-results' ] ) ); + $this->assertFalse( is_mcp_command_argv( [ 'qit', '--help' ] ) ); + $this->assertFalse( is_mcp_command_argv( [ 'qit' ] ) ); + } + + public function test_get_run_decodes_results_and_redacts_sensitive_report_url(): void { + $this->mock_get_single_response( $this->make_e2e_response() ); + + $result = $this->call_tool( 'qit_get_run', [ + 'test_run_id' => 98765, + ] ); + + $this->assertSame( 98765, $result['test_run_id'] ); + $this->assertSame( 'https://qit.woo.com/results/[REDACTED]', $result['result_url'] ); + $this->assertIsArray( $result['results']['ctrf_json'] ); + $this->assertSame( 'playwright', $result['results']['ctrf_json']['results']['tool']['name'] ); + } + + public function test_get_run_redacts_result_url_under_manager_subpath(): void { + $response = $this->make_e2e_response(); + $response['test_results_manager_url'] = 'https://qit.woo.com/qit/results/98765.secret?auth=abc'; + $this->mock_get_single_response( $response ); + + $result = $this->call_tool( 'qit_get_run', [ + 'test_run_id' => 98765, + ] ); + + $this->assertSame( 'https://qit.woo.com/qit/results/[REDACTED]?[REDACTED]', $result['result_url'] ); + } + + public function test_get_results_prefers_ctrf(): void { + $this->mock_get_single_response( $this->make_e2e_response() ); + + $result = $this->call_tool( 'qit_get_results', [ + 'test_run_id' => 98765, + ] ); + + $this->assertSame( 'ctrf_json', $result['source'] ); + $this->assertSame( 1, $result['results']['results']['summary']['failed'] ); + } + + public function test_get_failures_extracts_ctrf_failures_and_debug_signals(): void { + $response = $this->make_e2e_response(); + $response['debug_log'] = json_encode( [ + 'debug_log' => "[01-Jan-2025 00:00:00 UTC] PHP Fatal error: Uncaught RuntimeException\nplain line", + ] ); + $this->mock_get_single_response( $response ); + + $result = $this->call_tool( 'qit_get_failures', [ + 'test_run_id' => 98765, + ] ); + + $this->assertCount( 1, $result['failures'] ); + $this->assertSame( 'can apply coupon at checkout', $result['failures'][0]['name'] ); + $this->assertSame( 1, $result['debug_signals']['matching_lines'] ); + $this->assertNotEmpty( $result['next_steps'] ); + } + + public function test_get_failures_max_debug_log_lines_zero_returns_no_debug_lines(): void { + $response = $this->make_e2e_response(); + $response['debug_log'] = json_encode( [ + 'debug_log' => "[01-Jan-2025 00:00:00 UTC] PHP Fatal error: Uncaught RuntimeException\nplain line", + ] ); + $this->mock_get_single_response( $response ); + + $result = $this->call_tool( 'qit_get_failures', [ + 'test_run_id' => 98765, + 'max_debug_log_lines' => 0, + ] ); + + $this->assertSame( 2, $result['debug_signals']['total_lines'] ); + $this->assertSame( 0, $result['debug_signals']['matching_lines'] ); + $this->assertSame( [], $result['debug_signals']['lines'] ); + } + + public function test_get_failures_matching_lines_reports_pre_truncation_count(): void { + $response = $this->make_e2e_response(); + $response['debug_log'] = json_encode( [ + 'debug_log' => implode( "\n", [ + '[01-Jan-2025 00:00:00 UTC] PHP Warning: first signal', + '[01-Jan-2025 00:00:01 UTC] PHP Notice: second signal', + '[01-Jan-2025 00:00:02 UTC] PHP Fatal error: third signal', + ] ), + ] ); + $this->mock_get_single_response( $response ); + + $result = $this->call_tool( 'qit_get_failures', [ + 'test_run_id' => 98765, + 'max_debug_log_lines' => 1, + ] ); + + $this->assertSame( 3, $result['debug_signals']['matching_lines'] ); + $this->assertCount( 1, $result['debug_signals']['lines'] ); + $this->assertStringContainsString( 'third signal', $result['debug_signals']['lines'][0] ); + } + + public function test_embedded_result_urls_are_redacted_in_failures_and_debug_logs(): void { + $response = $this->make_e2e_response(); + $ctrf = json_decode( $response['ctrf_json'], true ); + + $ctrf['results']['tests'][1]['message'] = 'See https://qit.woo.com/results/98765.secret?auth=abc before retrying.'; + $response['ctrf_json'] = json_encode( $ctrf ); + $response['debug_log'] = json_encode( [ + 'debug_log' => '[01-Jan-2025 00:00:00 UTC] PHP Fatal error: See https://qit.woo.com/results/98765.secret?auth=abc', + ] ); + + $this->mock_get_single_response( $response ); + + $result = $this->call_tool( 'qit_get_failures', [ + 'test_run_id' => 98765, + ] ); + + $this->assertSame( 'See https://qit.woo.com/results/[REDACTED]?[REDACTED] before retrying.', $result['failures'][0]['message'] ); + $this->assertStringContainsString( + 'https://qit.woo.com/results/[REDACTED]?[REDACTED]', + $result['debug_signals']['lines'][0] + ); + $this->assertStringNotContainsString( '98765.secret', $result['failures'][0]['message'] ); + $this->assertStringNotContainsString( 'auth=abc', $result['debug_signals']['lines'][0] ); + } + + public function test_get_failures_extracts_legacy_security_messages(): void { + $this->mock_get_single_response( $this->make_security_response() ); + + $result = $this->call_tool( 'qit_get_failures', [ + 'test_run_id' => 55555, + ] ); + + $this->assertCount( 2, $result['failures'] ); + $this->assertSame( 'my-plugin/includes/class-api.php', $result['failures'][0]['file'] ); + $this->assertSame( 'PHPCS.Security.SQLInjection', $result['failures'][0]['rule'] ); + } + + public function test_last_local_run_context_redacts_remote_report(): void { + $last_run = [ + 'run_id' => 'local-123', + 'remote_report' => 'https://qit.woo.com/results/local.secret-token?auth=abc', + 'artifacts' => [ + 'reports' => [ + [ + 'type' => 'ctrf', + 'path' => '/tmp/qit/results/ctrf-report.json', + ], + ], + ], + ]; + file_put_contents( rtrim( Config::get_qit_dir(), '/' ) . '/last-run.json', json_encode( $last_run ) ); + + $result = $this->call_tool( 'qit_get_last_local_run_context', [] ); + + $this->assertTrue( $result['found'] ); + $this->assertSame( 'https://qit.woo.com/results/[REDACTED]?[REDACTED]', $result['context']['remote_report'] ); + } + + public function test_malformed_last_local_run_returns_tool_error(): void { + file_put_contents( rtrim( Config::get_qit_dir(), '/' ) . '/last-run.json', '{not-json' ); + + $response = $this->call_tool_response( 'qit_get_last_local_run_context', [] ); + + $this->assertTrue( $response['result']['isError'] ); + $this->assertStringContainsString( 'JSON file is malformed', $response['result']['content'][0]['text'] ); + } + + public function test_get_artifacts_rejects_test_run_id_with_last_local_run_source(): void { + $response = $this->call_tool_response( 'qit_get_artifacts', [ + 'test_run_id' => 98765, + 'source' => 'last_local_run', + ] ); + + $this->assertTrue( $response['result']['isError'] ); + $this->assertStringContainsString( 'Conflicting artifact arguments', $response['result']['content'][0]['text'] ); + $this->assertStringContainsString( 'source=last_local_run cannot be combined with test_run_id', $response['result']['content'][0]['text'] ); + } + + public function test_list_environments_returns_running_environment(): void { + $environment_monitor = App::make( EnvironmentMonitor::class ); + $environment_monitor->environment_added_or_updated( $this->make_env_info() ); + + $result = $this->call_tool( 'qit_list_environments', [] ); + + $this->assertCount( 1, $result['environments'] ); + $this->assertSame( 'mcp-env-1', $result['environments'][0]['env_id'] ); + } + + /** + * @param array $response + */ + private function mock_get_single_response( array $response ): void { + App::setVar( + sprintf( 'mock_%s%s', get_manager_url(), '/wp-json/cd/v1/get-single' ), + json_encode( $response ) + ); + } + + /** + * @param string $name + * @param array $arguments + * @return array + */ + private function call_tool( string $name, array $arguments ): array { + $response = $this->call_tool_response( $name, $arguments ); + + $this->assertArrayNotHasKey( 'error', $response ); + $this->assertArrayHasKey( 'structuredContent', $response['result'] ); + + return $response['result']['structuredContent']; + } + + /** + * @param string $name + * @param array $arguments + * @return array + */ + private function call_tool_response( string $name, array $arguments ): array { + return App::make( McpServer::class )->handle( [ + 'jsonrpc' => '2.0', + 'id' => 10, + 'method' => 'tools/call', + 'params' => [ + 'name' => $name, + 'arguments' => $arguments, + ], + ] ); + } + + private function make_env_info(): EnvInfo { + $temp_envs_dir = Environment::get_temp_envs_dir(); + $env_dir = $temp_envs_dir . 'e2e-mcp-env'; + + if ( ! is_dir( $env_dir ) ) { + mkdir( $env_dir, 0755, true ); + $this->created_dirs[] = $env_dir; + } + + $env_info = EnvInfo::from_array( [ 'environment' => 'e2e' ] ); + $env_info->temporary_env = $env_dir; + $env_info->env_id = 'mcp-env-1'; + $env_info->created_at = 1708728299; + $env_info->status = 'running'; + $env_info->site_url = 'http://localhost:8080'; + + return $env_info; + } + + /** + * @return array + */ + private function make_e2e_response(): array { + return [ + 'test_run_id' => 98765, + 'run_id' => 98765, + 'test_type' => 'e2e', + 'test_type_display' => 'E2E', + 'wordpress_version' => '6.7', + 'woocommerce_version' => '9.5.1', + 'php_version' => '8.2', + 'test_result_json' => json_encode( [ + 'summary' => 'Tests: 3 total, 2 passed, 1 failed', + 'testResults' => [ + [ + 'tests' => [ + 'Checkout flow' => [ + [ + 'status' => 'passed', + 'title' => 'can add product to cart', + ], + [ + 'status' => 'failed', + 'title' => 'can apply coupon at checkout', + ], + ], + ], + ], + ], + ] ), + 'ctrf_json' => json_encode( [ + 'results' => [ + 'tool' => [ 'name' => 'playwright' ], + 'summary' => [ + 'tests' => 3, + 'passed' => 2, + 'failed' => 1, + 'pending' => 0, + 'skipped' => 0, + 'other' => 0, + ], + 'tests' => [ + [ + 'name' => 'can add product to cart', + 'status' => 'passed', + ], + [ + 'name' => 'can apply coupon at checkout', + 'status' => 'failed', + 'message' => 'Expected coupon discount to be applied.', + ], + ], + ], + ] ), + 'status' => 'failed', + 'woo_extension' => [ + 'id' => 12345, + 'name' => 'My WooCommerce Plugin', + 'type' => 'plugin', + ], + 'test_results_manager_url' => 'https://qit.woo.com/results/98765.abc123', + 'test_summary' => 'Tests: 3 total, 2 passed, 1 failed', + 'debug_log' => '', + 'update_complete' => true, + 'test_media' => [], + 'created_at' => '2025-01-15 10:30:00', + ]; + } + + /** + * @return array + */ + private function make_security_response(): array { + return [ + 'test_run_id' => 55555, + 'run_id' => 55555, + 'test_type' => 'security', + 'test_type_display' => 'Security', + 'test_result_json' => json_encode( [ + 'tool' => 'phpcs-security-audit', + 'summary' => '2 warnings found', + 'files' => [ + 'my-plugin/includes/class-api.php' => [ + 'messages' => [ + [ + 'message' => 'Possible SQL injection via $wpdb->prepare().', + 'source' => 'PHPCS.Security.SQLInjection', + 'severity' => 5, + 'line' => 42, + 'column' => 15, + 'type' => 'WARNING', + ], + [ + 'message' => 'User input output without escaping.', + 'source' => 'PHPCS.Security.XSSVulnerability', + 'severity' => 5, + 'line' => 88, + 'column' => 20, + 'type' => 'WARNING', + ], + ], + ], + ], + ] ), + 'ctrf_json' => '', + 'status' => 'warning', + 'test_results_manager_url' => 'https://qit.woo.com/results/55555.def456', + 'test_summary' => '2 warnings found', + 'debug_log' => '', + 'update_complete' => true, + ]; + } +}