An MCP server that exposes curated, typed tools over the openQA REST API. It is built on rmcp and the ruoqa openQA client.
Read tools work anonymously; mutating tools require API credentials and
return 403 without them.
cargo install ruoqa-mcpor from a checkout:
cargo install --path .or build and run directly:
cargo build --release
./target/release/ruoqa-mcpor run the container image with Docker/podman — no Rust toolchain required.
The server reads its configuration from environment variables, falling back to the openQA client config file for credentials.
| Variable | Default | Purpose |
|---|---|---|
OPENQA_SERVER |
(unset) | openQA host(s) (e.g. openqa.opensuse.org), or a comma-separated list to talk to several at once (e.g. openqa.suse.de,openqa.opensuse.org). Empty falls back to ruoqa's client.conf discovery. |
OPENQA_API_KEY |
(unset) | API key, read by ruoqa itself; overrides the config file when set. Only valid with a single configured server — see below. |
OPENQA_API_SECRET |
(unset) | API secret, read by ruoqa itself; overrides the config file when set. Only valid with a single configured server — see below. |
OPENQA_USERNAME |
(unset) | openQA username; switches from HMAC signing to personal-access-token (Bearer) auth, sending Authorization: Bearer <username>:<key>:<secret> instead of X-API-* headers. Requires OPENQA_API_KEY/OPENQA_API_SECRET (or a matching client.conf section). Only valid with a single configured server. Rejected over plaintext http:// to a non-loopback host. |
OPENQA_VERIFY |
true |
TLS verification: true/false, or a path to a PEM CA bundle. See the warning below before using false. |
OPENQA_MCP_TIMEOUT |
30.0 |
Per-request HTTP timeout (seconds) for openQA calls; raise for slow queries like large latest=1 failed-job lists. <=0 disables the timeout. Empty/unset uses the default; unparseable, NaN, infinite, or out-of-range values abort startup. |
OPENQA_MCP_CALL_TIMEOUT |
300.0 |
Whole-tool-call deadline (seconds), independent of OPENQA_MCP_TIMEOUT; bounds a slow upstream regardless of which tool is waiting on it. <=0 disables it. Empty/unset uses the default; unparseable, NaN, infinite, or out-of-range values abort startup. |
OPENQA_API_KEY and OPENQA_API_SECRET must both be set together; setting
only one is a startup error. With 2 or more servers configured in
OPENQA_SERVER, setting either at all is also a startup error: there is no
way for a process-wide env var to apply to only some of the servers, so
multi-server credentials must live in client.conf's per-[host] sections
instead (see Config file). The same rule applies to
OPENQA_USERNAME: there is no per-host syntax for it yet, so it too is a
startup error with 2 or more configured servers.
Every tool call takes a mandatory server argument naming which configured
server to use — openqa.suse.de and openqa.opensuse.org are additionally
selectable by the short aliases osd and o3. Call list_servers to see the
full set for the running instance; with a single OPENQA_SERVER entry (or
none), there is exactly one valid value.
OPENQA_VERIFY set to a path loads that file as the only trusted CA
bundle (it replaces the platform trust store rather than merging with it),
matching httpx's verify=<path> semantics. If a deployment relies on merging
a custom CA with the platform roots, this is stricter than before.
OPENQA_VERIFY=falsedisables certificate verification for every openQA request. Any certificate is then accepted, so anything able to intercept the connection can read and replay the API key, the signed request, and every response. Prefer pointingOPENQA_VERIFYat your company or self-signed CA bundle instead — that keeps verification on while trusting your own root. Treatfalseas a last resort for throwaway debugging on a network you control, never as a deployment setting.The server deliberately does not refuse to start in this mode: whether it is acceptable is the operator's call.
ruoqaseparately logs a warning when credentials are sent over plaintexthttp://to a non-loopback host — run withRUST_LOG=warnto see it, since the default filter passes only errors.
Keeping a long-running server's configuration in a shell profile is awkward, so
every variable in this README — the ones above, the HTTP ones below, and
RUST_LOG — may instead live in ~/.env, read once at startup. The same is
true of the OTEL_* variables in docs/observability.md:
load_home_env() runs before both clap and telemetry initialize, so
~/.env reaches them just as well.
umask 077
cat >> ~/.env <<'EOF'
# comments and blank lines are ignored
OPENQA_SERVER=openqa.opensuse.org
OPENQA_MCP_HTTP_TOKEN="a-token-may-be-quoted"
EOFFor more than one server, use a comma-separated list instead:
OPENQA_SERVER=openqa.suse.de,openqa.opensuse.org.
A variable that is already exported always wins over the file, so ~/.env
supplies defaults rather than overrides. Only this fixed path is read — never a
.env in the working directory, because a daemon must not pick up credentials
from wherever it happened to be started. A missing or unreadable ~/.env is not
an error. The file usually holds secrets, so keep it mode 0600.
If the env credentials are not set, ruoqa falls back to a tiered
client.conf lookup: $OPENQA_CONFIG first, then
$XDG_CONFIG_HOME/openqa (or ~/.config/openqa if that's unset), then
/etc/openqa and /usr/etc/openqa. The first tier that has any file (a
client.conf and/or client.conf.d/*.conf drop-ins) wins outright — a user
config replaces /etc/openqa/client.conf rather than merging with it. An
empty tier (no files at all) falls through to the next one, so an unset or
empty $OPENQA_CONFIG directory does not exclude /etc.
Generate a key/secret from the API keys page of your openQA instance and add a section keyed by the host:
[openqa.opensuse.org]
key = YOUR_API_KEY
secret = YOUR_API_SECRETWithout any credentials the server is GET-only (read tools succeed, mutating
tools get 403).
Every tool below also takes a mandatory server argument (omitted from the
descriptions for brevity) — see Environment variables.
| Tool | Description |
|---|---|
list_servers |
List the openQA servers this MCP instance is configured to talk to. Takes no other arguments. |
list_jobs |
List jobs matching the given filters. Pass summary=true for a compact triage breakdown. |
list_jobs_overview |
List a condensed jobs overview matching the given filters. Pass summary=true for a compact triage breakdown. |
get_job |
Get full details for a single job. Optional ancestors/descendants (bool) add the job's restart-chain counts; sent as ?ancestors=1/?descendants=1 only when true, ignored by older openQA servers. |
get_job_comments |
List comments on a job. |
list_machines |
List configured worker machines. |
list_test_suites |
List configured test suites. |
list_products |
List configured products (mediums). |
find_jobs_by_setting |
Find jobs whose setting key equals list_value. |
get_job_details |
Get a single job with full test-module/step details. |
get_job_status |
Get a lightweight job status (id, state, result, blocked_by_id). |
list_job_groups |
List job groups. |
get_job_group |
Get a single job group. |
list_job_group_jobs |
List jobs belonging to a job group. |
get_job_group_build_results |
Get aggregated build results for a job group. |
list_parent_groups |
List parent job groups. |
get_parent_group |
Get a single parent job group. |
list_assets |
List assets known to the system. |
get_asset |
Get a single asset by id. |
list_workers |
List registered worker instances. Optional reserved (bool, sent as 0/1), limit, offset. |
list_bugs |
List tracked bugs referenced by jobs. |
search |
Full-text search across jobs, groups, and test modules. |
get_scheduled_product |
Get a scheduled product (result of a prior ISO trigger). |
get_iso_job_stats |
Get job statistics for scheduled products. |
list_group_comments |
List comments on a job group. |
list_parent_group_comments |
List comments on a parent job group. |
list_job_logs |
List a job's downloadable log files and uploaded (ulog) files. |
list_job_log_members |
List the members of a job log archive (tar, tar.gz, tar.xz). |
get_job_log |
Read a job log or uploaded file, optionally tailed, grepped, or extracted from an archive. |
get_job_log_errors |
Digest a job's logs down to the failure signal: the first matching tier of serial_terminal.txt TFAIL/TBROK, autoinst-log.txt "Test died", a generic fallback, or the tail — plus the failing module(s) with #step deep links, and (test_died tier only) a location with the running step and any stack trace. |
get_step_needles |
Return one test step's screenshot and its best-scoring needle candidates as MCP image content, after a JSON block naming them. module and step are required (from get_job_log_errors' failed_modules); max_candidates (default 1, at most 3) sets how many needle images follow the screenshot. |
list_jobs and list_jobs_overview accept the same optional filters:
state, result, distri, version, build, test, arch, machine,
groupid, group, latest, limit, ids. list_jobs additionally accepts
offset for pagination (the overview endpoint returns only the latest job per
scenario and is not paginated). Unset filters are dropped from the request.
ids accepts at most 500 entries (each becomes a repeated ids= query
parameter; more would risk a 414 from nginx's default request-line limit).
Both also accept summary (default false). The default full result can be
very large (~1.5 MB / 150+ jobs for a populated build) and may be truncated by
MCP clients. Pass summary=true for a compact per-result breakdown:
{
"total": 156,
"by_result": {"passed": 57, "softfailed": 61, "failed": 7, "...": 0},
"by_state": {"done": 136, "cancelled": 20},
"by_arch": {"x86_64": 78, "aarch64": 39, "s390x": 39},
"jobs": {"failed": [{"id": 1, "test": "install", "arch": "x86_64"}], "...": []}
}Jobs bucket by result; in-progress jobs (result none) bucket by state
(e.g. running, scheduled). To work with the full data instead, save it to
a temporary file and process it with jq, e.g.
jq '.jobs[] | select(.result=="failed")'.
list_job_logs, list_job_log_members, and get_job_log reach
GET /tests/<id>/file/<filename> and /tests/<id>/downloads_ajax, not
/api/v1/: openQA serves job logs and uploaded (ulogs) files from a plain
Mojolicious::Static route, both flat regardless of whether the file is a
built-in result file or an uploaded log. list_job_logs prefers the small
downloads_ajax fragment and falls back to the ~14 MB jobs/<id>/details
response if that route is unavailable or parses empty; the reply's source
field says which one answered. get_job_log's tail_lines is served by an
absolute byte range (bytes=<start>-), never a suffix range
(bytes=-<n>): openQA's Mojolicious::Static mishandles the latter,
returning the head of the file labelled as a 206 tail. Both the raw
download and any decompressed archive content are bounded by a 32 MiB
ceiling (max_bytes may lower it, never raise it); a binary artifact (e.g.
a video) is refused as unsupported_media rather than returned as mangled
text. list_job_logs intentionally covers logs and ulogs only, returning
just name and kind (result or ulog) per entry — it does not surface
assets (/tests/<id>/asset/…), sizes, or URLs, so its listing is not an
exhaustive artifact inventory; note that video.webm classes as
kind: "result", since it comes from the results section rather than the
"Uploaded logs" heading list_job_logs splits on.
get_job_log_errors collapses "which log, and what in it" into one call by
checking, in priority order, serial_terminal.txt for LTP/TAP-style
TFAIL/TBROK (the actual verdict for serial-console-driven frameworks,
never duplicated into autoinst-log.txt), then autoinst-log.txt for
"Test died" or the worker's own terminal verdict (Result: setup failure/api-failure/worker broken/timeout/died, for an abnormal
termination that never goes through a module death, e.g. an asset failing
to download before any test module runs), then a generic error/timeout
fallback, then just the last 30 lines — the first tier that matches wins,
and each reply is bounded to a fixed line budget regardless of the log's
size. serial_terminal.txt is
probed first (and only) because it is where a real product-assertion
failure shows up for those frameworks; a job that never wrote one (checked
via /details) skips the probe entirely rather than costing a 404. Unlike
get_job_log, a non-UTF-8 byte is decoded lossily instead of refused as
unsupported_media — serial_terminal.txt is raw console output that
routinely carries stray non-UTF-8 bytes, exactly the jobs this tier exists
for. Pass markers (a list of regexes) to scan filename (default
autoinst-log.txt) for something else entirely instead of the tier chain.
The tool also fetches /api/v1/jobs/<id>/details (up to ~14 MB,
best-effort) for the failed_modules step deep links; a failed fetch just
means that field is omitted, never an aborted digest. On the test_died
tier, a reply also carries location when the log has it: step (the last
[step:<category>,<name>,<n>] debug line before the failure) and stack
(the --- # stack trace frames os-autoinst appends to the die message,
capped at 10 frames). Older or less verbose logs have neither, and
location is omitted rather than sent empty.
get_step_needles fetches /api/v1/jobs/<id>/details, then the step's
screenshot (/tests/<id>/images/<name>) and its needle PNGs
(/needles/<distri>/<name>.png), and returns them as MCP image content
after a JSON summary whose images list names each block in order. The
matched needle, if the step has one, comes first; the remaining candidates
are ordered by mean similarity, then name. Each image is capped at 4 MiB.
A needle whose image can't be fetched (e.g. deleted or renamed since the
job ran) is reported as that candidate's image_error and the call still
succeeds; a screenshot that can't be
fetched, isn't a PNG, or is missing fails the call. Every candidate carries
an image_url too, for clients that don't render images.
Like the read tools, every tool below also takes a mandatory server
argument.
| Tool | Description |
|---|---|
restart_jobs |
Restart the given jobs in one bulk request. |
cancel_job |
Cancel a running or scheduled job. |
add_job_comment |
Add a comment to a job. |
trigger_isos |
Trigger ISO test scheduling for a product. |
delete_job |
Delete a job. |
duplicate_job |
Duplicate (clone) a job. |
set_job_priority |
Set the priority of a job. |
cancel_jobs |
Cancel jobs matching the given filters; at least one filter is required. |
add_group_comment |
Add a comment to a job group. |
add_parent_group_comment |
Add a comment to a parent job group. |
update_job_comment |
Update an existing job comment. |
delete_job_comment |
Delete a job comment. |
create_bug |
Create a tracked bug reference. |
cancel_scheduled_product |
Cancel a scheduled product / ISO by name. |
Mutating tools carry destructiveHint/readOnlyHint MCP annotations so
clients can gate them behind confirmation. To drop them entirely, start the
server in read-only mode with --readonly (or OPENQA_READONLY=true): the
mutating tools are never registered, so clients see only the read tools.
restart_jobs sends a single bulk request to openQA regardless of how many
ids are given, so job_ids is capped at 1-500 entries. Partial success (e.g.
one id missing its assets) is reported by openQA itself in the response's
result/errors/warnings fields rather than as an MCP error. trigger_isos's
extra map is capped at 100 entries (each becomes a scheduled-product/job-settings
row); individual values stay unbounded to allow an inline
SCENARIO_DEFINITIONS_YAML document. extra keys may not collide,
case-insensitively, with distri/version/flavor/arch or with each other.
A tool call fails one of two ways:
- The tool ran and openQA (or the network) said no. The MCP call still
succeeds (
isError: true), with a caller-visible payload:{"error": {"kind", "status"?, "message", "body"?}}.bodyis openQA's response body, truncated to 512 bytes.kindis one of:unauthorized,forbidden,not_found,rate_limited,bad_request,server_error,connection,timeout,response_too_large,invalid_response,unsupported_media(aget_job_logartifact that isn't text, e.g. a video or image),audit_unavailable(the audit stream cannot persist and the configured fail mode refuses the call — openQA was never asked). - The server itself is misconfigured or refused to route the request
(bad
client.conf, TLS setup failure, incomplete credentials, a cross-origin or outside-base-URL request). This is a JSON-RPCinternal_error, which most MCP clients render opaquely.
OPENQA_MCP_CALL_TIMEOUT firing is reported as a kind: "timeout" tool
error, not a protocol error: the tool call may have reached openQA, so an
in-flight write may already have been applied.
Most local MCP clients spawn the server over stdio. Wire it in with:
ruoqa-mcpExample MCP client configuration:
{
"mcpServers": {
"openqa": {
"command": "ruoqa-mcp",
"env": {
"OPENQA_SERVER": "openqa.opensuse.org"
}
}
}
}For multiple servers, set OPENQA_SERVER to a comma-separated list (e.g.
"openqa.suse.de,openqa.opensuse.org") and pass server on every tool call.
For remote or shared deployments, run over HTTP with --transport http. HTTP
callers authenticate with a bearer token, so generate one first:
export OPENQA_MCP_HTTP_TOKEN=$(openssl rand -hex 32)
ruoqa-mcp --transport http --server 127.0.0.1 --port 8000The MCP endpoint is mounted at /mcp; clients send
Authorization: Bearer <token> with every request.
Unlike stdio — where the client already owns the process — HTTP exposes the server's single openQA credential to anyone who can reach the port, so authentication is mandatory and deny-by-default. Two tokens define two scopes:
| Token | Scope | Tools |
|---|---|---|
OPENQA_MCP_HTTP_TOKEN |
write | all 45 read + mutating tools |
OPENQA_MCP_HTTP_READ_TOKEN |
read | the 31 read tools only |
Either may be set alone. A read-scope caller sees only the read tools in
tools/list and gets an MCP error — with no openQA request made — if it calls a
mutating tool anyway; the split is derived from each tool's readOnlyHint
annotation, so it cannot drift from the tool registry. Because the advertised
tool set depends on the credential, a client that caches tools/list across
tokens will show a stale list.
Tokens are never accepted as command-line flags: argv is world-readable via
ps. Like every other variable, they may come from ~/.env instead of
the environment:
umask 077
printf 'OPENQA_MCP_HTTP_TOKEN=%s\n' "$(openssl rand -hex 32)" >> ~/.envThe server refuses to start (before binding the port) when:
--transport httpis given with no token and no--insecure-no-auth;--insecure-no-authis combined with a token;- a token is shorter than 32 characters, or contains anything but printable non-space ASCII;
- the read token equals the write token;
- the
--allowed-hostflag is given without--transport http(the same value from the environment or~/.envis simply ignored by a stdio run).
Tokens set while running over stdio are ignored.
The transport is plaintext HTTP. A bearer token sent over it is readable by anything on the path, so never expose the port beyond a trusted network without terminating TLS in front of it (reverse proxy, service mesh, or an SSH tunnel).
Static bearer tokens are not MCP's OAuth 2.1 authorization flow. Clients that only implement the spec's
401→ resource-metadata → OAuth dance will not authenticate; use a client that lets you set a header.
To block DNS rebinding, requests are accepted only for a known authority:
localhost, 127.0.0.1 and ::1 always, plus every --allowed-host value.
Anything else gets 403. Name the public authority explicitly when the server
is not reached over loopback — the bind address is deliberately not treated as
an identity, so binding 0.0.0.0 allows nothing extra:
ruoqa-mcp --transport http --server 0.0.0.0 --allowed-host mcp.example.com:8000| Flag | Default | Purpose |
|---|---|---|
--transport |
stdio |
Transport to serve on: stdio or http. |
--http |
off | Deprecated alias for --transport http. |
--stdio |
off | Deprecated alias for --transport stdio. |
--server |
127.0.0.1 |
HTTP bind host. |
--port |
8000 |
HTTP bind port. |
--allowed-host |
(none) | Extra authority accepted in the Host header; repeatable. |
--insecure-no-auth |
off | Serve HTTP with no authentication at all; prints a warning on start. |
--readonly |
off | Unregister all mutating tools (read-only server). |
--audit-config |
(none) | Path to the audit-stream TOML file; auditing is off when unset. See Observability. |
--version |
— | Print version and exit. |
Flags override the environment, which supplies the defaults (and which
~/.env in turn supplies defaults for):
| Variable | Default | Purpose |
|---|---|---|
OPENQA_MCP_TRANSPORT |
stdio |
Default for --transport when it isn't given; set to http to serve over HTTP. An unrecognised value aborts startup. |
OPENQA_MCP_HOST |
127.0.0.1 |
Default HTTP bind host. |
OPENQA_MCP_PORT |
8000 |
Default HTTP bind port. |
OPENQA_MCP_HTTP_TOKEN |
(unset) | Bearer token granting the write scope. |
OPENQA_MCP_HTTP_READ_TOKEN |
(unset) | Bearer token granting the read scope. |
OPENQA_MCP_ALLOWED_HOSTS |
(unset) | Comma-separated default for --allowed-host. |
OPENQA_READONLY |
false |
Set truthy (1/true/yes/on) to disable mutating tools. |
OPENQA_MCP_AUDIT_CONFIG |
(unset) | Default for --audit-config. |
OPENQA_MCP_HEARTBEAT_INTERVAL |
15.0 |
Seconds between progress "heartbeat" pings sent while a tool waits on a slow openQA call, so MCP clients see liveness instead of timing out. Set <=0 to disable. Pings are a no-op unless the client sent a progressToken. Empty/unset uses the default; unparseable, NaN, infinite, or out-of-range values abort startup. |
--readonly and the read token are different levers: --readonly is
process-wide and unregisters the mutating tools for every caller, including
stdio; the read token restricts one HTTP principal while others keep write
access.
Press Ctrl-C to stop; the server shuts down cleanly on both transports.
The published image (ghcr.io/mimi1vx/ruoqa-mcp) is HTTP-only in practice —
it defaults OPENQA_MCP_TRANSPORT=http, binds 0.0.0.0:8000, and runs as
nonroot on a distroless base with no shell:
docker run -e OPENQA_SERVER=openqa.example.com \
-e OPENQA_MCP_HTTP_TOKEN=$(openssl rand -hex 32) \
-p 8000:8000 ghcr.io/mimi1vx/ruoqa-mcpOr with the bundled compose.yaml:
OPENQA_MCP_HTTP_TOKEN=$(openssl rand -hex 32) docker compose upGotchas specific to the image:
-
No token, no server. Same rule as any other run: the container exits
1before binding a port ifOPENQA_MCP_HTTP_TOKEN/OPENQA_MCP_HTTP_READ_TOKEN/--insecure-no-authare all absent. This is the designed fail-closed behaviour, not a bug. -
Binding
0.0.0.0grants no extraHostauthority. The image binds wide because loopback-only is useless in a container, but a non-loopback client still needsOPENQA_MCP_ALLOWED_HOSTSset to the authority it connects as (seeHostallowlist). -
Credentials: mount a
client.confat/etc/openqa/client.conf(works regardless ofHOME) or passOPENQA_API_KEY+OPENQA_API_SECRETtogether — setting only one is a startup error:docker run -v ./client.conf:/etc/openqa/client.conf:ro \ -e OPENQA_MCP_HTTP_TOKEN=... -p 8000:8000 \ ghcr.io/mimi1vx/ruoqa-mcp -
Auditing needs a writable volume. The image ships
/var/lib/ruoqa-mcpowned bynonroot(uid 65532) for the audit file; mount a volume there alongside the audit-config file. A named volume inherits that ownership, a bind mount needs the host directory owned by 65532 already. -
OPENQA_VERIFY=/path/ca.pemreplaces the platform trust store, it does not merge with it — mount the CA bundle read-only alongsideclient.confand point the variable at the in-container path:docker run -v ./ca.pem:/etc/ssl/custom/ca.pem:ro \ -e OPENQA_VERIFY=/etc/ssl/custom/ca.pem \ -e OPENQA_MCP_HTTP_TOKEN=... -p 8000:8000 \ ghcr.io/mimi1vx/ruoqa-mcp
There is no /health endpoint or HEALTHCHECK (the distroless base has no
shell or curl to run one); orchestrators should use a TCP check or an
authenticated GET /mcp from outside the container.
Two independent, opt-in streams: a JSONL audit log of every tool call, and OpenTelemetry logs/traces/metrics over OTLP/HTTP. Both are off unless configured, and turning one on has no effect on the other.
- Audit stream: set
--audit-config/OPENQA_MCP_AUDIT_CONFIGto a TOML file naming apathto append to. - OTLP export: set
OTEL_EXPORTER_OTLP_ENDPOINT(or a per-signal_LOGS/_TRACES/_METRICS_ENDPOINT) to a collector.
Two things worth knowing before enabling either: the exported audit stream is
exactly as sensitive as the file it mirrors, since it carries comment text
and other arguments verbatim; and OTEL_EXPORTER_OTLP_HEADERS is a
credential, so it is environment-only and never logged.
See docs/observability.md for the full variable
and key tables, the record schema, and the fail-mode semantics.
cargo test # run the test suite
cargo clippy --all-targets -- -D warnings # lint
cargo fmt --check # format check