Documentation / Troubleshooting

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: — the ENGINE_PIN file 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's meta.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 (what explain_diagnostic and the vouchfx-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 --transport value, a repeated flag, or a --urls value 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.
  • --workspace could 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.
  • --workspace requires 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.
  • --workspace failed 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 — the timeoutSeconds budget elapsed before the run completed.
  • cancelled: true — the calling MCP client's own request cancellation fired first, or a cancel_run call asked this run to stop. Both take the identical stop path described below; only a cancel_run-driven stop additionally records the run's registry status as cancelled rather than completed, 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 says running and 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 (call list_runs, and cancel that runId if 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 says running but 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 — and cancel_run is how you identify one: get_run_status and list_runs deliberately report such an entry as running, because establishing liveness means acquiring the lock and a read-only tool must never be able to make a concurrent run_suite call 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 — the specPath names a suite this run did not cover. A run's suite set is fixed when it starts and never rewritten; get_run_status reports it as specPaths. 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_run will say where it stopped, and get_run_events with types: ["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:

  • logs is 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. container and tailLines are 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, use get_run_events or explain_run.
  • environment.services and environment.dependencies are always [], and resources may be too. The only environment identifier in the v1 event stream is the resource an environment-error event names, and that event does not say which of the two it is — so identifiers appear under resources with role: "unclassified" and a health of null (not observed, never "unhealthy"). A run in which nothing went wrong named no resource at all, so all three arrays are legitimately empty.
  • reports has no html or junit property. They are omitted rather than null on purpose: the engine owns where it writes its reports and this server is never told the paths, so a null would wrongly suggest it looked. reports.events is 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:

  1. Leave run_suite blocking.
  2. Call list_runs to find the in-flight run and take its runId.
  3. Call get_run_status for state, and get_run_events / explain_run / get_step_timeline to 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.