openqa-mcp 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,161 @@
1
+ Metadata-Version: 2.3
2
+ Name: openqa-mcp
3
+ Version: 0.1.0
4
+ Summary: Local openQA mcp client
5
+ Requires-Dist: fastmcp>=3.4.3
6
+ Requires-Dist: openqa-async>=0.1.0
7
+ Requires-Python: >=3.13
8
+ Description-Content-Type: text/markdown
9
+
10
+ # openqa-mcp
11
+
12
+ An [MCP](https://modelcontextprotocol.io) server that exposes curated,
13
+ typed tools over the [openQA](https://open.qa) REST API. It is built on
14
+ [fastmcp](https://github.com/jlowin/fastmcp) 3.x and the
15
+ [openqa-async](https://pypi.org/project/openqa-async/) transport client.
16
+
17
+ Read tools work anonymously; mutating tools require API credentials and
18
+ return `403` without them.
19
+
20
+ ## Install
21
+
22
+ ```sh
23
+ uv sync
24
+ ```
25
+
26
+ This installs the package and its dependencies into a project virtualenv
27
+ and exposes the `openqa-mcp` console script.
28
+
29
+ ## Configuration
30
+
31
+ The server reads its configuration from environment variables, falling back
32
+ to the openQA client config file for credentials.
33
+
34
+ ### Environment variables
35
+
36
+ | Variable | Default | Purpose |
37
+ | --- | --- | --- |
38
+ | `OPENQA_SERVER` | openqa-async default | openQA host (e.g. `openqa.opensuse.org`). |
39
+ | `OPENQA_API_KEY` | *(unset)* | API key; overrides the config file when set. |
40
+ | `OPENQA_API_SECRET` | *(unset)* | API secret; overrides the config file when set. |
41
+ | `OPENQA_VERIFY` | `true` | TLS verification: `true`/`false`, or a path to a CA bundle. |
42
+
43
+ `OPENQA_API_KEY` and `OPENQA_API_SECRET` only take effect when **both** are
44
+ set; a partial pair is ignored so the client is never half-configured.
45
+
46
+ ### Config file
47
+
48
+ If the env credentials are not set, openqa-async loads them from
49
+ `~/.config/openqa/client.conf` (or `/etc/openqa/client.conf`). Generate a
50
+ key/secret from the *API keys* page of your openQA instance and add a section
51
+ keyed by the host:
52
+
53
+ ```ini
54
+ [openqa.opensuse.org]
55
+ key = YOUR_API_KEY
56
+ secret = YOUR_API_SECRET
57
+ ```
58
+
59
+ Without any credentials the server is GET-only (read tools succeed, mutating
60
+ tools get `403`).
61
+
62
+ ## Tools
63
+
64
+ ### Read tools
65
+
66
+ | Tool | Description |
67
+ | --- | --- |
68
+ | `list_jobs` | List jobs matching the given filters. |
69
+ | `list_jobs_overview` | List a condensed jobs overview matching the given filters. |
70
+ | `get_job` | Get full details for a single job. |
71
+ | `get_job_comments` | List comments on a job. |
72
+ | `list_machines` | List configured worker machines. |
73
+ | `list_test_suites` | List configured test suites. |
74
+ | `list_products` | List configured products (mediums). |
75
+ | `find_jobs_by_setting` | Find jobs whose setting `key` equals `list_value`. |
76
+
77
+ `list_jobs` and `list_jobs_overview` accept the same optional filters:
78
+ `state`, `result`, `distri`, `version`, `build`, `test`, `arch`, `machine`,
79
+ `groupid`, `group`, `latest`, `limit`, `page`, `ids`. Unset filters are
80
+ dropped from the request.
81
+
82
+ ### Mutating tools (require credentials)
83
+
84
+ | Tool | Description |
85
+ | --- | --- |
86
+ | `restart_jobs` | Restart each of the given jobs. |
87
+ | `cancel_job` | Cancel a running or scheduled job. |
88
+ | `add_job_comment` | Add a comment to a job. |
89
+ | `trigger_isos` | Trigger ISO test scheduling for a product. |
90
+ | `delete_job` | Delete a job. |
91
+
92
+ Mutating tools carry the `mutating` tag so MCP clients can gate them behind
93
+ confirmation. To drop them entirely, start the server in read-only mode with
94
+ `--readonly` (or `OPENQA_READONLY=true`): the mutating tools are never
95
+ registered, so clients see only the read tools.
96
+
97
+ ## Running
98
+
99
+ ### stdio (default)
100
+
101
+ Most local MCP clients spawn the server over stdio. Wire it in with:
102
+
103
+ ```sh
104
+ uv run openqa-mcp
105
+ ```
106
+
107
+ Example MCP client configuration:
108
+
109
+ ```json
110
+ {
111
+ "mcpServers": {
112
+ "openqa": {
113
+ "command": "uv",
114
+ "args": ["run", "openqa-mcp"],
115
+ "env": {
116
+ "OPENQA_SERVER": "openqa.opensuse.org"
117
+ }
118
+ }
119
+ }
120
+ }
121
+ ```
122
+
123
+ ### HTTP (optional)
124
+
125
+ For remote or shared deployments, run over HTTP with `--http`:
126
+
127
+ ```sh
128
+ uv run openqa-mcp --http --server 127.0.0.1 --port 8000
129
+ ```
130
+
131
+ The server can also be launched as a module:
132
+
133
+ ```sh
134
+ uv run python -m openqa_mcp --http --port 8000
135
+ ```
136
+
137
+ | Flag | Default | Purpose |
138
+ | --- | --- | --- |
139
+ | `--http` | off | Serve over HTTP instead of stdio. |
140
+ | `--stdio` | on | Serve over stdio; overrides `OPENQA_MCP_TRANSPORT=http`. |
141
+ | `--server` | `127.0.0.1` | HTTP bind host. |
142
+ | `--port` | `8000` | HTTP bind port. |
143
+ | `--readonly` | off | Unregister all mutating tools (read-only server). |
144
+
145
+ Flags override the environment, which supplies the defaults:
146
+
147
+ | Variable | Default | Purpose |
148
+ | --- | --- | --- |
149
+ | `OPENQA_MCP_TRANSPORT` | `stdio` | Set to `http` to serve over HTTP. |
150
+ | `OPENQA_MCP_HOST` | `127.0.0.1` | Default HTTP bind host. |
151
+ | `OPENQA_MCP_PORT` | `8000` | Default HTTP bind port. |
152
+ | `OPENQA_READONLY` | `false` | Set truthy (`1`/`true`/`yes`/`on`) to disable mutating tools. |
153
+
154
+ Press `Ctrl-C` to stop; the server shuts down cleanly and closes its client.
155
+
156
+ ## Development
157
+
158
+ ```sh
159
+ uv run pytest # run the test suite
160
+ uv run ruff check . # lint
161
+ ```
@@ -0,0 +1,152 @@
1
+ # openqa-mcp
2
+
3
+ An [MCP](https://modelcontextprotocol.io) server that exposes curated,
4
+ typed tools over the [openQA](https://open.qa) REST API. It is built on
5
+ [fastmcp](https://github.com/jlowin/fastmcp) 3.x and the
6
+ [openqa-async](https://pypi.org/project/openqa-async/) transport client.
7
+
8
+ Read tools work anonymously; mutating tools require API credentials and
9
+ return `403` without them.
10
+
11
+ ## Install
12
+
13
+ ```sh
14
+ uv sync
15
+ ```
16
+
17
+ This installs the package and its dependencies into a project virtualenv
18
+ and exposes the `openqa-mcp` console script.
19
+
20
+ ## Configuration
21
+
22
+ The server reads its configuration from environment variables, falling back
23
+ to the openQA client config file for credentials.
24
+
25
+ ### Environment variables
26
+
27
+ | Variable | Default | Purpose |
28
+ | --- | --- | --- |
29
+ | `OPENQA_SERVER` | openqa-async default | openQA host (e.g. `openqa.opensuse.org`). |
30
+ | `OPENQA_API_KEY` | *(unset)* | API key; overrides the config file when set. |
31
+ | `OPENQA_API_SECRET` | *(unset)* | API secret; overrides the config file when set. |
32
+ | `OPENQA_VERIFY` | `true` | TLS verification: `true`/`false`, or a path to a CA bundle. |
33
+
34
+ `OPENQA_API_KEY` and `OPENQA_API_SECRET` only take effect when **both** are
35
+ set; a partial pair is ignored so the client is never half-configured.
36
+
37
+ ### Config file
38
+
39
+ If the env credentials are not set, openqa-async loads them from
40
+ `~/.config/openqa/client.conf` (or `/etc/openqa/client.conf`). Generate a
41
+ key/secret from the *API keys* page of your openQA instance and add a section
42
+ keyed by the host:
43
+
44
+ ```ini
45
+ [openqa.opensuse.org]
46
+ key = YOUR_API_KEY
47
+ secret = YOUR_API_SECRET
48
+ ```
49
+
50
+ Without any credentials the server is GET-only (read tools succeed, mutating
51
+ tools get `403`).
52
+
53
+ ## Tools
54
+
55
+ ### Read tools
56
+
57
+ | Tool | Description |
58
+ | --- | --- |
59
+ | `list_jobs` | List jobs matching the given filters. |
60
+ | `list_jobs_overview` | List a condensed jobs overview matching the given filters. |
61
+ | `get_job` | Get full details for a single job. |
62
+ | `get_job_comments` | List comments on a job. |
63
+ | `list_machines` | List configured worker machines. |
64
+ | `list_test_suites` | List configured test suites. |
65
+ | `list_products` | List configured products (mediums). |
66
+ | `find_jobs_by_setting` | Find jobs whose setting `key` equals `list_value`. |
67
+
68
+ `list_jobs` and `list_jobs_overview` accept the same optional filters:
69
+ `state`, `result`, `distri`, `version`, `build`, `test`, `arch`, `machine`,
70
+ `groupid`, `group`, `latest`, `limit`, `page`, `ids`. Unset filters are
71
+ dropped from the request.
72
+
73
+ ### Mutating tools (require credentials)
74
+
75
+ | Tool | Description |
76
+ | --- | --- |
77
+ | `restart_jobs` | Restart each of the given jobs. |
78
+ | `cancel_job` | Cancel a running or scheduled job. |
79
+ | `add_job_comment` | Add a comment to a job. |
80
+ | `trigger_isos` | Trigger ISO test scheduling for a product. |
81
+ | `delete_job` | Delete a job. |
82
+
83
+ Mutating tools carry the `mutating` tag so MCP clients can gate them behind
84
+ confirmation. To drop them entirely, start the server in read-only mode with
85
+ `--readonly` (or `OPENQA_READONLY=true`): the mutating tools are never
86
+ registered, so clients see only the read tools.
87
+
88
+ ## Running
89
+
90
+ ### stdio (default)
91
+
92
+ Most local MCP clients spawn the server over stdio. Wire it in with:
93
+
94
+ ```sh
95
+ uv run openqa-mcp
96
+ ```
97
+
98
+ Example MCP client configuration:
99
+
100
+ ```json
101
+ {
102
+ "mcpServers": {
103
+ "openqa": {
104
+ "command": "uv",
105
+ "args": ["run", "openqa-mcp"],
106
+ "env": {
107
+ "OPENQA_SERVER": "openqa.opensuse.org"
108
+ }
109
+ }
110
+ }
111
+ }
112
+ ```
113
+
114
+ ### HTTP (optional)
115
+
116
+ For remote or shared deployments, run over HTTP with `--http`:
117
+
118
+ ```sh
119
+ uv run openqa-mcp --http --server 127.0.0.1 --port 8000
120
+ ```
121
+
122
+ The server can also be launched as a module:
123
+
124
+ ```sh
125
+ uv run python -m openqa_mcp --http --port 8000
126
+ ```
127
+
128
+ | Flag | Default | Purpose |
129
+ | --- | --- | --- |
130
+ | `--http` | off | Serve over HTTP instead of stdio. |
131
+ | `--stdio` | on | Serve over stdio; overrides `OPENQA_MCP_TRANSPORT=http`. |
132
+ | `--server` | `127.0.0.1` | HTTP bind host. |
133
+ | `--port` | `8000` | HTTP bind port. |
134
+ | `--readonly` | off | Unregister all mutating tools (read-only server). |
135
+
136
+ Flags override the environment, which supplies the defaults:
137
+
138
+ | Variable | Default | Purpose |
139
+ | --- | --- | --- |
140
+ | `OPENQA_MCP_TRANSPORT` | `stdio` | Set to `http` to serve over HTTP. |
141
+ | `OPENQA_MCP_HOST` | `127.0.0.1` | Default HTTP bind host. |
142
+ | `OPENQA_MCP_PORT` | `8000` | Default HTTP bind port. |
143
+ | `OPENQA_READONLY` | `false` | Set truthy (`1`/`true`/`yes`/`on`) to disable mutating tools. |
144
+
145
+ Press `Ctrl-C` to stop; the server shuts down cleanly and closes its client.
146
+
147
+ ## Development
148
+
149
+ ```sh
150
+ uv run pytest # run the test suite
151
+ uv run ruff check . # lint
152
+ ```
@@ -0,0 +1,31 @@
1
+ [project]
2
+ name = "openqa-mcp"
3
+ version = "0.1.0"
4
+ description = "Local openQA mcp client"
5
+ readme = "README.md"
6
+ requires-python = ">=3.13"
7
+ dependencies = [
8
+ "fastmcp>=3.4.3",
9
+ "openqa-async>=0.1.0",
10
+ ]
11
+
12
+ [project.scripts]
13
+ openqa-mcp = "openqa_mcp.__main__:main"
14
+
15
+ [build-system]
16
+ requires = ["uv_build>=0.11.26,<0.12"]
17
+ build-backend = "uv_build"
18
+
19
+ [dependency-groups]
20
+ dev = [
21
+ "pytest",
22
+ "pytest-asyncio",
23
+ "respx",
24
+ "httpx",
25
+ "ruff>=0.15.20",
26
+ "ty>=0.0.56",
27
+ ]
28
+
29
+ [tool.pytest.ini_options]
30
+ asyncio_mode = "auto"
31
+ testpaths = ["tests"]
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
@@ -0,0 +1,91 @@
1
+ """Command-line entry point for the openQA MCP server.
2
+
3
+ Selects the transport and, for HTTP, the bind address. Flags override the
4
+ environment; the environment (``OPENQA_MCP_TRANSPORT``/``HOST``/``PORT``)
5
+ supplies the defaults. Run with ``openqa-mcp`` or ``python -m openqa_mcp``.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import argparse
11
+ import os
12
+
13
+ from .server import disable_mutating_tools, mcp
14
+
15
+
16
+ def _env_flag(name: str) -> bool:
17
+ """Interpret an environment variable as a boolean toggle.
18
+
19
+ Truthy values (case-insensitive): ``1``, ``true``, ``yes``, ``on``.
20
+ Anything else (including unset) is false.
21
+ """
22
+ return os.environ.get(name, "").strip().lower() in {"1", "true", "yes", "on"}
23
+
24
+
25
+ def build_parser() -> argparse.ArgumentParser:
26
+ """Build the CLI parser.
27
+
28
+ ``--http``/``--stdio`` are mutually exclusive; neither (nor
29
+ ``OPENQA_MCP_TRANSPORT=http``) means stdio. ``--server``/``--port`` set the
30
+ HTTP bind address, defaulting from ``OPENQA_MCP_HOST``/``OPENQA_MCP_PORT``.
31
+ """
32
+ parser = argparse.ArgumentParser(
33
+ prog="openqa-mcp",
34
+ description="Run the openQA MCP server over stdio (default) or HTTP.",
35
+ )
36
+ transport = parser.add_mutually_exclusive_group()
37
+ transport.add_argument(
38
+ "--http",
39
+ action="store_true",
40
+ help="serve over HTTP instead of stdio",
41
+ )
42
+ transport.add_argument(
43
+ "--stdio",
44
+ action="store_true",
45
+ help="serve over stdio (default; overrides OPENQA_MCP_TRANSPORT=http)",
46
+ )
47
+ parser.add_argument(
48
+ "--server",
49
+ dest="host",
50
+ default=os.environ.get("OPENQA_MCP_HOST", "127.0.0.1"),
51
+ help="HTTP bind host (default: %(default)s, or OPENQA_MCP_HOST)",
52
+ )
53
+ parser.add_argument(
54
+ "--port",
55
+ type=int,
56
+ default=int(os.environ.get("OPENQA_MCP_PORT", "8000")),
57
+ help="HTTP bind port (default: %(default)s, or OPENQA_MCP_PORT)",
58
+ )
59
+ parser.add_argument(
60
+ "--readonly",
61
+ action="store_true",
62
+ default=_env_flag("OPENQA_READONLY"),
63
+ help="disable all mutating tools (default: OPENQA_READONLY)",
64
+ )
65
+ return parser
66
+
67
+
68
+ def main() -> None:
69
+ """Parse arguments and run the server on the selected transport."""
70
+ args = build_parser().parse_args()
71
+
72
+ if args.readonly:
73
+ disable_mutating_tools()
74
+
75
+ # An explicit --stdio wins; otherwise --http or the env toggle selects HTTP.
76
+ http = not args.stdio and (
77
+ args.http or os.environ.get("OPENQA_MCP_TRANSPORT") == "http"
78
+ )
79
+ try:
80
+ if http:
81
+ mcp.run(transport="http", host=args.host, port=args.port)
82
+ else:
83
+ mcp.run()
84
+ except KeyboardInterrupt:
85
+ # Ctrl-C: the lifespan's finally block already closed the client as the
86
+ # async context unwound; swallow the traceback and exit cleanly.
87
+ pass
88
+
89
+
90
+ if __name__ == "__main__":
91
+ main()
@@ -0,0 +1,101 @@
1
+ """Shared ``AsyncOpenQAClient`` construction and FastMCP lifespan wiring.
2
+
3
+ ``get_client`` builds a client from environment configuration, optionally
4
+ overriding the API credentials baked in by ``openqa-async``'s config-file
5
+ loading. ``lifespan`` exposes a single shared client to the server for the
6
+ duration of its run.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import os
12
+ from collections.abc import AsyncIterator
13
+ from contextlib import asynccontextmanager
14
+ from dataclasses import dataclass
15
+
16
+ import httpx
17
+ from openqa_async.aclient import AsyncOpenQAClient
18
+
19
+ _FALSE_TOKENS = frozenset({"0", "false", "no"})
20
+ _TRUE_TOKENS = frozenset({"1", "true", "yes"})
21
+
22
+
23
+ def _parse_verify(raw: str | None) -> bool | str:
24
+ """Map ``OPENQA_VERIFY`` to an httpx ``verify`` value.
25
+
26
+ Bool-ish tokens toggle verification; any other non-empty value is
27
+ treated as a path to a CA bundle. Unset defaults to ``True``.
28
+ """
29
+ if raw is None:
30
+ return True
31
+ token = raw.strip()
32
+ lowered = token.lower()
33
+ if lowered in _FALSE_TOKENS:
34
+ return False
35
+ if lowered in _TRUE_TOKENS:
36
+ return True
37
+ if token:
38
+ return token # CA bundle path
39
+ return True
40
+
41
+
42
+ def _apply_env_credentials(client: AsyncOpenQAClient) -> None:
43
+ """Override the client's API credentials from the environment.
44
+
45
+ Only applied when both ``OPENQA_API_KEY`` and ``OPENQA_API_SECRET``
46
+ are set, avoiding a half-configured client that signs with an empty
47
+ secret.
48
+
49
+ NOTE (library-internal touch-point): ``AsyncOpenQAClient`` bakes auth
50
+ into ``self.client`` at construction time -- the httpx client's static
51
+ ``X-API-Key`` header comes from ``_apikey`` via ``_default_headers()``
52
+ and the HMAC auth from ``apisecret`` via ``_build_auth()``. Assigning
53
+ the credentials alone does nothing until ``client.client`` is rebuilt
54
+ the same way the constructor does. Keep this the only place that
55
+ reaches into these private attributes; prefer ``client.conf`` in docs.
56
+ """
57
+ api_key = os.environ.get("OPENQA_API_KEY")
58
+ api_secret = os.environ.get("OPENQA_API_SECRET")
59
+ if not (api_key and api_secret):
60
+ return
61
+
62
+ client._apikey = api_key
63
+ client.apisecret = api_secret
64
+ client.client = httpx.AsyncClient(
65
+ base_url=client.baseurl,
66
+ headers=client._default_headers(),
67
+ auth=client._build_auth(),
68
+ trust_env=True,
69
+ verify=client.verify,
70
+ )
71
+
72
+
73
+ def get_client() -> AsyncOpenQAClient:
74
+ """Build a shared ``AsyncOpenQAClient`` from environment configuration.
75
+
76
+ Reads ``OPENQA_SERVER`` (empty falls back to the openqa-async default),
77
+ ``OPENQA_VERIFY``, and optionally ``OPENQA_API_KEY`` /
78
+ ``OPENQA_API_SECRET`` to override config-file credentials.
79
+ """
80
+ server = os.environ.get("OPENQA_SERVER", "")
81
+ verify = _parse_verify(os.environ.get("OPENQA_VERIFY"))
82
+ client = AsyncOpenQAClient(server=server, verify=verify)
83
+ _apply_env_credentials(client)
84
+ return client
85
+
86
+
87
+ @dataclass
88
+ class AppContext:
89
+ """Shared state made available to tools for the server's lifetime."""
90
+
91
+ client: AsyncOpenQAClient
92
+
93
+
94
+ @asynccontextmanager
95
+ async def lifespan(server: object) -> AsyncIterator[AppContext]:
96
+ """Open a single shared client on startup, close it on shutdown."""
97
+ client = get_client()
98
+ try:
99
+ yield AppContext(client=client)
100
+ finally:
101
+ await client.aclose()
@@ -0,0 +1,246 @@
1
+ """Curated openQA MCP tools.
2
+
3
+ Each tool is a thin, typed wrapper over ``AsyncOpenQAClient.openqa_request``:
4
+ it drops ``None`` params and returns the parsed dict/list body. One-line
5
+ docstrings become the MCP tool descriptions. Mutating tools are tagged
6
+ ``mutating`` and require API credentials (openQA answers ``403`` without them).
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import asyncio
12
+ from typing import Any, cast
13
+
14
+ from fastmcp import Context, FastMCP
15
+ from openqa_async.aclient import AsyncOpenQAClient
16
+
17
+ from .client import AppContext, lifespan
18
+
19
+ #: Tag marking tools that mutate openQA state; stripped in read-only mode.
20
+ MUTATING_TAG = "mutating"
21
+
22
+ mcp = FastMCP(
23
+ "openQA",
24
+ instructions=(
25
+ "Query and control an openQA instance. Read tools inspect jobs, "
26
+ "machines, test suites, and products; mutating tools (tagged "
27
+ "'mutating') restart/cancel/delete jobs, comment, and trigger ISOs, "
28
+ "and require API credentials."
29
+ ),
30
+ lifespan=lifespan,
31
+ )
32
+
33
+
34
+ def _client(ctx: Context) -> AsyncOpenQAClient:
35
+ """Return the shared client from the lifespan context."""
36
+ return cast(AppContext, ctx.lifespan_context).client
37
+
38
+
39
+ def _drop_none(params: dict[str, Any]) -> dict[str, Any]:
40
+ """Strip keys whose value is ``None`` so they are not sent as query params."""
41
+ return {k: v for k, v in params.items() if v is not None}
42
+
43
+
44
+ def _api(path: str) -> str:
45
+ """Prefix a REST endpoint with ``api/v1/``.
46
+
47
+ ``openqa-async`` joins request paths straight onto the server host, so
48
+ the ``/api/v1`` prefix that fronts every REST endpoint must be supplied
49
+ here; without it requests hit non-existent web-UI routes and 404.
50
+ """
51
+ return f"api/v1/{path}"
52
+
53
+
54
+ # --------------------------------------------------------------------------- #
55
+ # READ tools #
56
+ # --------------------------------------------------------------------------- #
57
+
58
+
59
+ @mcp.tool
60
+ async def list_jobs(
61
+ ctx: Context,
62
+ state: str | None = None,
63
+ result: str | None = None,
64
+ distri: str | None = None,
65
+ version: str | None = None,
66
+ build: str | None = None,
67
+ test: str | None = None,
68
+ arch: str | None = None,
69
+ machine: str | None = None,
70
+ groupid: int | None = None,
71
+ group: str | None = None,
72
+ latest: int | None = None,
73
+ limit: int | None = None,
74
+ page: int | None = None,
75
+ ids: list[int] | None = None,
76
+ ) -> dict | list:
77
+ """List jobs matching the given filters."""
78
+ params = _drop_none(
79
+ {
80
+ "state": state,
81
+ "result": result,
82
+ "distri": distri,
83
+ "version": version,
84
+ "build": build,
85
+ "test": test,
86
+ "arch": arch,
87
+ "machine": machine,
88
+ "groupid": groupid,
89
+ "group": group,
90
+ "latest": latest,
91
+ "limit": limit,
92
+ "page": page,
93
+ "ids": ids,
94
+ }
95
+ )
96
+ return await _client(ctx).openqa_request("GET", _api("jobs"), params=params)
97
+
98
+
99
+ @mcp.tool
100
+ async def list_jobs_overview(
101
+ ctx: Context,
102
+ state: str | None = None,
103
+ result: str | None = None,
104
+ distri: str | None = None,
105
+ version: str | None = None,
106
+ build: str | None = None,
107
+ test: str | None = None,
108
+ arch: str | None = None,
109
+ machine: str | None = None,
110
+ groupid: int | None = None,
111
+ group: str | None = None,
112
+ latest: int | None = None,
113
+ limit: int | None = None,
114
+ page: int | None = None,
115
+ ids: list[int] | None = None,
116
+ ) -> dict | list:
117
+ """List a condensed jobs overview matching the given filters."""
118
+ params = _drop_none(
119
+ {
120
+ "state": state,
121
+ "result": result,
122
+ "distri": distri,
123
+ "version": version,
124
+ "build": build,
125
+ "test": test,
126
+ "arch": arch,
127
+ "machine": machine,
128
+ "groupid": groupid,
129
+ "group": group,
130
+ "latest": latest,
131
+ "limit": limit,
132
+ "page": page,
133
+ "ids": ids,
134
+ }
135
+ )
136
+ return await _client(ctx).openqa_request(
137
+ "GET", _api("jobs/overview"), params=params
138
+ )
139
+
140
+
141
+ @mcp.tool
142
+ async def get_job(ctx: Context, job_id: int) -> dict | list:
143
+ """Get full details for a single job."""
144
+ return await _client(ctx).openqa_request("GET", _api(f"jobs/{job_id}"))
145
+
146
+
147
+ @mcp.tool
148
+ async def get_job_comments(ctx: Context, job_id: int) -> dict | list:
149
+ """List comments on a job."""
150
+ return await _client(ctx).openqa_request("GET", _api(f"jobs/{job_id}/comments"))
151
+
152
+
153
+ @mcp.tool
154
+ async def list_machines(ctx: Context) -> dict | list:
155
+ """List configured worker machines."""
156
+ return await _client(ctx).openqa_request("GET", _api("machines"))
157
+
158
+
159
+ @mcp.tool
160
+ async def list_test_suites(ctx: Context) -> dict | list:
161
+ """List configured test suites."""
162
+ return await _client(ctx).openqa_request("GET", _api("test_suites"))
163
+
164
+
165
+ @mcp.tool
166
+ async def list_products(ctx: Context) -> dict | list:
167
+ """List configured products (mediums)."""
168
+ return await _client(ctx).openqa_request("GET", _api("products"))
169
+
170
+
171
+ @mcp.tool
172
+ async def find_jobs_by_setting(ctx: Context, key: str, list_value: str) -> dict | list:
173
+ """Find jobs whose setting ``key`` equals ``list_value``."""
174
+ params = {"key": key, "list_value": list_value}
175
+ return await _client(ctx).openqa_request(
176
+ "GET", _api("job_settings/jobs"), params=params
177
+ )
178
+
179
+
180
+ # --------------------------------------------------------------------------- #
181
+ # MUTATING tools (require credentials; 403 without) #
182
+ # --------------------------------------------------------------------------- #
183
+
184
+
185
+ @mcp.tool(tags={MUTATING_TAG})
186
+ async def restart_jobs(ctx: Context, job_ids: list[int]) -> list:
187
+ """Restart each of the given jobs (requires credentials)."""
188
+ client = _client(ctx)
189
+ results = []
190
+ for job_id in job_ids:
191
+ results.append(
192
+ await client.openqa_request("POST", _api(f"jobs/{job_id}/restart"))
193
+ )
194
+ return results
195
+
196
+
197
+ @mcp.tool(tags={MUTATING_TAG})
198
+ async def cancel_job(ctx: Context, job_id: int) -> dict | list:
199
+ """Cancel a running or scheduled job (requires credentials)."""
200
+ return await _client(ctx).openqa_request("POST", _api(f"jobs/{job_id}/cancel"))
201
+
202
+
203
+ @mcp.tool(tags={MUTATING_TAG})
204
+ async def add_job_comment(ctx: Context, job_id: int, text: str) -> dict | list:
205
+ """Add a comment to a job (requires credentials)."""
206
+ return await _client(ctx).openqa_request(
207
+ "POST", _api(f"jobs/{job_id}/comments"), data={"text": text}
208
+ )
209
+
210
+
211
+ @mcp.tool(tags={MUTATING_TAG})
212
+ async def trigger_isos(
213
+ ctx: Context,
214
+ distri: str,
215
+ version: str,
216
+ flavor: str,
217
+ arch: str,
218
+ extra: dict[str, str] | None = None,
219
+ ) -> dict | list:
220
+ """Trigger ISO test scheduling for a product (requires credentials)."""
221
+ body = {"DISTRI": distri, "VERSION": version, "FLAVOR": flavor, "ARCH": arch}
222
+ if extra:
223
+ body.update(extra)
224
+ return await _client(ctx).openqa_request("POST", _api("isos"), data=body)
225
+
226
+
227
+ @mcp.tool(tags={MUTATING_TAG})
228
+ async def delete_job(ctx: Context, job_id: int) -> dict | list:
229
+ """Delete a job (requires credentials)."""
230
+ result = await _client(ctx).openqa_request("DELETE", _api(f"jobs/{job_id}"))
231
+ # A 204 No Content yields a raw httpx Response, not a dict/list; normalize.
232
+ return result if isinstance(result, (dict, list)) else {}
233
+
234
+
235
+ def disable_mutating_tools() -> list[str]:
236
+ """Unregister every tool tagged ``mutating`` (read-only mode).
237
+
238
+ ``FastMCP.list_tools`` is a coroutine but does no real I/O here, so it is
239
+ safe to drive with ``asyncio.run`` from the synchronous CLI before
240
+ ``mcp.run`` starts its own event loop. Returns the removed tool names.
241
+ """
242
+ tools = asyncio.run(mcp.list_tools())
243
+ removed = [t.name for t in tools if MUTATING_TAG in t.tags]
244
+ for name in removed:
245
+ mcp.local_provider.remove_tool(name)
246
+ return removed