openqa-mcp 0.1.0__py3-none-any.whl
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.
- openqa_mcp/__init__.py +1 -0
- openqa_mcp/__main__.py +91 -0
- openqa_mcp/client.py +101 -0
- openqa_mcp/server.py +246 -0
- openqa_mcp-0.1.0.dist-info/METADATA +161 -0
- openqa_mcp-0.1.0.dist-info/RECORD +8 -0
- openqa_mcp-0.1.0.dist-info/WHEEL +4 -0
- openqa_mcp-0.1.0.dist-info/entry_points.txt +3 -0
openqa_mcp/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
openqa_mcp/__main__.py
ADDED
|
@@ -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()
|
openqa_mcp/client.py
ADDED
|
@@ -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()
|
openqa_mcp/server.py
ADDED
|
@@ -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
|
|
@@ -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,8 @@
|
|
|
1
|
+
openqa_mcp/__init__.py,sha256=kUR5RAFc7HCeiqdlX36dZOHkUI5wI6V_43RpEcD8b-0,22
|
|
2
|
+
openqa_mcp/__main__.py,sha256=W4nGmb-0NPJNcvNF0kTLzP95swVLgsWg_RwGQsRUG1k,2888
|
|
3
|
+
openqa_mcp/client.py,sha256=wzslDDudSieh0UN1E_w74lxPO5sZXRX2_o8R6nXXIFU,3374
|
|
4
|
+
openqa_mcp/server.py,sha256=yplE1hT29JzkHth2GDo5h6wfkQ4s7YZtY--Wd6cYPn8,7954
|
|
5
|
+
openqa_mcp-0.1.0.dist-info/WHEEL,sha256=uOqnPWqgFlbov4NeTCercq7cBQ2UN7xh5fiW55lOnAg,81
|
|
6
|
+
openqa_mcp-0.1.0.dist-info/entry_points.txt,sha256=UAAZK9lOv7Kh-h22BEUkQV4zPKhTcXQQamOeLQ2kokQ,57
|
|
7
|
+
openqa_mcp-0.1.0.dist-info/METADATA,sha256=Ld_JGrJwo4xNawXQGhIPeCsETyqnHd4nvKToFCOypQs,4842
|
|
8
|
+
openqa_mcp-0.1.0.dist-info/RECORD,,
|