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/--portbind 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
getandput, 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-writtenget/putare ordinary tools — present infull, restorable undercoreviatools_allow, removable viatools_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-Tflag). 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.
switchis not an MCP tool (moving the active-template pointer is REPL-only navigation): over MCP you target a template per call withtemplate=.load_templateandunload <rrid>are exposed — each names its own RRID.list_templatesis 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:
approverefuses non-interactively on a Gitea checkout-hash mismatch (at the REPL it prompts for confirmation, default no) unlessaccept_headnames 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 beforereviewerrecords 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-continueargument; 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 touchapprove’s refusal above — andcommit,export,reject --reviewerandrequest_reviewrefuse the same way, unless given their own--allow-stale, so a stale-loaded report cannot be published or overwritten non-interactively either.approve’scheck_hash()re-query is live (asks Gitea again, every call) and replaces that load-time verdict forapprove -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 whilemtui-mcpstill holds it loaded) passes them silently —commitis 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.
commentandcommittake their text/message as a required argument (no editor/stdin prompt), andregenerategates overwrite on the--forceflag 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
preparetool 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 onprepare’s success does not see a false positive on a metadata-empty report. - On a report whose metadata carries a
binariesblock,preparenarrows 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.updateexcludes 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--newpackagestep, 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 atracing::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 survivesmax_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_hostwithout-tfollowsadded <hosts>(orno 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 returnsOkin that case, so these lines are the signal to check.exportemitsWARNING: 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.runprints its own verdict above its per-host output, for the same head-survives-max_output_bytesreason:run completed on h1 (exit 0), h2 (exit 1)names every host it ran on with its exit code, followed byFAILED on h2 (exit 1)when any host exited non-zero.runreturnsOkon 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
runtool’scommandis 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": [...] }.sizeis the byte length of the section’s compact JSON (advisory).unfilledholds the RFC 6901 pointer of everynullleaf in the whole document, in sorted key order;completeis true when there is none.dirtyis true while edits awaitcommit.
report_section_read (read-only)
- Parameters (required):
section. Plus optionaltemplate. - 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,testingorreview),value(any JSON —nullis a legitimate value for an unansweredverdictorcomment). Plus optionaltemplate. updateandinstallare pipeline-owned and refused. A section the report lacks refuses. Anissueswrite must keep exactly the same issue keys; usereport_issue_writefor 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
nullon 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 atcommit. - A write changes local state only and marks the report dirty;
commituploads it. A laterexportoverwritestesting.install,testing.openqaandpeople.testers. - Returns
{ "id", "section", "dirty": true, "size" }.
report_issue_read (read-only)
- Parameters (all optional):
issue_id(such asbsc#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 optionaltemplate. - 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.filesare the local regular files, sorted bypath(report-directory-relative; subdirectories and symlinks are skipped).serveris{ "origin", "size", "at" }for the server’s file of that name (originispipelineoruploaded), ornullwhen it has none; sizes differ untilcommituploads.server_onlylists 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_errorset.
report_file_read (read-only)
Reads one of those files as text.
- Parameters (required):
path, relative to the report directory — eithercheckers.logor a file directly insidebuild_checks/, theinstall_logsdirectory orresults/. Optional:offset(1-based first line, default 1),limit(maximum lines),template. - Returns
{ "id", "path", "total_lines", "content" }; a windowed read also carriesoffsetandreturned_lines.contentis bounded by[mcp] max_output_bytes, and the read stops at[mcp] max_input_bytes(0disables the cap), cutting a line that crosses it. A capped read ends with a notice, andtotal_linesthen 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_secondsblocks 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 withwait_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 foregroundrun.) Two commands treat a cancel as a normal ending rather than a failure:request_review --watchstops watching (the request stays posted) andregeneratestops waiting (the server keeps building). Both return success, so their job endsdone, notcancelled, 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) orOTEL_EXPORTER_OTLP_LOGS_ENDPOINT(full URL, wins verbatim). Unset-or-empty disables export, as does an invalid endpoint or a non-http/protobufprotocol (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: onlyhttp/protobufor unset.OTEL_SERVICE_NAME(defaultmtui): resourceservice.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.