Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

MCP server

mtui-mcp is a Model Context Protocol server that exposes mtui’s functionality as tools an LLM client can call. Its tool surface is synthesised from the same command registry the mtui REPL uses: each non-denied command becomes one tool, with a JSON input schema derived from the command’s clap argument spec, dispatched through the same engine as the REPL. Adding, renaming, or removing a command changes the MCP tools automatically. Two commands, get and put, are denied synthesis and re-served under the same names as hand-written tools that carry file content in-band (see File transfer tools) — a path on the server’s filesystem would be meaningless to a remote --transport http client, and is never needed on stdio either. The underlying semantics of each tool are the command semantics in the Command reference.

Building and running

The server lives behind the mcp feature, enabled by default alongside the mtui REPL’s cli feature (see Installation):

cargo run -p mtui --bin mtui-mcp -- --help

Two transports are served:

  • stdio (default) — one process == one client. Use this for a local LLM client that spawns the server as a subprocess.

    mtui-mcp                       # stdio
    
  • streamable HTTP — many clients, with per-client session isolation.

    mtui-mcp --transport http --host 127.0.0.1 --port 8000
    

    --host/--port bind the HTTP listener and are ignored under stdio. The bind address is loopback only — rmcp’s DNS-rebinding guard rejects non-loopback binds.

Logs go to stderr; stdout is the transport, so never write to it.

Tool profiles

The advertised tool surface can be narrowed with [mcp] profile:

  • full (default) — every synthesised tool.
  • core — a curated everyday subset; the rest are filtered out before they reach the wire.

Fine-tune with [mcp] tools_allow (add specific tools on top of the profile) and [mcp] tools_deny (remove specific tools; deny wins last). See the [mcp] table in Configuration for these and the resource caps (max_output_bytes, max_active_jobs, session budget, …).

Agent clients should prefer core: the full surface (65 tools) is sent on every request, while core (36 tools) covers the everyday flow — load → inspect → run/install → fill the report → approve/reject — without the host bookkeeping and server-tuning tail. If a workflow never lists the queue or polls openQA, deny those on top:

[mcp]
profile = "core"
tools_deny = ["updates", "openqa_overview"]

Security boundary

Profiles are not authentication or authorization. They reduce the advertised tool surface; they do not gate callers. HTTP session isolation is likewise not caller authentication. Keep the HTTP transport on its default loopback interface, or place it behind an authenticated boundary you trust to operate the remaining maintenance tools.

Permanent deny-list

Some commands are never synthesised into MCP tools, under every profile — this cannot be re-enabled with [mcp] tools_allow:

  • The interactive/REPL-only commands, which require a controlling terminal or have no headless meaning: quit, exit, EOF, switch, shell, help, edit.
  • The path-based transfer commands get and put, replaced by the hand-written in-band tools of the same names (#434): their synthesized forms exchanged server-local paths a remote client cannot read or create. (Only the synthesized command forms are permanently gone; the replacement hand-written get/put are ordinary tools — present in full, restorable under core via tools_allow, removable via tools_deny.)

Local process execution and terminal launching are not on this list because they are not in mtui at all. The former lrun (run a command as the local process user) was removed outright — an MCP client already has its own local execution, and a REPL user is already at a shell. The former terms (spawn a terminal emulator per refhost) went the same way: it duplicated shell (#566).

The deny-list is intersected with the live registry and consistency-tested; a renamed or removed command that leaves a stale deny-list entry is warned about at boot, so the boundary cannot drift silently.

config_show filtering

config_show prints less over MCP than at the REPL (#410). The REPL operator owns the machine and can read mtui.toml directly, so it prints every resolved value verbatim (secrets as <set>). A headless client gets only what it needs to operate the session: endpoint URLs with embedded userinfo stripped and the operational tunables/flags. The no-argument bulk dump is refused headlessly — name the attribute(s) explicitly — as are the operator-local values (session_user, template_dir, refhosts_path, and a ssl_verify CA-bundle path; its true/false form is shown). Values config show never lists (the oscrc location, the OBS API URL, lock/history paths) reach neither surface through this tool.

Session state and isolation

Session state is isolated per client. Under stdio one process serves one client, so there is exactly one config / loaded template set / group of connected hosts. Under HTTP each connected client gets its own isolated session (its own config view, loaded templates, and hosts), so concurrent clients never see each other’s state. Clients load their own state at runtime via the load_template and add_host tools — mtui-mcp takes no boot-time update/host flags (see Invocation).

The HTTP registry refuses to create more than [mcp] session_cap concurrent sessions and reaps a session after [mcp] session_idle_timeout seconds of inactivity (disconnecting its hosts — the SDK gives no per-session teardown callback, so this sweep is what releases a vanished client’s SSH connections).

This isolation depends on rmcp’s legacy Mcp-Session-Id session lifecycle, so over HTTP mtui-mcp advertises protocol revisions 2024-11-05 through 2025-11-25 and declines 2026-07-28: that revision removes protocol-level sessions, and rmcp’s transport layer classifies any non-initialize request carrying complete 2026-07-28 metadata as stateless regardless of config, mints a throwaway server for it, and serves it inline — which would tear down and rebuild the per-client McpSession (its SSH connections, pool claims) on every such request. A client asking for it over HTTP falls back to the latest revision mtui-mcp does support.

Over stdio there is one client per process and no per-request session to protect, so that inline lifecycle is harmless: mtui-mcp advertises 2026-07-28 there too, so a client that opens with server/discover at that revision succeeds instead of being refused.

Multiple templates: scoping and fan-out

A session can hold several loaded templates at once (call load_template more than once; loading an already-loaded RRID reloads it). list_templates lists the set, and each template keeps its own test report and SSH host group.

Every command that acts on a loaded template carries the shared -T/--template and --all-templates flags, so its synthesised tool exposes two optional parameters. The session-level tools — load_template, unload, list_templates, list_refhosts, updates, config_show/config_set, whoami, set_log_level — expose neither, and regenerate exposes only template:

  • template="<RRID>" — scope this one call to a single loaded template (the analogue of the REPL -T flag). An unknown RRID returns a clean error.
  • all_templates=true — force fan-out across every loaded template.
  • all_templates=false — suppress fan-out on a tool whose command fans out by default, narrowing it to one template instead.

A client still sending template/all_templates to unload, list_templates, list_refhosts, updates, whoami or set_log_level, or all_templates to regenerate, has them ignored with a server-log warning until 26.5. load_template refuses them now; config_show/config_set never took them.

Omitting both parameters resolves per command: a read/annotate tool (list_* other than the session-level ones above, show_*, openqa_*, checkers, checkout, export, set_workflow, comment) fans out across every loaded template, prefixing each template’s output with an === <RRID> === banner. A host-mutating or remote-write tool (update, prepare, downgrade, install, uninstall, set_repo, run, reboot, lock, unlock, add_host, remove_host, approve, reject, assign, unassign, request_review, commit, plus the hand-written get/ put transfer tools) never implicitly fans out: with several templates loaded and neither parameter set, the call is refused rather than guessed at — the error names the loaded RRIDs and the template=/all_templates=true escape hatches. This is the same rule the report_* tools use for their own required-with-several-loaded template parameter (below).

A fanned-out call that fails on one template keeps running on the others and reports an aggregate failure at the end.

switch is not an MCP tool (moving the active-template pointer is REPL-only navigation): over MCP you target a template per call with template=. load_template and unload <rrid> are exposed — each names its own RRID. list_templates is available as a read-only listing.

Non-interactive prompt behaviour

MCP sessions run with no interactive prompter installed, so a command that would prompt at the REPL never blocks waiting for input:

  • approve refuses non-interactively on a Gitea checkout-hash mismatch (at the REPL it prompts for confirmation, default no) unless accept_head names the PR head as Gitea reports it now — the refusal prints that head. The value is a pin, checked even when the report is not stale, and the LGTM is refused again if the head moves before it is posted. A missing Gitea token or a failed Gitea call refuses on every surface, since the check never produced a verdict to confirm. The reviewer and document checks, the Slack gate and the backend’s own checks (Gitea: hash gate, assignment, already decided; OBS: -g, assignment) all run before reviewer records or uploads anything, so a refusal from them publishes nothing. The OBS verdict check cannot: it reads the uploaded document, and a Gitea head that moves during the upload is refused at the LGTM with the reviewer already stored.
  • load_template’s interactive “Force continue loading template ?” fallback — for a stale checkout hash that TeReGen also refuses to regenerate (already hand-edited) — is exposed as the --force-continue argument; without it the load is unconditionally abandoned, since the question’s non-interactive default is “no” (openSUSE/mtui#517). A force-continued load neither repairs nor bypasses the recorded mismatch, so it does not touch approve’s refusal above — and commit, export, reject --reviewer and request_review refuse the same way, unless given their own --allow-stale, so a stale-loaded report cannot be published or overwritten non-interactively either. approve’s check_hash() re-query is live (asks Gitea again, every call) and replaces that load-time verdict for approve -r; the others instead read whether the load itself saw a mismatch, so a report that loaded clean and only goes stale once the session is already open (a maintainer pushes to the PR while mtui-mcp still holds it loaded) passes them silently — commit is the one where that gap is most likely to matter, since it is the publish step.
  • A command timeout aborts immediately instead of offering the REPL’s wait/retry prompt.
  • comment and commit take their text/message as a required argument (no editor/stdin prompt), and regenerate gates overwrite on the --force flag rather than a prompt.

SSH authentication is public-key only in every surface. A failed key auth reports the host as unreachable; mtui-mcp never falls back to a password prompt.

File transfer tools

The hand-written get and put tools replace the synthesized commands of the same names (#434), carrying content in-band in both directions. The REPL get/put commands work on literal paths instead, with downloads landing in {report_wd}/downloads/{name}.{host}.

get (read-only)

Downloads remote (an absolute file path; folders are not supported in-band) from every enabled host and returns, per host, the remote size, a truncated flag, and the content: UTF-8 as content, binary as content_b64 (standard base64). Reads are capped per host at [mcp] max_input_bytes, enforced while reading — a host never buffers more than the cap (0 disables it and reads the whole file) — and each host gets an equal share of the [mcp] max_output_bytes wire budget. With a cap set, size is the file size the server reports when the file is opened, so it can differ from the returned bytes if the file changes during the read; a server that reports no size fails the call, naming the host, rather than being read without a bound. Without a cap, size is the number of bytes read. Truncation never splices a notice into the content: the truncated flag and size are the signal. Pass hosts (a list of connected hostnames) to retry or page a subset when one host’s file was truncated; unknown or disabled names refuse the call. Any host failure fails the whole call with the host named — partial content is never returned as success.

put

Uploads a payload carried in the call — content (UTF-8) or content_b64, exactly one of the two — to <target tempdir>/<filename> on every enabled host, the same destination the REPL put uses. filename must be a bare name (no path separators). A payload above [mcp] max_input_bytes is refused outright: truncating an upload would corrupt it. Any host failure fails the call with the host named.

Command behaviour notes

  • The prepare tool fails — instead of reporting success — when the loaded report’s metadata names no package versions, and when no prepare command could be built for a connected host (#396), so automation keying on prepare’s success does not see a false positive on a metadata-empty report.
  • On a report whose metadata carries a binaries block, prepare narrows the package list per host to what that host’s products compose, and fails by name a host whose products compose none of it — rather than sending a list zypper refuses with “capability not found” (104). An architecture a product declares but the metadata never mentions composes nothing, not the full list, so it gets the same named refusal. update excludes such a host from its patch, scopes its own lock/repository/package-check to the hosts that do compose it, and reports a failure naming it, instead of patching a host its own prepare established no baseline on.
  • update’s tool result carries diagnostics for every non-fatal degradation of the run — a composition refusal, a host excluded because its test update repository genuinely failed to register (its partial add is undone and its lock released before the patch reaches its peers), a repository refresh that may have left stale metadata on a host that still gets patched, a prepare host failure it continues past, a failed --newpackage step, a failed update leaving the test repositories configured, a failed automatic rollback, and the three ways the post-update test-repo cleanup can fail — on both the success and the failure path. The server’s tracing goes to its own stderr, not the tool result, so a tracing::warn! alone would never reach an MCP client.
  • Every workflow fan-out (install, uninstall, prepare, downgrade, update) confirms success as <verb> completed on <hosts>, so a clean run is never an empty tool result. update’s confirmation is printed above its diagnostics — the head of the buffer is what survives max_output_bytes — and is qualified whenever the run recorded a degradation: update completed on h1: the patch passed its checks, with 2 degradations reported below. The count is of degradations only; a check’s own recognised sections (the vendor-support notice, extra rpm output) ride in the same diagnostics and are routine on a healthy update, so they leave the confirmation bare.
  • add_host without -t follows added <hosts> (or no reference hosts resolved/connected) with one line per test-target slot or testplatform it left without a host and why: no matching refhost, every candidate claimed by another RRID, no candidate connected (unreachable or claimed remotely), the retry budget spent, or the refhosts inventory unavailable. The call still returns Ok in that case, so these lines are the signal to check.
  • export emits WARNING: no package version data recorded for <host>... lines in its tool output when a host has no recorded package data and its install-result block was therefore left unverified in the report.
  • run prints its own verdict above its per-host output, for the same head-survives-max_output_bytes reason: run completed on h1 (exit 0), h2 (exit 1) names every host it ran on with its exit code, followed by FAILED on h2 (exit 1) when any host exited non-zero. run returns Ok on a non-zero remote exit — a non-zero exit is often expected — so this verdict, not the tool’s success/failure status, is the signal to check.
  • The run tool’s command is argv tokens, not a shell line: the tokens are re-quoted before dispatch, so ["cat /etc/os-release"] reaches the host as one quoted word (exit 127) and ["zypper","lr","|","grep","x"] passes a literal | as an argument. Split the words yourself (["cat","/etc/os-release"]), and for anything needing a shell — pipes, redirection, ; — ask for one explicitly: ["sh","-c","zypper lr | grep x"].

Report document tools

Seven hand-written tools work on the loaded report as a document: five read and edit it as named sections and issues rather than lines of text, and two list and read its files. Each accepts an optional template="<RRID>" selecting which loaded template to act on; with more than one template loaded an unscoped call is refused — the same “one template, or refuse” rule described under Multiple templates: scoping and fan-out — and with zero or one loaded it may be omitted. All refuse cleanly when no report is loaded.

The sections are verdict, comment, people, update, install, issues, testing and review; review is absent from a PI report. Reads never change the report.

report_sections (read-only)

  • Parameters: template (optional).
  • Returns { "id", "dirty", "sections": [{"name","size","has_unanswered"}], "complete", "unfilled": [...] }. size is the byte length of the section’s compact JSON (advisory). unfilled holds the RFC 6901 pointer of every null leaf in the whole document, in sorted key order; complete is true when there is none. dirty is true while edits await commit.

report_section_read (read-only)

  • Parameters (required): section. Plus optional template.
  • Returns { "id", "section", "data" }. A section the report lacks refuses.

report_section_write

Replaces one tester section with value.

  • Parameters (required): section (verdict, comment, people, issues, testing or review), value (any JSON — null is a legitimate value for an unanswered verdict or comment). Plus optional template.
  • update and install are pipeline-owned and refused. A section the report lacks refuses. An issues write must keep exactly the same issue keys; use report_issue_write for one entry.
  • The whole candidate document is re-parsed against the typed schema, and a value carrying a key the schema would drop (an unknown key in a closed object, or null on an optional field) is refused. A refusal lists the offending pointers and leaves the report untouched. String patterns and array minimums are not checked locally, so the server can still refuse at commit.
  • A write changes local state only and marks the report dirty; commit uploads it. A later export overwrites testing.install, testing.openqa and people.testers.
  • Returns { "id", "section", "dirty": true, "size" }.

report_issue_read (read-only)

  • Parameters (all optional): issue_id (such as bsc#1234567), template.
  • Without issue_id, returns { "id", "issues": [{"id","title","status", "severity"}] }; with it, { "id", "issue_id", "issue" }.

report_issue_write

Replaces one existing issue with value; the set of issues cannot grow or shrink. Validated and applied exactly like report_section_write.

  • Parameters (required): issue_id, value. Plus optional template.
  • Returns { "id", "issue_id", "dirty": true }.

report_files (read-only)

Lists the report directory’s files — build_checks/, the install_logs directory, results/ and checkers.log — merged by file name with the files teregen holds for the report.

  • Parameters: template (optional).
  • Returns { "id", "files": [{"path","name","size","server"}], "server_only": [{"name","origin","size","at"}] }, plus "server_error" when the server listing failed. files are the local regular files, sorted by path (report-directory-relative; subdirectories and symlinks are skipped). server is { "origin", "size", "at" } for the server’s file of that name (origin is pipeline or uploaded), or null when it has none; sizes differ until commit uploads. server_only lists server files with no local copy.
  • A failed server listing (not found, still generating, unreachable) never fails the call: the local list is returned with server_error set.

report_file_read (read-only)

Reads one of those files as text.

  • Parameters (required): path, relative to the report directory — either checkers.log or a file directly inside build_checks/, the install_logs directory or results/. Optional: offset (1-based first line, default 1), limit (maximum lines), template.
  • Returns { "id", "path", "total_lines", "content" }; a windowed read also carries offset and returned_lines. content is bounded by [mcp] max_output_bytes, and the read stops at [mcp] max_input_bytes (0 disables the cap), cutting a line that crosses it. A capped read ends with a notice, and total_lines then counts only the lines read up to the cap; the size of the unread rest is unknown.
  • Refuses a path that escapes the report directory (.., an absolute path, a symlink out), one outside the readable set, and a missing file. A file the server lists but the report directory lacks is refused with a distinct message: server-side files cannot be fetched yet (needs teregen T15).

A result larger than [mcp] max_output_bytes comes back as { "truncated": true, "size", "content" }, where content is the capped JSON text, rather than as clipped (invalid) JSON.

Background jobs

Long-running commands run as background jobs so a tool call returns promptly with a job id you can poll. The slow host commands — run, update, downgrade, prepare, install, uninstall, set_repo, reboot, regenerate, add_host, and load_template — accept a background=true parameter. Instead of holding the request open for the minutes the operation takes, the call returns immediately with the job id(s); when the command fans out across several templates it mints one job per template, each independently pollable and cancellable.

add_host and load_template -a connect to a whole batch of reference hosts, and one unreachable candidate among them can hold the call open far past any client’s patience — backgrounding is the only way to keep such a call cancellable via job_cancel.

Four job-control tools manage them:

  • job_list (read-only) — every job in the session and its state.
  • job_status(job_id, wait_seconds=0) (read-only) — one job’s state (running / done / failed / cancelled) and elapsed time; wait_seconds blocks for a terminal state first.
  • job_result(job_id, wait_seconds=0) (read-only) — a finished job’s captured output; it errors while the job is still running (or wait for it with wait_seconds) and surfaces the command’s failure envelope if it failed.
  • job_cancel(job_id) — cancel a running job. (A command already executing on a host may run to completion there even after cancel returns — the same caveat as Ctrl-C on a foreground run.) Two commands treat a cancel as a normal ending rather than a failure: request_review --watch stops watching (the request stays posted) and regenerate stops waiting (the server keeps building). Both return success, so their job ends done, not cancelled, with the reply text saying what was and was not finished.

wait_seconds (0-120, default 0) turns a poll loop into one held request: job_status and job_result park until the job is terminal or the budget lapses, then reply exactly as a plain poll would — the running snapshot, the output, or a “still running” error that now points at wait_seconds. Keep it under the client’s own request timeout; a value above 120 is refused, not clamped. While parked the call emits progress heartbeats if the client supplied a progressToken, and a notifications/cancelled interrupts it. Over HTTP rmcp additionally pings the per-request SSE stream every 15s; a client that silently disconnects instead leaves the wait to run out its budget server-side, holding nothing.

A job blocked mid host-operation cannot stop at a checkpoint, so cancelling it force-aborts the dispatch — which skips the operation’s own unlock(). A forced cancel therefore releases /var/lock/mtui.lock on the job’s behalf and reports the outcome in its reply.

What it releases is deliberately narrow: only the locks the cancelled job’s own host group actually took, and never a comment-marked (exclusive) hold such as a Product Increment assignment lock or an operator’s lock -c <text> reservation. Locks are owned per user + PID on the wire, so a broader sweep would remove a sibling template’s — or another MCP session’s — live hold on a shared refhost and report it as released.

The reply distinguishes: which hosts are now unlocked; which are still locked and by whom (check with list_locks; unlock --force releases the whole group of every loaded template); which release failed (check with list_locks and retry unlock once the host is reachable); and which templates could not be reached inside the release budget, whose lock state is therefore unknown. A cancel with no held lock to act on says nothing extra.

The job’s template scope is recorded when the job is minted. A job started without a resolvable scope falls back to every loaded template — the conservative reading for a dispatch that may have run across all of them.

Nothing else is done at the hosts. The remote command may still be running there; where it is a package transaction, the package manager’s own system-wide lock keeps serialising it, but a run or reboot has no such second layer and the release is purely mtui-side bookkeeping.

A cooperative cancel — a body that unwound at a checkpoint — is unchanged: it ran its own unlock discipline, and the cancel does not second-guess it.

Jobs are scoped to the session (under HTTP, the caller’s isolated session, so one client never sees another’s jobs). The per-session job budget (max_active_jobs, max_completed_jobs) and the single-result size cap (max_output_bytes) bound resource use so one client cannot exhaust the server or dwarf the client’s context.

Audit log

mtui-mcp executes consequential actions with nobody watching, so a configured server keeps a durable record of what ran. Set [mcp] audit_log to a file path; unset (the default) disables auditing and leaves behaviour byte-identical.

The sink is versioned JSONL, one object per line, opened O_APPEND with mode 0600 and fsynced before the response returns — entries survive a server restart and are never truncated. The open is O_NOFOLLOW and the resulting descriptor is vetted: anything that is not a regular file owned by the serving user is refused, so a planted symlink or FIFO cannot divert the append (or the 0600 tightening) onto another file. O_NOFOLLOW covers the path’s final component only — the sink’s directory must be writable by the serving user alone. A same-user hardlink is allowed on purpose; it crosses no boundary. The record is durable and append-only but not tamper-evident: there is no hash chain and no HMAC, so anyone writing as the owner can rewrite history undetected. Tamper evidence means shipping each record off-host as it lands — see OTLP log export. An outcome record carries the schema version, the arrival timestamp, the sequence number, the session id, the transport, the tool name, the (redacted, see below) arguments, the outcome (ok / error / unknown-tool), the duration, and the RRIDs and host names the call resolved to. An intent record carries the same header and the same arguments, and nothing that only exists afterwards. A terminal record carries no arguments at all.

Host names on a record are best-effort: they are empty when the session is busy with an exclusive background job (a backgrounded load_template, add_host or config_set), because a record write never waits on the work it records; rrids are always recorded.

[mcp] audit_log_max_bytes (default 256 MiB) bounds the sink: the first open that finds the file at or over the cap rotates it to <path>.1, shifting .1….4 down one and discarding what was .5. That is usually an append, but the start-up probe opens the sink the same way, so a server can rotate before it answers anything. Archiving beyond .5 is the operator’s job. 0 disables rotation altogether; any other value below 4096 is raised to it with a warning, because a cap under one record’s worth rotates the trail away.

Rotation is housekeeping and never a gate: it neither blocks the dispatch worker nor refuses a call. Two servers may share one path, so it is serialised by an advisory lock on the file itself — taken without waiting. A writer that finds the lock held, or a filesystem that cannot take it at all (an NFS export without lockd answers ENOLCK), appends past the cap and rotates on a later call. A rename it cannot perform warns and keeps appending too, and that warning is not repeated until a rotation succeeds. Two consequences worth knowing when reading the files back: a writer that opened the sink just under the cap lands its line in <path>.1, so seq is monotonic overall but the order across the boundary file is not; and on NFS, Linux emulates flock with per-process POSIX locks, so two HTTP sessions in one process do not serialise there — at worst a second shift, never a refused call.

A tool that is not advertised readOnlyHint writes an intent record before it runs — schema version, arrival timestamp, sequence number, session, transport, event: "intent", tool, (redacted) arguments, and the trace correlation when the client supplied one. Nothing else, because nothing else exists yet. Its outcome record then carries intent: <seq>, and its duration_ms — measured from arrival, like ts — includes the intent record’s own write. Read-only tools skip the intent and write only the outcome record, so a poll loop still costs one record per call.

A backgrounded call therefore writes three records: the intent, a dispatch record naming the started job_ids, and a terminal record when the job reaches done / failed / cancelled, joinable by job id. The terminal record carries no arguments and no intent — it joins its dispatch by job_id, which also matters because a terminal record’s seq is not ordered against the calls around it in either direction: neither against its own dispatch record, since a job that settles at once can be minted first, nor against a job_result(wait_seconds) reply that waited for it. Join by job_id, never by seq.

A configured sink is probed once at start-up, before the OTLP probe: if it cannot be opened the server refuses to start and names the path on stderr, rather than loading quietly and failing on the first tool call.

Afterwards the sink fails in two different ways, on purpose. If the intent write fails the call is refused and nothing ran — the guarantee the sink exists to make. If the outcome write fails the work has already happened, so the executed result is still returned with [audit: outcome record lost (<reason>)] appended — as a trailing text block on a successful reply, or to the message of an error one. Discarding a result to report a logging failure would tell the client a refusal that never happened. The notice is always the last content block, so content[0] stays the verbatim payload a --json caller parses, and it is not counted in the record’s response_bytes. A failed terminal write can only warn — its dispatch already answered.

Reading the log back: an intent that no later record points at means “started, outcome not recorded” — a crash or a kill, or a lost outcome write whose client was told in band (and which the OTLP stream may still hold).

config_set never records the value for any attribute, so a future secret attribute cannot leak by omission; the record marks whether the attribute is a known secret. File-body payloads (put content/content_b64, report_section_write/report_issue_write value) never land verbatim either: each records {bytes, sha256} over the original string, so a credentials file or SSH key uploaded via put stays correlatable without being persisted. Always fingerprinted, never inline, regardless of size — the same redaction feeds the file body, the OTLP body, and the size accounting, and no payload key is ever an indexed OTLP attribute.

Everything else is masked, not trusted. Every recorded string, at any nesting — run and comment argv included — runs through a token scanner that strips URL userinfo (the same sanitize_url the rest of mtui logs through) and replaces the value of a secret-named flag or key: --password secret, DB_PASSWORD=…, x-api-key: …, Authorization: Bearer …, Authorization: token … and '{"password":"…"}' all record <redacted>, through the quoting a shell line or a JSON payload wraps them in, while token_bucket=50, max_tokens=100, grep token file and ssh -p 22 host are left legible. Bearer, Basic and token keep their place in front of the credential they name — but only after a key that is itself a credential name, so Basic functionality verified stays a sentence.

The scanner keys on the name beside the value, so what it does not catch is worth knowing rather than discovering: a bare secret with no key beside it; a single-letter flag, whose meaning is per-program (sshpass -p secret, curl -u user:pass, psql -U user); a value attached to one (-pSECRET, -U user%pass); and anything passed by environment variable, stdin or a file the tool reads.

All file I/O runs on the blocking pool (spawn_blocking), so a down or slow disk never stalls the dispatch worker or the other calls it is serving — the one call that waits is the one whose own records are being written.

OTLP log export

The same record can also go to an OpenTelemetry collector as OTLP/HTTP LOGS (hand-rolled protobuf over the workspace reqwest/rustls stack — no opentelemetry-* crates): one log record per audit event, body = the verbatim JSONL line (already redacted and fingerprinted as above). Configuration is env-only (headers must never be CLI flags), so there are no new TOML keys:

  • OTEL_EXPORTER_OTLP_ENDPOINT (base URL, gains /v1/logs) or OTEL_EXPORTER_OTLP_LOGS_ENDPOINT (full URL, wins verbatim). Unset-or-empty disables export, as does an invalid endpoint or a non-http/protobuf protocol (validated only when an endpoint exists).
  • OTEL_EXPORTER_OTLP_HEADERS / OTEL_EXPORTER_OTLP_LOGS_HEADERS (key=value,..., values percent-decoded; logs-specific wins).
  • OTEL_EXPORTER_OTLP_PROTOCOL / OTEL_EXPORTER_OTLP_LOGS_PROTOCOL: only http/protobuf or unset.
  • OTEL_SERVICE_NAME (default mtui): resource service.name, the multi-deployment join key.

Sink matrix: file-only (audit_log set, no endpoint), OTLP-only (endpoint set, audit_log unset — the JSONL line is still built in memory and used verbatim), both (file first in seq order, then OTLP), neither (auditing off, dispatch byte-identical).

Each record carries closed mtui.* attributes (tool, event, seq, transport, session.id, plus outcome when there is one, response_bytes when sized, and allowlisted kwarg keys) and the W3C trace ids when the client supplied a strict lowercase 55-byte traceparent via _meta (also echoed as the record’s trace field; session.id stays server-minted). A startup probe posts one real diagnostics record (~5x500 ms) before serving and latches export health; failed batches merge into a pending audit_gap sent on recovery. Batching: 2048/stream cap, 512/batch, 500 ms interval, 10 s request timeout, 5 s shutdown flush, redirects off, [mtui] ssl_verify TLS posture.

Export never gates a tool call. The health latch decides whether a record is enqueued, nothing more: an unhealthy or full exporter logs a warning, merges the record’s seq into the audit_gap it will report on recovery, and the call still runs and still answers. The file sink stays the durable truth, so OTLP-only mode never refuses anything. Tracing diagnostics ride a separate best-effort queue (drop + counter; exporter/HTTP-stack targets excluded). The endpoint value never appears in logs, records, or errors.

Cancelling a foreground call

A client that sends an explicit notifications/cancelled for an in-flight foreground tool call drops the dispatch and releases the CommandLock it was holding — a follow-up call on the same RRID is not left queued behind it. What happens next depends on whether the call could be holding a host operation lock.

A report_* tool, a transfer tool (get/put) or a parked job_status/job_result wait never dispatches through the engine, so it cannot hold /var/lock/mtui.lock: the cancel drops the dispatch immediately and the tool call resolves to an error rather than a fabricated success.

A synthesised command tool (run, update, install, …) can be mid host-operation when the cancel arrives, and dropping it outright would strand that lock exactly as an unhandled job_cancel would. It instead runs the same two-stage sequence job_cancel uses: the dispatch’s own cancellation token is signalled first, giving it a short grace period to unwind at a checkpoint (a body that does — for example one already watching the session’s cancel token — returns its own verdict, success or failure, not a synthetic cancellation error). Only if the grace elapses is the dispatch force-aborted; the abort then best-effort releases /var/lock/mtui.lock on every host the call’s own group actually took (never a comment-marked reservation) and the error text names what it unlocked, what is still locked and by whom, and what could not be reached inside the release budget.

This only helps a client that sends the notification. mtui-mcp’s default transport is stdio, where there is no per-request connection to drop — a client that simply stops waiting (e.g. because it hit its own timeout) sends nothing, and the server has no way to learn the caller gave up. In that case the lock is still released, just on the ordinary schedule: the connect_timeout budget (commit A) and the shared backup-retry budget bound how long that takes rather than leaving it unbounded. Backgrounding a slow command (background=true, see above) plus job_cancel remains the reliable way to recover a stuck call regardless of transport.

Long-running calls: progress heartbeats

Many commands legitimately take minutes (a run against a slow refhost, an update, a checkout). To keep MCP clients from timing out, mtui-mcp emits notifications/progress while a slow tool runs — for both synthesised command tools and the hand-written tools — provided the client supplied a progressToken on the request. Spec-compliant clients (Claude Desktop, opencode, the MCP Inspector, Cursor, …) reset their read deadline on each frame, so a ten-minute command still returns cleanly. Clients that ignore progress notifications should raise their own per-server read timeout instead. The job-control tools emit them only while parked on wait_seconds.

Connecting a client

mtui-mcp speaks standard MCP framing. A stdio client spawns the binary as a subprocess; an HTTP client connects to http://HOST:PORT/mcp.

Claude Desktop (stdio) — in claude_desktop_config.json:

{
  "mcpServers": {
    "mtui": { "command": "mtui-mcp", "args": ["--transport", "stdio"] }
  }
}

opencode (remote HTTP) — start mtui-mcp --transport http --port 8765, then in opencode.json:

{
  "mcp": {
    "mtui": { "type": "remote", "url": "http://127.0.0.1:8765/mcp", "enabled": true }
  }
}

opencode also accepts "type": "local" with "command": ["mtui-mcp", "--transport", "stdio"] to spawn it per-session instead.