Troubleshooting¶
Server exits at startup¶
Several conditions are startup-fatal: the server writes one line to stderr and exits with a non-zero code before it ever speaks MCP. They fall into two groups with opposite fixes, so identify which you have before doing anything — reinstalling will not fix a configuration fault, and editing your client config will not fix a packaging one.
That line is a structured JSON record like every other line this server writes (see
Reading the server's log output), with "level":"error". The
prefixes below are the beginning of its message field, so both of these work:
# structured: exactly the failing message, nothing else
… 2>&1 | jq -r 'select(.level=="error") | .message'
# plain grep still works too — the prefix is inside the JSON line
… 2>&1 | grep 'vouchfx-mcp: could not read ENGINE_PIN:'
Group 1 — packaging faults (fix: reinstall the tool)¶
These mean a shipped artefact is missing or corrupt. Nothing you configure causes them. Grep for these exact prefixes to tell them apart:
vouchfx-mcp: could not read ENGINE_PIN:— theENGINE_PINfile that ships beside the built executable is missing or malformed. Without it the server has no engine version to gate the CLI handshake against, so it cannot proceed at all.vouchfx-mcp: could not derive the result provenance stamp:— the embedded, vendored composed schema's own version marker (the source of every result'smeta.schemaVersion) is missing or corrupt. This is a packaging fault in the shipped assembly, not a bad local schema file — there is no local schema file to fix.vouchfx-mcp: could not load the diagnostic catalogue:— one of the embedded diagnostic catalogue pages (whatexplain_diagnosticand thevouchfx-docs:///errors/{code}resource family both serve, one page per catalogued code) is missing or malformed. A single bad page is forced to fail at startup rather than poisoning every code's lookup later, on whichever call happens to touch it first.
For all three, reinstalling the tool package (dotnet tool update --global Vouchfx.Mcp --version 1.0.0,
or reinstall from a fresh dotnet pack if you build from source) replaces the corrupt embedded
artefact. If a reinstall does not clear one, that is a bug in the shipped package worth reporting.
Group 2 — configuration faults (fix: correct how you launch the server)¶
These are caused by the flags and environment you start the server with, and reinstalling will not help. All fail closed: the server exits rather than starting in a degraded or unexpected posture.
VFX-E-1007— the HTTP transport's configuration is unusable: no bearer token, a token that is too short or padded or non-ASCII, an unknown--transportvalue, a repeated flag, or a--urlsvalue this server will not bind. No HTTP listener is opened. See VFX-E-1007 for every cause and its fix, and the install guide's HTTP section for setup.--workspacecould not be parsed — a missing value, or a near-miss spelling such as--workspce, which is refused with a did-you-mean rather than silently ignored. Silently ignoring it would leave you running with path containment off while believing it was on.--workspacerequires an existing local directory — the root is missing, a regular file, inaccessible, or linked to a network-shaped target. Choose an existing local directory; the server deliberately does not create a directory for a mistyped root. Link cycles and paths beyond the bounded resolution budget also refuse startup. The fixed diagnostic omits supplied paths and link targets.--workspacefailed its containment check — the root does not resolve, or its run-artefact directory (<root>/.vouchfx/runs) does not land inside the root, typically via a symlink. Checked once at startup rather than surfacing as a per-call refusal for the server's whole lifetime.
If you did not intend to use either flag, omit both: stdio with no workspace is the default and needs no configuration at all.
CLI pin / version mismatch¶
run_suite performs a handshake against the installed vouchfx CLI's own --version output before
every run, comparing it against this build's ENGINE_PIN. Three distinct outcomes, each returned as a
structured tool error rather than a crash:
- CLI not found on PATH — install the pinned public CLI using
Install & registration, then add its absolute tool directory to the
MCP server process's
PATH. - Version mismatch — select a CLI whose version and full commit match the
first line of this checkout's
ENGINE_PIN. Older public engines, including rc.6, do not satisfy the current v1.2.0 pin. - Unparseable version output — reinstall the pinned CLI from NuGet.org, or use the exact source/private-feed procedure described in the install guide for contributor validation.
Install or update the matching stable 1.2.0 CLI, then verify the selected executable
before starting the MCP server:
dotnet tool install --global vouchfx --version 1.2.0
# If vouchfx is already installed:
dotnet tool update --global vouchfx --version 1.2.0
vouchfx --version
Its output must match both fields of ENGINE_PIN: the version without its leading
v, followed by + and the full commit SHA. Check the executable on the MCP
server's own PATH, which may differ from an interactive shell.
None of these ever spawn the CLI further to try to "fix itself" — a mismatch is always surfaced as a
structured result, never a silent behavioural drift. run_suite, list_step_types,
describe_step_type, plan_coverage, and scaffold_suite perform this handshake. validate_suite
and search_docs work from this server's own embedded artefacts and remain available when the CLI is
missing.
Live catalogue requires Spec A (shape-level list --json)¶
list_step_types and describe_step_type call vouchfx list --json on the pinned CLI and require
every entry to carry requiredFields, optionalFields, captureSupported, and familyIntent. If
the installed engine only returns thin type/family/provider keys (pre–Spec A), the tool returns an
error naming that gap and the minimum engine capability — it does not invent field lists or
silently degrade to type keys alone.
Fix: install a vouchfx build that includes Spec A (engine-schema-and-catalogue-export), matching
ENGINE_PIN once that version is pinned, then retry. Confirm with:
vouchfx list --json
and check that entries include the four bar-B fields above.
External providers (engine v1.2.0 and later)¶
Engine v1.2.0 loads trusted external providers from VOUCHFX_PROVIDERS and ~/.vouchfx/providers
as well as from explicit --providers arguments. This server passes no provider options, so the CLIs
it starts use whatever the server's environment and its account's home directory supply.
External providers in the tool
reference lists what each tool does with them.
A step type the engine accepts is unknown to validate_suite¶
validate_suite, normalize_suite and run_suite's pre-flight know only the pinned engine's
bundled step types. They report a step whose type comes from an external provider as VFX-D-1201,
and run_suite returns VFX-D-1100 without starting the CLI. scaffold_suite can still draft such a
step, because it uses the CLI. If list_step_types lists the type with tier: "community", the
provider is loaded and the suite may well be valid:
- Do not rename the type to the semantic finding's suggestion. That suggestion is for typos.
- Validate and run the suite with the CLI, in the same environment as the server:
bash
vouchfx validate path/to/suite.e2e.yaml
vouchfx run path/to/suite.e2e.yaml
If list_step_types does not list the type either, it is a typo, or the provider is not installed
for the account the server runs as. vouchfx providers list shows what that account has installed.
get_schema reports VFX-D-1106 although the pinned engine is installed¶
When the CLI loads external providers, its vouchfx schema export includes their step clauses, so it
differs from the vendored schema this server serves. get_schema then adds the VFX-D-1106 warning
even when vouchfx --version matches ENGINE_PIN exactly. The schema it returns is still the bundled
contract that validate_suite enforces. With no providers loaded, the pinned engine's export matches
the vendored schema and the warning does not appear.
A provider location the engine cannot load breaks the CLI-backed tools¶
If VOUCHFX_PROVIDERS names a directory that does not exist, or a provider in one of the searched
directories fails the engine's admission checks, every provider-scanning vouchfx command prints the
reason to stderr and exits 3 before doing anything else. vouchfx --version is unaffected, so the pin
handshake still passes. Measured at the v1.2.0 pin with a missing VOUCHFX_PROVIDERS directory:
| Tool | Result |
|---|---|
list_step_types, describe_step_type |
VFX-E-1401, whose message asks for a matching CLI. The CLI is fine; the provider location is not. |
plan_coverage |
VFX-E-1603, quoting the engine's own sentence, for example Provider directory does not exist: '…'. |
scaffold_suite |
VFX-E-1301, quoting the same sentence. |
run_suite |
verdict: "EnvironmentError" with exitCode: 3 and no step results. Its remediationHint about Docker does not apply. |
get_schema |
The vendored schema, without the VFX-D-1106 cross-check, because the live export failed. |
To fix it, run vouchfx list --json in the server's environment to see the engine's own message.
Then correct or unset VOUCHFX_PROVIDERS, or repair ~/.vouchfx/providers with
vouchfx providers uninstall and vouchfx providers install. A change to VOUCHFX_PROVIDERS needs a
server restart, because the CLIs it starts inherit the environment it was started with. A change
under ~/.vouchfx/providers takes effect on the next call.
Docker daemon unavailable¶
A suite the engine cannot even start because Docker is unreachable is reported by run_suite as
verdict: "EnvironmentError" — never "Fail". This distinction is deliberate and load-bearing: an
environment error means the infrastructure could not be stood up (an unreachable Docker daemon, a
failed image pull, a container that never became healthy), not that any test assertion was actually
evaluated and found wanting. Conflating the two would report "your system is broken" when the honest
answer is "the test environment itself never came up".
When verdict is "EnvironmentError", remediationHint names the likely cause — e.g. that Docker
appears to be unavailable and to check the daemon is running and reachable — built from the run's own
environment-error events where the engine reported one, from the CLI's stderr output as a fallback
when the run failed before any such event was even recorded, or — since issue #96 — from the
engine's own environment-configuration diagnostic when it printed one and a suite recorded no step
result (see the next section). explain_run's
categoryMeaning field explains the same distinction in plain language for any run you diagnose after
the fact, and its environmentErrors list carries the same evidence (errorKind, resourceName,
detail) for every environment error the run recorded.
A suite that validates clean aborts Inconclusive over its env: block¶
Schema-only symptom: validate_suite at level: "schema" can accept the
suite's shape while its managed dependency env: map contains a reserved name.
Semantic and full validation now report this override as an error in
semanticDiagnostics, with code VFX-D-1213.
Current authoring check: names and dependency types match exactly, including
case. The rule checks the engine-owned names for elasticsearch
(discovery.type, xpack.security.enabled, ES_JAVA_OPTS,
cluster.routing.allocation.disk.threshold_enabled), minio (MINIO_ROOT_USER,
MINIO_ROOT_PASSWORD), s3/RustFS (RUSTFS_ACCESS_KEY, RUSTFS_SECRET_KEY) and
azureservicebus (ACCEPT_EULA, MSSQL_SA_PASSWORD, SQL_SERVER). It never
inspects values or resolves credentials. Service environments and external-driver
configuration are outside this rule.
The engine still refuses these managed overrides before container startup. The JSON Schema checks syntax; the semantic rule provides the type-specific reserved name check. Use semantic or full validation to catch it during authoring. The following runtime/event explanation records the rc.6 refusal behavior and remains relevant when a suite reaches execution without that semantic preflight.
What is different from an ordinary Inconclusive: the refusal fires before Docker or any container
is touched, so no step ever runs. From engine v1.0.0-rc.6 the engine still writes an events file
for the run, but it records one Inconclusive scenario and nothing else: explain_run and
diagnose_run report Inconclusive with totalStepCount: 0 and no notable step, and
get_step_timeline has no step to show. The engine's own sentence is in that file as the message on
its scenario-completed event, which get_run_events relays; the remediationHint on the run_suite
result carries the same sentence, and is the quicker place to read it. (At v1.0.0-rc.5 the engine
wrote no events file at all for this run, so every events-file reader reported it missing
(VFX-E-1004) and the hint was the only explanation anywhere.)
You do not have to keep the original run_suite result around, though: the hint is persisted to the
run registry (issue #114) at the run's completing write, so it survives after that result has left your
context. Call get_run_status with the same runId and read its remediationHint field — it is the
identical string run_suite returned, verbatim. This also covers the run-level TIMEOUT hint ("The run
did not complete within…"; see "Timeouts and cancellation" below) whenever the result carries a runId:
it is the same field on the same result and is persisted the same way. A budget that expires before any
suite starts, while the supplied paths are still being expanded and validated, registers no run at all,
so that result's runId is null and its hint exists only in the run_suite result itself. A run
recorded before this server's registry format moved from version 1 to 2 reports remediationHint: null — that field genuinely did not exist yet when it ran, so
there is nothing to recover for it. So does a run whose registry entry would cross that cap once
completed (dozens of long spec paths written largely in non-ASCII characters, say): its completion is
recorded without the hint rather than not at all, whatever the hint's source. Ordinarily the original
run_suite result is then the only surviving copy — but not if the registry entry already carried a
hint from elsewhere before this server's own completing write ran, in which case there is no copy to
recover at all.
Fix: remove the offending env: entry, or declare the backend as a service: with an image: if
you need full control over its environment — exactly as the engine's own sentence says.
Timeouts and cancellation¶
run_suite accepts an optional timeoutSeconds (1–3600, default 300) and always reports a run that
did not finish in time — or one whose MCP request was itself cancelled by the calling client — as
verdict: "Inconclusive", never "Fail": the suite's actual result was never determined, which is
a different thing from a determined failure.
Two boolean fields distinguish why the run ended early:
timedOut: true— thetimeoutSecondsbudget elapsed before the run completed.cancelled: true— the calling MCP client's own request cancellation fired first, or acancel_runcall asked this run to stop. Both take the identical stop path described below; only acancel_run-driven stop additionally records the run's registrystatusascancelledrather thancompleted, since it is a deliberate lifecycle action a host then polls the result of.
Exactly one of the two is true for an aborted run; steps is empty in either case, since no step
outcomes were recorded before the abort. If a run is legitimately slow rather than stuck, raise
timeoutSeconds on the next attempt (up to the 3600-second ceiling) rather than retrying blindly.
What actually happens to the Docker topology when a run is aborted: run_suite requests a
graceful engine stop first, not an immediate kill — it closes the CLI child process's stdin, the
signal a vouchfx started with --shutdown-on-stdin-eof (available from v1.0.0-alpha.10) uses to
cancel its own internal token and run its normal container/network teardown to completion, up to its
own internal budget. Only if the CLI is still running once that grace period elapses does run_suite
fall back to a hard, whole-process-tree kill. In the ordinary case, then, an aborted run's containers
and Aspire session network are torn down by the engine itself, the same as a normal completion — no
orphaned container is left behind, and no external cleanup is needed. The hard-kill fallback is rare
(reached only if the engine's own teardown genuinely hangs past its internal budget); in that specific
case, Docker containers are not reachable through the killed process tree at all, so their cleanup
falls back to Testcontainers' own Ryuk reaper, which independently reaps orphaned containers within
roughly the reaper's usual detection window regardless of how the parent process ended. If you ever
see a leftover vouchfx-related container or aspire-session-network-* network after a cancelled or
timed-out run_suite call, it should be self-cleaning shortly afterwards; if it persists, that is
worth reporting rather than assuming it is expected.
Run already in progress¶
A run_suite call is rejected with error code VFX-E-1501 RunInProgress when another run is already
active. The scope of this rejection depends on whether a workspace is configured:
- With
--workspace: the claim is enforced across all server processes accessing the same workspace, via an OS-level file lock on<root>/.vouchfx/runs/.lock. Two separate host connections, editor windows, or restarted servers pointing at the same workspace all contend for the claim; the second to ask is refused. - Without
--workspace: the claim is process-local only. Overlapping calls to the same server see this code, but separate server instances do not contend.
The error is marked retryable (retryable: true), and the identical call will succeed once the active
run finishes. The error's details carries the active run's runId when available, helping you
correlate the refusal with the run you are waiting on (for example, to call explain_run once it
finishes). A lock whose holder no longer exists is not a problem you have to resolve manually: the
claim is an operating-system-held file handle rather than a pid written into a file, so a server that
crashes or is force-quit releases its claim automatically.
Do not delete <root>/.vouchfx/runs/.lock by hand. On Windows the operating system denies the
delete while a run holds the file, so the attempt is useless. On Linux and macOS the claim is an
advisory flock record on the file's inode, so the delete succeeds even mid-run and silently
breaks mutual exclusion — the next run_suite call creates a fresh file, gets a new inode with no
lock on it, and starts a second concurrent run against the same workspace. Relatedly, on every
platform the .lock file is expected to persist between runs: it is never read and never blocks
anything, so a leftover one in a quiet workspace is normal and should be left alone. See
VFX-E-1501 for the full per-platform detail.
The escape hatch: cancel_run¶
If you do not want to wait for the active run, cancel_run is the supported way out — never a manual
kill, and never deleting the lock file. Take the runId from the rejection's details (or from
list_runs) and call cancel_run with it. The run is asked to stop through exactly the graceful
sequence described above — stdin closed first, hard kill only if the engine's own teardown overruns —
so the topology is torn down properly and the claim is released as part of the run ending. The
cancelled run is recorded with status: "cancelled" and outcome Inconclusive; it is not lost, and
explain_run/get_run_events still read whatever it produced before it stopped.
Two answers mean the run cannot be stopped from here, and both are worth reading rather than retrying blindly:
VFX-E-1507— the entry saysrunningand this server is not the one holding it. Read the message: the workspace lock answers per workspace, not per run, so it cannot on its own prove another process has your run. The message names which of three it established — a different server process is running it (there is no cross-process cancel: the lock is a bare file handle with no channel through it, and this server will not invent a competing side-channel, so cancel from the process that started the run); the run has already finished here and its completing record was lost or is being written this instant; or this server is busy with another run whose lock masks the probe (calllist_runs, and cancel thatrunIdif that is the one you meant). The code is retryable precisely because the condition clears when whatever holds the workspace finishes.VFX-E-1508— the entry saysrunningbut the lock is free, so nothing is running it. This is the phantom entry a hard-killed server leaves behind — or one whose completing registry write failed, which the server announced on stderr at the time — andcancel_runis how you identify one:get_run_statusandlist_runsdeliberately report such an entry asrunning, because establishing liveness means acquiring the lock and a read-only tool must never be able to make a concurrentrun_suitecall fail. Nothing is blocking you — start the run again — and remove<root>/.vouchfx/runs/<runId>/if you want the stale entry gone.
get_step_timeline refuses your specPath or stepId¶
Both refusals are about this run's own record, not about your filesystem or your suite, and neither is retryable — the identical call reports the identical thing until you change the argument.
VFX-E-1509— thespecPathnames a suite this run did not cover. A run's suite set is fixed when it starts and never rewritten;get_run_statusreports it asspecPaths. The comparison is generous about spelling (a relative path is resolved against the workspace root, both sides are normalised, and matching follows the platform's own file-name rules) and strict about membership, so a path that resolves to a file this run never touched is refused rather than answered. Note that the file existing is irrelevant in both directions: a suite deleted since the run is still a suite that run covered, and still works here.VFX-E-1510— the run's event stream records no step with that id. Matched exactly and ordinally; there is no fuzzy suggestion. Before assuming a typo, check whether the step ever ran: a run cut short by a cancellation, a timeout or an environment error never reaches the steps after the one that stopped it, and a suite refused by pre-flight validation ran nothing at all.explain_runwill say where it stopped, andget_run_eventswithtypes: ["step-completed"]lists every step that did finish.
A step that ran and recorded no individual attempts is a different thing entirely and is not an
error: it comes back as a successful result with an empty attempts array, a null verifyMode, and
a conclusion saying so. That is the ordinary shape of a step left on the default IMMEDIATE verify
mode, which emits a completion event and no attempt events.
list_runs says truncated: true¶
The registry examines at most 10,000 run directories per read, in the filesystem's own enumeration
order — before the newest-first sort — so a workspace holding more than that is listed from an
arbitrary slice, and successive pages need not come from the same one. truncated: true is the
registry reporting that its scan stopped at that bound; it is a fact from the scan itself, not a guess
from the row count (10,000 rows back is otherwise indistinguishable from a workspace holding exactly
10,000 runs).
Read it alongside nextCursor, not instead of it: nextCursor says more matching runs remain
within what was scanned, truncated says what was scanned is not everything. The combination to watch
for is truncated: true with no nextCursor — the walk ended at this server's bound rather than at the
end of the registry.
Startup/pre-run maintenance now applies terminal retention
under the workspace claim: defaults are 1,000 eligible terminal runs and 30 days. Check its stderr
counts and incomplete flag. A held claim, scan/mutation limits, opt-out, corrupt entries or foreign
contents can prevent a complete sweep. Preserve evidence before removing anything manually; leave
.lock untouched. Each page still reparses examined metadata; retention does not implement an index
or guarantee that every workspace falls below the scan bound.
get_run_artifacts returns partial: true and an empty section¶
That is the expected answer today, not a fault, and the result tells you which part is missing rather
than leaving you to infer it: read the gaps array, where each entry names the field, a one-line
reason, and the upstream ask (awaits: "U4") that would close it.
Three specific shapes surprise people:
logsis always[]. This server has no container log access at all — no engine flag exposes it and the server never talks to a container runtime — so it returns an empty list rather than an error or an invented line.containerandtailLinesare accepted and validated but select and bound nothing yet; they exist so the tool's contract does not change again when log access lands. For what the run itself recorded, useget_run_eventsorexplain_run.environment.servicesandenvironment.dependenciesare always[], andresourcesmay be too. The only environment identifier in the v1 event stream is the resource anenvironment-errorevent names, and that event does not say which of the two it is — so identifiers appear underresourceswithrole: "unclassified"and ahealthofnull(not observed, never "unhealthy"). A run in which nothing went wrong named no resource at all, so all three arrays are legitimately empty.reportshas nohtmlorjunitproperty. They are omitted rather thannullon purpose: the engine owns where it writes its reports and this server is never told the paths, so anullwould wrongly suggest it looked.reports.eventsis the artefact it does have.
If reports.events.available is false, the run's own event stream has been deleted or cannot be read
— usually a cleaned output directory. The run's metadata outlives its events file, so the other sections
still answer; explain_run, get_run_events and get_step_timeline will refuse the same run with
VFX-E-1004, which is the same fact reported by tools for which that file is the whole answer.
Suite validation timeout¶
validate_suite (and the same pre-flight check run_suite performs before spawning anything) runs the
actual YAML/schema evaluation inside an isolated child process bounded by a 10-second wall-clock
timeout. A suite that does not validate within that window is reported as a tool error carrying
code VFX-E-1150 — the suite's actual validity was never determined, and the worker process is killed
(its exit confirmed, not merely assumed) rather than left running. In practice a genuine .e2e.yaml
suite validates in well under a second; although VFX-E-1150 is marked retryable: true (a wall-clock
kill can be caused by transient load on your machine rather than by the suite), one on an
ordinary-sized suite is worth reporting as a possible bug rather than retried indefinitely. A separate
code, VFX-E-1901, covers the case where the worker process itself could not be started, crashed, or
produced output this server could not parse — distinct from a timeout, but likewise meaning validity
was never actually determined.
Note the distinction these two codes sit on: they are VFX-E-… errors (isError true) precisely
because no verdict was reached. A suite that genuinely fails validation is the opposite case — a
successful call carrying VFX-D-… diagnostics. If you are branching on isError, a merely-invalid
suite will never take the error branch.
Reading the server's log output¶
All of this server's own logging goes to stderr (stdout is the JSON-RPC channel and carries
nothing else), as one JSON object per line — so you can pipe it straight into jq, or into
whatever your client ships logs to, without a parser.
Every record has this shape:
{"timestamp":"2026-09-12T09:41:07.1234567Z","level":"information","message":"vouchfx-mcp: run started (1 suite(s))","runId":"run-1f2e…","seq":1}
| Field | Always present | Meaning |
|---|---|---|
timestamp |
yes | UTC, ISO-8601 round-trip form (always Z, never a numeric offset). |
level |
yes | Lower-case: trace, debug, information, warning, error, critical. |
message |
yes | The human-readable line. Control characters are escaped; a record is always one physical line. |
runId |
run-lifecycle records only | The run this line concerns — the same id run_suite returned and get_run_status reports, so you can join logs to a run directly. |
seq |
run-lifecycle records only | This server's own per-run record counter, starting at 1. |
errorType |
only when a record has a cause | The exception's type name — never its message, stack trace, or inner exceptions. |
The optional fields are omitted rather than written as null when they do not apply, so
select(.runId) picks out exactly the run-scoped lines.
Note "only during a run" would be too generous: runId and seq are carried by this server's
run-lifecycle records, not by everything emitted while a run happens to be in flight. A record
from the MCP SDK or the hosting infrastructure that lands mid-run has neither, because nothing hands
those components the run's identity.
A note on seq, because the name invites a wrong assumption: it counts this server's log records
for that run, and is not an index into the engine's event stream. Its purpose is ordering two
records that share a millisecond timestamp. To go from a log line to the run's engine events, take
its runId and call get_run_status (or get_run_events) — eventsFilePath on that result is the
join, not a shared sequence number. The engine's events carry their own, unrelated runId.
Today there are four run-scoped records:
| Record | Level | Carries |
|---|---|---|
| run started | information | the suite count |
| run completed | information | the taxonomy verdict (Pass, Fail, EnvironmentError, Inconclusive) and the wall-clock duration |
| run threw | warning | errorType — the run ended without reaching a verdict |
| completion not recorded | warning | errorType — the verdict reached you, but writing it back to the registry failed |
The pairing is the useful property: every started record is followed by exactly one of the other three. So an unpaired "run started" means the run is still going or the server died — and nothing else.
The last two are both worth alerting on, and both carry their cause in errorType rather than in the
message text, so one query finds either:
… 2>&1 | jq -r 'select(.errorType) | "\(.runId) \(.errorType) \(.message)"'
After "completion not recorded" in particular, get_run_status will keep reporting that run as
running forever — the verdict reached the caller but the registry never learned it, and there is no
reaper to correct the entry.
Two durations, deliberately different. The run completed record's duration measures the run
scope — from the registry write that mints the runId to the registry write that records the verdict.
The vouchfx.duration_ms attribute on the ActivitySource span (OpenTelemetry-compatible) for the same call (see the observability section of the
overview) measures the whole tool call, including the argument validation and suite
pre-validation that happen before the run scope opens. The span's number is therefore the larger one,
and seeing two different figures for "the same run" is expected rather than a discrepancy.
Diagnostic logging and secret material¶
At its default Information level this server logs neither tool arguments nor tool results, and none
of this server's own log records contains a secret, a raw environment variable value, or suite
content — the record fields above are the complete set, and a guard test pins which code may write
one at all. That scope is exact: the guard scans this server's src/, so it says nothing about what
the MCP SDK or the hosting infrastructure may log through the same pipeline, and nothing about what
frame-level tracing captures (see immediately below).
Do not raise the log level to Trace while working with suites that carry secret material. At
Trace the MCP SDK logs entire JSON-RPC frames, and since validate_suite accepts a suite inline via
its yaml argument, a frame now contains the full suite body — including any ${secret:…}
references and any literal credentials the draft happens to hold. Those frames land in whatever your
client captures stderr into. This is a property of frame-level tracing, not of any particular tool:
the server never resolves a secret reference and never echoes one into a result, but it cannot
un-log a request it was asked to trace. Use Debug or lower for routine diagnosis, and reserve
Trace for reproducing a protocol-level problem with a suite you would be happy to paste into a bug
report.
Long-running runs: poll, because MCP Tasks is not available¶
If you are looking for the MCP Tasks extension (the 2026-07-28 addition that lets a server hand back a task handle instead of blocking), this server does not implement it, and the reason is a dependency rather than a decision we can revisit on request.
Measured 2026-09-12 against the pinned SDK (ModelContextProtocol 2.2.0, checked by reflecting
the shipped assemblies rather than reading release notes): the SDK has no Tasks support to build on.
There are no Tasks-related types, and the capability set a server can negotiate is exactly
Completions, Experimental, Extensions, Logging, Prompts, Resources and Tools — there is
no tasks capability for a client and server to agree on. Implementing Tasks anyway would mean
hand-writing the whole extension protocol against an untyped Extensions dictionary with no help
from the SDK, which we will not do on a surface this important to get right.
What is available instead, stated precisely — because the obvious reading of "poll" does not work.
run_suite blocks until the run finishes and returns the verdict, and the runId comes back
with that result. So the caller that started a run cannot poll its own run: it has no id until the
call it is waiting on returns. There is no fire-and-forget mode (wait: false is refused with
VFX-E-1504, pending upstream work).
Observation comes from a concurrent call, and for most hosts that does not need a second session at all.
Option 1 — a concurrent call on the same session (what most stdio hosts can do). The read-only tools are safe to call while a run is in flight: they never take the run lock, which is a structural property rather than a convention. So an agent that can issue a second tool call while the first is outstanding — which MCP allows, since requests are matched by id rather than serialised — can simply:
- Leave
run_suiteblocking. - Call
list_runsto find the in-flight run and take itsrunId. - Call
get_run_statusfor state, andget_run_events/explain_run/get_step_timelineto read the event stream while it runs.
This matters because a stdio host typically has exactly one session per server process, so
requiring a second session would have meant requiring a second server — and without --workspace
that second server would see no runs at all.
Option 2 — a second session, which you need for cancel_run. Same steps, but from another client
connected to the same server process. Cancellation is the case that forces this if your agent
cannot issue concurrent calls: cancel_run fires the in-flight run's own cancellation token, which
only exists inside the process running it. Cross-process cancel is refused rather than faked
(VFX-E-1507) — a second server process cannot stop the first one's run.
Two caveats that decide whether this is usable for you. The run registry is session-scoped unless
the server was launched with --workspace, so without that flag a separate server process sees no
runs at all — the second session must reach the same process. And get_run_status reports the
last recorded state rather than a liveness check: a server killed mid-run leaves an entry reading
running forever. The log records described above are what actually tell you whether a run is still
going.
The tripwire, if you are tracking this. The signal that Tasks becomes possible is the appearance
of a tasks capability (a TasksCapability type) in a future ModelContextProtocol release. Until
that exists, polling is the only long-running-call mechanism this server has, and the story that
would add Tasks is closed blocked-on-SDK rather than dropped.
Where to look next¶
For anything not covered above, run_suite's returned eventsFilePath and explain_run /
diagnose_run are the fastest way to get an evidence-backed answer rather than guessing: both read
that same file (or the most recent finished run in the registry when you omit the path) and name the
exact failing or inconclusive step, its RETRY attempt timeline, and any environment errors recorded
— all without re-running anything. diagnose_run additionally returns two proposal kinds: Fail steps
get review patch proposals; EnvironmentError/Inconclusive get scoped spec-edit proposals (never
auto-applied; EnvironmentError additionally yields infrastructure guidance). See
explain_run and diagnose_run
for their full result shapes.