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
6 changes: 6 additions & 0 deletions docs/_index.md
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,12 @@ kci-dev --settings /path/to/.kci-dev.toml

Pull results from the Web Dashboard. See detailed [documentation](results).

#### mcp

Run an MCP server exposing KernelCI data and actions to AI agents. See detailed [documentation](mcp).

- [mcp](mcp)

### Maestro Commands

#### config
Expand Down
50 changes: 50 additions & 0 deletions docs/mcp.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
+++
title = 'mcp'
date = 2026-07-01T00:00:00+00:00
description = 'Run an MCP server exposing KernelCI data and actions to AI agents.'
+++

This command runs an MCP (Model Context Protocol) server so AI agents and
automation tools can query KernelCI results and drive Maestro jobs.

MCP support is an optional extra:

```sh
pip install kci-dev[mcp]
```

Read-only dashboard query tools (trees, builds, boots, tests, hardware,
known issues) are always available and need no configuration. Maestro
node lookup tools are enabled when the configured instance has an `api`
URL, and job retry/checkout trigger tools when it also has a `pipeline`
URL and a `token`. See the [config file](../config_file.md) documentation.
Use the top-level `--instance` option (`kci-dev --instance staging mcp`) to
select which configured instance the server uses.

Run with the default stdio transport for local agents:

```sh
kci-dev mcp
```

Example Claude Code registration:

```sh
claude mcp add kernelci -- kci-dev mcp
```

Run as an HTTP server (streamable HTTP transport):

```sh
kci-dev mcp --transport http --host 127.0.0.1 --port 8000
```

The HTTP transport has no authentication layer: anyone who can reach the
port can call the exposed tools, including the job-triggering ones, using
the token from your configuration. Keep it bound to 127.0.0.1, prefer the
stdio transport for local use, and do not expose the port beyond hosts you
trust.

Tools that change state (`retry_job`, `trigger_checkout`) are annotated
as non-read-only so MCP clients can ask for confirmation before calling
them.
2 changes: 1 addition & 1 deletion kcidev/libs/common.py
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@ def load_toml(settings, subcommand):
return config

# config and results subcommand work without a config file
if subcommand not in ("config", "results", "submit", "storage"):
if subcommand not in ("config", "results", "submit", "storage", "mcp"):
if not config:
logging.warning(f"No config file found for subcommand {subcommand}")
kci_err(
Expand Down
2 changes: 2 additions & 0 deletions kcidev/libs/maestro_common.py
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,8 @@ def maestro_get_node(url, nodeid):
sys.exit(errno.ENOENT)

node_data = response.json()
if node_data is None:
raise click.ClickException(f"Node {nodeid} not found")
logging.debug(
f"Node {nodeid} data received, state: {node_data.get('state', 'unknown')}"
)
Expand Down
4 changes: 3 additions & 1 deletion kcidev/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
commit,
config,
maestro,
mcp,
results,
storage,
submit,
Expand Down Expand Up @@ -45,7 +46,7 @@ def cli(ctx, settings, instance, debug):
if subcommand not in ("results", "config"):
if instance:
ctx.obj["INSTANCE"] = instance
elif subcommand not in ("submit", "storage"):
elif subcommand not in ("submit", "storage", "mcp"):
ctx.obj["INSTANCE"] = ctx.obj["CFG"].get("default_instance")
fconfig = config_path(settings)
if not ctx.obj["INSTANCE"]:
Expand All @@ -68,6 +69,7 @@ def register_commands(command_group=None):
command_group.add_command(commit.commit)
command_group.add_command(config.config)
command_group.add_command(maestro.maestro)
command_group.add_command(mcp.mcp)
command_group.add_command(testretry.testretry)
command_group.add_command(results.results)
command_group.add_command(storage.storage)
Expand Down
27 changes: 27 additions & 0 deletions kcidev/mcp/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-

from mcp.server.fastmcp import FastMCP

from kcidev.libs.common import kcidev_version
from kcidev.mcp import tools_dashboard, tools_maestro

SERVER_INSTRUCTIONS = """KernelCI MCP server.

Query KernelCI build, boot and test results and known issues from the
web dashboard, inspect Maestro nodes, and, when a token is configured,
retry jobs or trigger custom checkouts. Start with list_trees to
discover tree, branch and commit values, then get_summary for an
overview of a commit's results.
"""


def create_server(cfg=None, instance=None, host="127.0.0.1", port=8000):
server = FastMCP("kci-dev", instructions=SERVER_INSTRUCTIONS, host=host, port=port)
server._mcp_server.version = kcidev_version
tools_dashboard.register_tools(server)
icfg = (cfg or {}).get(instance) or {}
tools_maestro.register_tools(
server, icfg.get("api"), icfg.get("pipeline"), icfg.get("token")
)
return server
35 changes: 35 additions & 0 deletions kcidev/mcp/errors.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-

import contextlib
import sys
from functools import wraps

import click
import requests

from kcidev.api import KciDevError


class ToolExecutionError(Exception):
"""Raised when a KernelCI API call behind an MCP tool fails."""


def tool_errors(func):
@wraps(func)
def wrapper(*args, **kwargs):
with contextlib.redirect_stdout(sys.stderr):
try:
return func(*args, **kwargs)
except KciDevError as e:
raise ToolExecutionError(str(e)) from e
except click.ClickException as e:
raise ToolExecutionError(e.format_message()) from e
except click.Abort as e:
raise ToolExecutionError("KernelCI API request failed") from e
except SystemExit as e:
raise ToolExecutionError("KernelCI API request failed") from e
except requests.exceptions.RequestException as e:
raise ToolExecutionError(str(e)) from e

return wrapper
Loading
Loading