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.
+
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,
+ ];
+ }
+}