diff --git a/docs/_index.md b/docs/_index.md index 440008d1..fa3ea9e6 100644 --- a/docs/_index.md +++ b/docs/_index.md @@ -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 diff --git a/docs/mcp.md b/docs/mcp.md new file mode 100644 index 00000000..3f8097b6 --- /dev/null +++ b/docs/mcp.md @@ -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. diff --git a/kcidev/libs/common.py b/kcidev/libs/common.py index de5ccf16..55df1ee5 100644 --- a/kcidev/libs/common.py +++ b/kcidev/libs/common.py @@ -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( diff --git a/kcidev/libs/maestro_common.py b/kcidev/libs/maestro_common.py index d5a5c1ae..374b86b8 100644 --- a/kcidev/libs/maestro_common.py +++ b/kcidev/libs/maestro_common.py @@ -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')}" ) diff --git a/kcidev/main.py b/kcidev/main.py index 0f2f62e6..fbf66410 100755 --- a/kcidev/main.py +++ b/kcidev/main.py @@ -12,6 +12,7 @@ commit, config, maestro, + mcp, results, storage, submit, @@ -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"]: @@ -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) diff --git a/kcidev/mcp/__init__.py b/kcidev/mcp/__init__.py new file mode 100644 index 00000000..255b2d2d --- /dev/null +++ b/kcidev/mcp/__init__.py @@ -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 diff --git a/kcidev/mcp/errors.py b/kcidev/mcp/errors.py new file mode 100644 index 00000000..86ba0662 --- /dev/null +++ b/kcidev/mcp/errors.py @@ -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 diff --git a/kcidev/mcp/tools_dashboard.py b/kcidev/mcp/tools_dashboard.py new file mode 100644 index 00000000..a47f92c1 --- /dev/null +++ b/kcidev/mcp/tools_dashboard.py @@ -0,0 +1,280 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + +from kcidev.api import KciDevError, KernelCIClient +from kcidev.libs.filters import StatusFilter +from kcidev.mcp.errors import tool_errors + +_client = KernelCIClient() + + +def _page(data, key, status, limit, offset): + items = data[key] if isinstance(data, dict) else data + total = len(items) + if status: + status_filter = StatusFilter(status) + items = [item for item in items if status_filter.matches(item)] + return { + key: items[offset : offset + limit], + "total": total, + "matched": len(items), + "limit": limit, + "offset": offset, + } + + +@tool_errors +def list_trees(origin: str = "maestro", days: int = 7): + """List kernel trees with recent results in the KernelCI dashboard. + + Returns tree names, git URLs, branches and latest commit hashes for + the given origin over the last N days. Use this first to discover + valid giturl/branch/commit values for the other query tools. + """ + return _client.get_tree_list(origin, days) + + +@tool_errors +def get_summary( + giturl: str, + branch: str, + commit: str, + origin: str = "maestro", + arch: str | None = None, +): + """Get the build/boot/test summary for one commit of a tree. + + Returns aggregated pass/fail/inconclusive counts. Use list_trees to + find giturl, branch and commit values. + """ + return _client.get_summary(origin, giturl, branch, commit, arch) + + +@tool_errors +def list_commits(giturl: str, branch: str, commit: str, origin: str = "maestro"): + """List recent checkouts of a tree with per-commit result counts. + + Returns the commit history leading up to the given commit hash, with + aggregated build/boot/test status counts for each checkout. Use this + to find earlier commits of a tree and compare results across + checkouts with get_summary. The commit must be the full 40-character + hash of a checkout the dashboard has ingested, such as a tree head + hash from list_trees; other commits have no history record. + """ + try: + commits = _client.get_commits_history(origin, giturl, branch, commit) + except KciDevError as e: + if "not found" in str(e).lower(): + raise KciDevError( + f"{e}; history only exists for ingested checkout commits, " + "pass the full 40-character hash of a commit from list_trees" + ) from e + raise + for entry in commits: + if isinstance(entry.get("builds"), dict): + entry["builds"] = {k.lower(): v for k, v in entry["builds"].items()} + return commits + + +@tool_errors +def list_builds( + giturl: str, + branch: str, + commit: str, + origin: str = "maestro", + arch: str | None = None, + tree: str | None = None, + start_date: str | None = None, + end_date: str | None = None, + status: str | None = None, + limit: int = 100, + offset: int = 0, +): + """List kernel builds for one commit of a tree. + + Optional filters: arch (e.g. 'arm64'), tree name, ISO date range, and + status ('pass', 'fail' or 'inconclusive'). Results are paginated with + limit/offset; the response carries 'total' (before status filtering) + and 'matched' counts so you know whether to fetch further pages. + Returns build entries with ids usable with get_build. + """ + data = _client.get_builds( + origin, giturl, branch, commit, arch, tree, start_date, end_date + ) + return _page(data, "builds", status, limit, offset) + + +@tool_errors +def list_boots( + giturl: str, + branch: str, + commit: str, + origin: str = "maestro", + arch: str | None = None, + tree: str | None = None, + start_date: str | None = None, + end_date: str | None = None, + boot_origin: str | None = None, + status: str | None = None, + limit: int = 100, + offset: int = 0, +): + """List boot test results for one commit of a tree. + + Optional filters: arch, tree name, ISO date range, boot origin, and + status ('pass', 'fail' or 'inconclusive'). Results are paginated with + limit/offset; the response carries 'total' (before status filtering) + and 'matched' counts so you know whether to fetch further pages. + Returns boot entries with ids usable with get_test. + """ + data = _client.get_boots( + origin, giturl, branch, commit, arch, tree, start_date, end_date, boot_origin + ) + return _page(data, "boots", status, limit, offset) + + +@tool_errors +def list_tests( + giturl: str, + branch: str, + commit: str, + origin: str = "maestro", + arch: str | None = None, + tree: str | None = None, + start_date: str | None = None, + end_date: str | None = None, + status: str | None = None, + limit: int = 100, + offset: int = 0, +): + """List test results for one commit of a tree. + + Optional filters: arch, tree name, ISO date range, and status ('pass', + 'fail' or 'inconclusive'). A full commit can carry tens of thousands + of tests, so filter by status and paginate with limit/offset; the + response carries 'total' (before status filtering) and 'matched' + counts so you know whether to fetch further pages. + Returns test entries with ids usable with get_test. + """ + data = _client.get_tests( + origin, giturl, branch, commit, arch, tree, start_date, end_date + ) + return _page(data, "tests", status, limit, offset) + + +@tool_errors +def get_build(build_id: str): + """Get details for a single build by dashboard build id. + + Build ids look like 'maestro:'. Returns config, compiler, logs + and status for the build. + """ + return _client.get_build(build_id) + + +@tool_errors +def get_test(test_id: str): + """Get details for a single test or boot by dashboard test id. + + Test ids look like 'maestro:'. Returns status, logs, environment + and misc data for the test. + """ + return _client.get_test(test_id) + + +@tool_errors +def list_hardware(origin: str = "maestro"): + """List hardware platforms with results over the last 7 days. + + Returns platform names usable with get_hardware_summary. + """ + return _client.get_hardware_list(origin) + + +@tool_errors +def get_hardware_summary(name: str, origin: str = "maestro"): + """Get the build/boot/test summary for one hardware platform. + + Covers the last 7 days. Use list_hardware to find platform names. + """ + return _client.get_hardware_summary(name, origin) + + +@tool_errors +def list_issues(origin: str = "maestro", days: int = 7): + """List known issues (recognised failure patterns) from the dashboard. + + Returns issue ids and descriptions for the last N days. Issue ids + are usable with get_issue, get_issue_builds and get_issue_tests. + """ + return _client.get_issue_list(origin, days) + + +@tool_errors +def get_issue(issue_id: str): + """Get details for a single known issue by issue id. + + Issue ids look like 'maestro:'. + """ + return _client.get_issue(issue_id) + + +@tool_errors +def get_issue_builds( + issue_id: str, + origin: str = "maestro", + status: str | None = None, + limit: int = 100, + offset: int = 0, +): + """List builds affected by a known issue. + + Optional status filter ('pass', 'fail' or 'inconclusive') and + limit/offset pagination; the response carries 'total' and 'matched' + counts. + """ + data = _client.get_issue_builds(issue_id, origin) + return _page(data, "builds", status, limit, offset) + + +@tool_errors +def get_issue_tests( + issue_id: str, + origin: str = "maestro", + status: str | None = None, + limit: int = 100, + offset: int = 0, +): + """List tests affected by a known issue. + + Optional status filter ('pass', 'fail' or 'inconclusive') and + limit/offset pagination; the response carries 'total' and 'matched' + counts. + """ + data = _client.get_issue_tests(issue_id, origin) + return _page(data, "tests", status, limit, offset) + + +READ_ONLY_TOOLS = ( + list_trees, + get_summary, + list_commits, + list_builds, + list_boots, + list_tests, + get_build, + get_test, + list_hardware, + get_hardware_summary, + list_issues, + get_issue, + get_issue_builds, + get_issue_tests, +) + + +def register_tools(server): + from mcp.types import ToolAnnotations + + for tool in READ_ONLY_TOOLS: + server.tool(annotations=ToolAnnotations(readOnlyHint=True))(tool) diff --git a/kcidev/mcp/tools_maestro.py b/kcidev/mcp/tools_maestro.py new file mode 100644 index 00000000..acb4b8e7 --- /dev/null +++ b/kcidev/mcp/tools_maestro.py @@ -0,0 +1,103 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + +from kcidev.libs.maestro_common import maestro_get_node, maestro_get_nodes +from kcidev.mcp.errors import ToolExecutionError, tool_errors +from kcidev.subcommands.checkout import send_checkout_full +from kcidev.subcommands.testretry import send_jobretry + + +def _retry_job(pipeline_url, token, node_id): + result = send_jobretry(pipeline_url, node_id, token) + if result is None: + raise ToolExecutionError(f"Maestro job retry failed for node {node_id}") + return result + + +def _trigger_checkout( + pipeline_url, token, giturl, branch, commit, job_filter, platform_filter +): + kwargs = { + "giturl": giturl, + "branch": branch, + "commit": commit, + "job_filter": job_filter, + } + if platform_filter: + kwargs["platform_filter"] = platform_filter + result = send_checkout_full(pipeline_url, token, **kwargs) + if result is None: + raise ToolExecutionError(f"Maestro checkout failed for {giturl} at {commit}") + return result + + +def register_tools(server, api_url, pipeline_url, token): + from mcp.types import ToolAnnotations + + read_only = ToolAnnotations(readOnlyHint=True) + action = ToolAnnotations(readOnlyHint=False, destructiveHint=False) + + if api_url: + + @server.tool(annotations=read_only) + @tool_errors + def get_node(node_id: str): + """Get a Maestro node (job, build or test run) by node id. + + Returns the full node including state (running, done, timeout), + result (pass, fail, incomplete), artifacts and timestamps. Use + this to poll a job started with trigger_checkout or retry_job. + Node ids are 24-character hex strings. + """ + return maestro_get_node(api_url, node_id) + + @server.tool(annotations=read_only) + @tool_errors + def list_nodes( + filters: list[str] | None = None, limit: int = 50, offset: int = 0 + ): + """List Maestro nodes, oldest first, optionally filtered. + + Filters are 'field=value' strings, for example 'name=checkout', + 'state=done', 'result=fail' or 'treeid='. Matching is + exact; append '__re' to a field for a regex match, for example + 'name__re=baseline' matches all baseline job variants. Results + are returned oldest first, so to reach recent nodes window the + query with a filter such as 'created__gt=2026-07-01' rather + than paginating from the start. Use limit and offset to + paginate within the window. + """ + return maestro_get_nodes(api_url, limit, offset, filters or [], True) + + if pipeline_url and token: + + @server.tool(annotations=action) + @tool_errors + def retry_job(node_id: str): + """Retry a failed or incomplete KernelCI test job. + + Creates a new job for the given Maestro node id. Use list_nodes + or the dashboard tools to find the node id of the failed job. + """ + return _retry_job(pipeline_url, token, node_id) + + @server.tool(annotations=action) + @tool_errors + def trigger_checkout( + giturl: str, + branch: str, + commit: str, + job_filter: list[str], + platform_filter: list[str] | None = None, + ): + """Trigger a custom KernelCI pipeline run for a tree/branch/commit. + + Starts a checkout of the given kernel git URL, branch and commit, + running only the jobs named in job_filter (for example + ['baseline-arm64']), optionally restricted to the platforms in + platform_filter. Returns a tree id; poll progress with list_nodes + using 'treeid='. + """ + return _trigger_checkout( + pipeline_url, token, giturl, branch, commit, job_filter, platform_filter + ) diff --git a/kcidev/subcommands/mcp.py b/kcidev/subcommands/mcp.py new file mode 100644 index 00000000..f1623169 --- /dev/null +++ b/kcidev/subcommands/mcp.py @@ -0,0 +1,54 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + +import logging + +import click + +from kcidev.libs.common import * + + +@click.command( + help="""Run an MCP (Model Context Protocol) server exposing KernelCI. + +Read-only dashboard query tools are always available. 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. + +Requires the mcp extra: pip install kci-dev[mcp] + +\b +Examples: + kci-dev mcp + kci-dev --instance production mcp + kci-dev mcp --transport http --port 8000 +""" +) +@click.option( + "--transport", + type=click.Choice(["stdio", "http"]), + default="stdio", + help="MCP transport: stdio for local agents, http for a hosted server", +) +@click.option("--host", default="127.0.0.1", help="Bind address for http transport") +@click.option("--port", default=8000, type=int, help="Port for http transport") +@click.pass_context +def mcp(ctx, transport, host, port): + try: + from kcidev.mcp import create_server + except ImportError: + kci_err("MCP support is not installed, install with: pip install kci-dev[mcp]") + raise click.Abort() + + cfg = ctx.obj.get("CFG") or {} + instance = ctx.obj.get("INSTANCE") or cfg.get("default_instance") + if instance and instance not in cfg: + kci_err(f"Instance {instance} not found in config") + raise click.Abort() + server = create_server(cfg, instance, host=host, port=port) + logging.info( + "Starting MCP server %s", + "via stdio" if transport == "stdio" else f"on {host}:{port}", + ) + server.run(transport="stdio" if transport == "stdio" else "streamable-http") diff --git a/pyproject.toml b/pyproject.toml index 8e82af1c..158fcd6a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -36,6 +36,10 @@ pyyaml = "^6.0.2" tabulate = "0.9.0" matplotlib = ">=3.6" mplcursors = ">=0.6" +mcp = { version = "^1.9", optional = true } + +[tool.poetry.extras] +mcp = ["mcp"] [tool.poetry.scripts] kci-dev = 'kcidev.main:run' diff --git a/tests/test_kcidev.py b/tests/test_kcidev.py index 8a69e864..61142262 100644 --- a/tests/test_kcidev.py +++ b/tests/test_kcidev.py @@ -956,3 +956,15 @@ def test_kcidev_submit_build_help(): def test_clean(): # clean enviroment shutil.rmtree("my-new-repo/") + + +def test_kcidev_mcp_help(): + command = ["poetry", "run", "kci-dev", "mcp", "--help"] + result = run(command, stdout=PIPE, stderr=PIPE, universal_newlines=True) + print("returncode: " + str(result.returncode)) + print("#### stdout ####") + print(result.stdout) + print("#### stderr ####") + print(result.stderr) + assert result.returncode == 0 + assert "MCP" in result.stdout diff --git a/tests/test_maestro_common.py b/tests/test_maestro_common.py new file mode 100644 index 00000000..c3d646d5 --- /dev/null +++ b/tests/test_maestro_common.py @@ -0,0 +1,16 @@ +from unittest.mock import Mock + +import click +import pytest + +from kcidev.libs import maestro_common + + +def test_maestro_get_node_missing_raises_clean_error(monkeypatch): + response = Mock(status_code=200) + response.json.return_value = None + monkeypatch.setattr( + maestro_common.kcidev_session, "get", Mock(return_value=response) + ) + with pytest.raises(click.ClickException, match="not found"): + maestro_common.maestro_get_node("https://api.example.org/", "0" * 24) diff --git a/tests/test_mcp_errors.py b/tests/test_mcp_errors.py new file mode 100644 index 00000000..f435c26a --- /dev/null +++ b/tests/test_mcp_errors.py @@ -0,0 +1,85 @@ +import click +import pytest + +pytest.importorskip("mcp") + +import requests + +from kcidev.mcp.errors import ToolExecutionError, tool_errors + + +def test_tool_errors_passes_through_return_value(): + @tool_errors + def ok(): + return {"a": 1} + + assert ok() == {"a": 1} + + +def test_tool_errors_converts_click_abort(): + @tool_errors + def fail(): + raise click.Abort() + + with pytest.raises(ToolExecutionError): + fail() + + +def test_tool_errors_converts_click_exception_with_message(): + @tool_errors + def fail(): + raise click.ClickException("bad param") + + with pytest.raises(ToolExecutionError, match="bad param"): + fail() + + +def test_tool_errors_converts_system_exit(): + @tool_errors + def fail(): + raise SystemExit(2) + + with pytest.raises(ToolExecutionError): + fail() + + +def test_tool_errors_converts_requests_error(): + @tool_errors + def fail(): + raise requests.exceptions.ConnectionError("boom") + + with pytest.raises(ToolExecutionError, match="boom"): + fail() + + +def test_tool_errors_redirects_stdout_to_stderr(capsys): + @tool_errors + def noisy(): + print("chatter") + return 1 + + assert noisy() == 1 + captured = capsys.readouterr() + assert captured.out == "" + assert "chatter" in captured.err + + +def test_tool_errors_preserves_signature(): + @tool_errors + def f(x: int, y: str = "a"): + return x + + import inspect + + assert list(inspect.signature(f).parameters) == ["x", "y"] + + +def test_tool_errors_converts_kcidev_error(): + from kcidev.api import KciDevError + + @tool_errors + def fail(): + raise KciDevError("Dashboard build request failed: boom") + + with pytest.raises(ToolExecutionError, match="Dashboard build request failed"): + fail() diff --git a/tests/test_mcp_server.py b/tests/test_mcp_server.py new file mode 100644 index 00000000..5e9b8ece --- /dev/null +++ b/tests/test_mcp_server.py @@ -0,0 +1,128 @@ +from unittest.mock import Mock + +import pytest + +pytest.importorskip("mcp") + +import anyio +import requests +from mcp.shared.memory import ( + create_connected_server_and_client_session as client_session, +) + +from kcidev.libs import dashboard +from kcidev.mcp import create_server + +CFG = { + "default_instance": "test", + "test": { + "api": "https://api.example.org/", + "pipeline": "https://pipeline.example.org/", + "token": "secret", + }, +} + + +def _list_tools(server): + async def run(): + async with client_session(server._mcp_server) as session: + result = await session.list_tools() + return {t.name: t for t in result.tools} + + return anyio.run(run) + + +def _call_tool(server, name, arguments): + async def run(): + async with client_session(server._mcp_server) as session: + return await session.call_tool(name, arguments) + + return anyio.run(run) + + +def test_dashboard_tools_registered_without_config(): + tools = _list_tools(create_server()) + assert "list_trees" in tools + assert "get_summary" in tools + assert "get_build" in tools + assert "get_node" not in tools + assert "retry_job" not in tools + assert "trigger_checkout" not in tools + + +def test_maestro_tools_registered_with_full_config(): + tools = _list_tools(create_server(CFG, "test")) + assert "get_node" in tools + assert "list_nodes" in tools + assert "retry_job" in tools + assert "trigger_checkout" in tools + + +def test_action_tools_not_registered_without_token(): + cfg = {"test": {"api": "https://api.example.org/"}} + tools = _list_tools(create_server(cfg, "test")) + assert "get_node" in tools + assert "retry_job" not in tools + assert "trigger_checkout" not in tools + + +def test_action_tools_not_registered_without_pipeline_token(): + cfg = { + "test": { + "api": "https://api.example.org/", + "pipeline": "https://pipeline.example.org/", + } + } + tools = _list_tools(create_server(cfg, "test")) + assert "get_node" in tools + assert "retry_job" not in tools + assert "trigger_checkout" not in tools + + +def test_tool_annotations(): + tools = _list_tools(create_server(CFG, "test")) + assert tools["list_trees"].annotations.readOnlyHint is True + assert tools["get_node"].annotations.readOnlyHint is True + assert tools["retry_job"].annotations.readOnlyHint is False + assert tools["trigger_checkout"].annotations.readOnlyHint is False + + +def test_call_tool_success(monkeypatch): + response = Mock(status_code=200) + response.json.return_value = {"id": "maestro:b1"} + monkeypatch.setattr(dashboard.kcidev_session, "get", Mock(return_value=response)) + + result = _call_tool(create_server(), "get_build", {"build_id": "maestro:b1"}) + assert result.isError is False + assert "maestro:b1" in result.content[0].text + + +def test_call_tool_failure_is_tool_error_not_crash(monkeypatch): + monkeypatch.setattr( + dashboard.kcidev_session, + "get", + Mock(side_effect=requests.exceptions.ConnectionError("no route")), + ) + + result = _call_tool(create_server(), "get_build", {"build_id": "maestro:b1"}) + assert result.isError is True + + +def test_get_node_missing_returns_clean_tool_error(monkeypatch): + from kcidev.libs import maestro_common + + response = Mock(status_code=200) + response.json.return_value = None + monkeypatch.setattr( + maestro_common.kcidev_session, "get", Mock(return_value=response) + ) + + result = _call_tool(create_server(CFG, "test"), "get_node", {"node_id": "0" * 24}) + assert result.isError is True + assert "not found" in result.content[0].text + + +def test_server_reports_kcidev_version(): + from kcidev.libs.common import kcidev_version + + assert create_server()._mcp_server.version == kcidev_version diff --git a/tests/test_mcp_tools_dashboard.py b/tests/test_mcp_tools_dashboard.py new file mode 100644 index 00000000..081c7907 --- /dev/null +++ b/tests/test_mcp_tools_dashboard.py @@ -0,0 +1,153 @@ +from unittest.mock import Mock + +import pytest + +pytest.importorskip("mcp") + +import requests + +from kcidev.libs import dashboard +from kcidev.mcp import tools_dashboard +from kcidev.mcp.errors import ToolExecutionError + + +def _mock_get(monkeypatch, payload): + response = Mock(status_code=200) + response.json.return_value = payload + get = Mock(return_value=response) + monkeypatch.setattr(dashboard.kcidev_session, "get", get) + return get + + +def _mock_post(monkeypatch, payload): + response = Mock(status_code=200) + response.json.return_value = payload + post = Mock(return_value=response) + monkeypatch.setattr(dashboard.kcidev_session, "post", post) + return post + + +def test_get_build_fetches_dashboard(monkeypatch): + get = _mock_get(monkeypatch, {"id": "maestro:abc"}) + result = tools_dashboard.get_build("maestro:abc") + assert result == {"id": "maestro:abc"} + assert "build/maestro:abc" in get.call_args[0][0] + + +def test_list_trees_passes_origin_and_days(monkeypatch): + get = _mock_get(monkeypatch, []) + tools_dashboard.list_trees(origin="redhat", days=3) + url = get.call_args[0][0] + assert "origin=redhat" in url + assert "interval_in_days=3" in url + + +def test_list_builds_passes_filters(monkeypatch): + get = _mock_get(monkeypatch, []) + tools_dashboard.list_builds( + giturl="https://git.example.org/linux.git", + branch="master", + commit="deadbeef", + arch="arm64", + ) + url = get.call_args[0][0] + assert "tree/deadbeef/builds" in url + assert "filter_architecture=arm64" in url + + +def test_get_hardware_summary_posts(monkeypatch): + post = _mock_post(monkeypatch, {"summary": {}}) + result = tools_dashboard.get_hardware_summary("foo,bar") + assert result == {"summary": {}} + assert "hardware/foo%2Cbar/summary" in post.call_args[0][0] + + +def test_dashboard_error_becomes_tool_error(monkeypatch): + get = Mock(side_effect=requests.exceptions.ConnectionError("no route")) + monkeypatch.setattr(dashboard.kcidev_session, "get", get) + with pytest.raises(ToolExecutionError): + tools_dashboard.get_build("maestro:abc") + + +def test_all_read_only_tools_have_docstrings(): + for tool in tools_dashboard.READ_ONLY_TOOLS: + assert tool.__doc__, f"{tool.__name__} is missing a docstring" + + +def test_list_tests_filters_status_and_paginates(monkeypatch): + tests = [{"id": f"t{i}", "status": "PASS"} for i in range(50)] + [ + {"id": f"f{i}", "status": "FAIL"} for i in range(30) + ] + _mock_get(monkeypatch, {"tests": tests}) + result = tools_dashboard.list_tests( + giturl="https://git.example.org/linux.git", + branch="master", + commit="deadbeef", + status="fail", + limit=10, + offset=5, + ) + assert result["total"] == 80 + assert result["matched"] == 30 + assert len(result["tests"]) == 10 + assert all(t["status"] == "FAIL" for t in result["tests"]) + assert result["tests"][0]["id"] == "f5" + + +def test_list_tests_default_limit_bounds_response(monkeypatch): + _mock_get( + monkeypatch, {"tests": [{"id": str(i), "status": "PASS"} for i in range(300)]} + ) + result = tools_dashboard.list_tests( + giturl="https://git.example.org/linux.git", branch="master", commit="deadbeef" + ) + assert result["total"] == 300 + assert result["matched"] == 300 + assert len(result["tests"]) == 100 + + +def test_get_issue_builds_wraps_bare_list(monkeypatch): + _mock_get( + monkeypatch, [{"id": "b1", "status": "FAIL"}, {"id": "b2", "status": "PASS"}] + ) + result = tools_dashboard.get_issue_builds("maestro:abc", status="fail") + assert result["total"] == 2 + assert result["matched"] == 1 + assert result["builds"] == [{"id": "b1", "status": "FAIL"}] + + +def test_list_commits_fetches_history(monkeypatch): + get = _mock_get(monkeypatch, []) + tools_dashboard.list_commits( + giturl="https://git.example.org/linux.git", branch="master", commit="deadbeef" + ) + assert "tree/deadbeef/commits" in get.call_args[0][0] + + +def test_list_commits_normalises_builds_status_keys(monkeypatch): + _mock_get( + monkeypatch, + [ + { + "git_commit_hash": "deadbeef", + "builds": {"PASS": 31, "FAIL": 1}, + "boots": {"pass": 23, "fail": 0}, + "tests": {"pass": 100, "fail": 2}, + } + ], + ) + result = tools_dashboard.list_commits( + giturl="https://git.example.org/linux.git", branch="master", commit="deadbeef" + ) + assert result[0]["builds"] == {"pass": 31, "fail": 1} + assert result[0]["boots"] == {"pass": 23, "fail": 0} + + +def test_list_commits_not_found_includes_guidance(monkeypatch): + _mock_get(monkeypatch, {"error": "History of tree commits not found"}) + with pytest.raises(ToolExecutionError, match="list_trees"): + tools_dashboard.list_commits( + giturl="https://git.example.org/linux.git", + branch="master", + commit="deadbeef", + ) diff --git a/tests/test_mcp_tools_maestro.py b/tests/test_mcp_tools_maestro.py new file mode 100644 index 00000000..7c17f6c5 --- /dev/null +++ b/tests/test_mcp_tools_maestro.py @@ -0,0 +1,74 @@ +from unittest.mock import Mock + +import pytest + +pytest.importorskip("mcp") + +from kcidev.mcp import tools_maestro +from kcidev.mcp.errors import ToolExecutionError + + +def test_retry_job_returns_result(monkeypatch): + send = Mock(return_value={"message": "OK"}) + monkeypatch.setattr(tools_maestro, "send_jobretry", send) + result = tools_maestro._retry_job("https://pipeline/", "tok", "node1") + assert result == {"message": "OK"} + send.assert_called_once_with("https://pipeline/", "node1", "tok") + + +def test_retry_job_failure_raises(monkeypatch): + monkeypatch.setattr(tools_maestro, "send_jobretry", Mock(return_value=None)) + with pytest.raises(ToolExecutionError, match="node1"): + tools_maestro._retry_job("https://pipeline/", "tok", "node1") + + +def test_trigger_checkout_passes_kwargs(monkeypatch): + send = Mock(return_value={"treeid": "t1"}) + monkeypatch.setattr(tools_maestro, "send_checkout_full", send) + result = tools_maestro._trigger_checkout( + "https://pipeline/", + "tok", + "https://git.example.org/linux.git", + "master", + "deadbeef", + ["baseline-arm64"], + None, + ) + assert result == {"treeid": "t1"} + send.assert_called_once_with( + "https://pipeline/", + "tok", + giturl="https://git.example.org/linux.git", + branch="master", + commit="deadbeef", + job_filter=["baseline-arm64"], + ) + + +def test_trigger_checkout_includes_platform_filter(monkeypatch): + send = Mock(return_value={"treeid": "t1"}) + monkeypatch.setattr(tools_maestro, "send_checkout_full", send) + tools_maestro._trigger_checkout( + "https://pipeline/", + "tok", + "https://git.example.org/linux.git", + "master", + "deadbeef", + ["baseline-arm64"], + ["qemu-arm64"], + ) + assert send.call_args.kwargs["platform_filter"] == ["qemu-arm64"] + + +def test_trigger_checkout_failure_raises(monkeypatch): + monkeypatch.setattr(tools_maestro, "send_checkout_full", Mock(return_value=None)) + with pytest.raises(ToolExecutionError, match="deadbeef"): + tools_maestro._trigger_checkout( + "https://pipeline/", + "tok", + "https://git.example.org/linux.git", + "master", + "deadbeef", + ["baseline-arm64"], + None, + ) diff --git a/tox.ini b/tox.ini index 01c35664..aa7856d8 100644 --- a/tox.ini +++ b/tox.ini @@ -5,8 +5,8 @@ skip_missing_interpreters = true [testenv] allowlist_externals = poetry commands_pre = - poetry install --no-root - poetry sync + poetry install --no-root --extras mcp + poetry sync --extras mcp commands = poetry run pytest tests/ --import-mode importlib