Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

<p align="center">
<img src="https://github.com/woocommerce/qit-cli/assets/9341686/640698a7-01c3-498a-8bb2-7c5e337e0a9c" alt="Qit Quick Demo">
</p>
Expand Down
9 changes: 8 additions & 1 deletion src/qit-cli.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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 );
Expand Down
35 changes: 35 additions & 0 deletions src/src/Commands/McpCommand.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
<?php

namespace QIT_CLI\Commands;

use QIT_CLI\MCP\McpServer;
use QIT_CLI\MCP\StdioTransport;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;

class McpCommand extends Command {
protected static $defaultName = 'mcp'; // phpcs:ignore WordPress.NamingConventions.ValidVariableName.PropertyNotSnakeCase

private McpServer $server;
private StdioTransport $transport;

public function __construct( McpServer $server, StdioTransport $transport ) {
$this->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 );
}
}
191 changes: 191 additions & 0 deletions src/src/MCP/McpServer.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,191 @@
<?php

namespace QIT_CLI\MCP;

use QIT_CLI\App;

class McpServer {
private ToolRegistry $tools;
private bool $should_exit = false;

public function __construct( ToolRegistry $tools ) {
$this->tools = $tools;
}

/**
* @param array<string,mixed> $message
* @return array<string,mixed>|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<string,mixed>
*/
private function success_response( $id, $result ): array {
return [
'jsonrpc' => '2.0',
'id' => $id,
'result' => $result,
];
}

/**
* @param mixed $id
* @param array<string,mixed> $params
* @return array<string,mixed>
*/
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<string,mixed> $data
* @return array<string,mixed>
*/
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 : '{}';
}
}
24 changes: 24 additions & 0 deletions src/src/MCP/McpToolException.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
<?php

namespace QIT_CLI\MCP;

class McpToolException extends \RuntimeException {
/** @var array<string,mixed> */
private array $details;

/**
* @param string $message
* @param array<string,mixed> $details
*/
public function __construct( string $message, array $details = [], int $code = 0, ?\Throwable $previous = null ) {
parent::__construct( $message, $code, $previous );
$this->details = $details;
}

/**
* @return array<string,mixed>
*/
public function get_details(): array {
return $this->details;
}
}
Loading
Loading