Documentation / Tool & resource reference

Tool & resource reference

Every tool below returns a structured result — success or failure — rather than throwing for an ordinary bad input; where a tool can also return an MCP tool error (as opposed to a structured success payload), that is called out explicitly. Field names shown are the JSON property names the MCP result actually carries.

Stable codes on every error and diagnostic. Two shapes carry them, and the prefix tells you which:

  • VFX-D-… is a diagnostic — a finding about your suite or run, returned as data on a successful call (isError is false). An invalid suite is not a tool failure.
  • VFX-E-… is an error — the call itself could not be performed, returned as a tool error (isError true) whose entire body is one object: { code, message, docsUrl, retryable }.

retryable means "retrying this same call, unchanged, might succeed" — it is false for anything you must fix first. Every VFX-E-… error object carries a docsUrl of the form https://vouchfx-mcp.vouchfx.io/docs/errors/<code>.html. VFX-D-… diagnostics appear in three arrays: validate_suite's errors (no docsUrl), its semanticDiagnostics (carries one), and get_schema's diagnostics (carries one). For diagnostics without a docsUrl field, look up the catalogue page directly by code using the URL pattern documented in the Resources section below.

One code is omitted from the per-tool tables below because it is not specific to any of them: VFX-E-1902 (retryable false) means this server produced an outcome it could not render — a bug in this server, not in your input. Any tool whose handler dispatches over multiple outcome variants (an "outcome switch") may in principle return it; none is expected to. search_docs has no such switch — see its own "no error shape at all" note below.

Every successful result also carries a meta object, alongside the per-tool fields documented below and omitted from each "Result shape" line to avoid repeating it eighteen times:

"meta": {
  "schemaVersion": "v1",        // the vouchfx language schema version this server validates against,
                                // read from the vendored composed schema's own version marker
  "serverVersion": "0.1.0",     // the same value as serverInfo.version in the MCP initialize handshake
  "workspaceRoot": "/path/..."  // the configured workspace root, or the installed tool's base directory
}

It lets a host identify which schema version and server version produced a result without a separate handshake call. workspaceRoot reports the workspace root when a --workspace flag was supplied at server startup (see Install & registration), or the resolved base directory of the installed tool when no workspace is configured.

Privacy note on workspaceRoot. This is a local filesystem path, and it is attached to every successful result. When no workspace is configured, it resolves under the invoking user's profile (e.g. C:\Users\<username>\.dotnet\tools\... or ~/.dotnet/tools/...), so it commonly reveals the OS username and the local install layout. When a workspace is configured, it reports the workspace root you supplied. MCP hosts routinely forward tool output to a third-party model backend, so treat this value as leaving the machine. It is not covered by the engine's secret redaction — a path is not a ${secret:...} reference.

External providers (engine v1.2.0 and later)

From engine v1.2.0 the vouchfx CLI can load trusted external step providers as well as its bundled ones. It searches each --providers <dir> argument, then the directories listed in VOUCHFX_PROVIDERS, then ~/.vouchfx/providers when that directory exists, which is where vouchfx providers install puts them. The engine's external providers guide describes the contract.

This server never passes --providers or --no-default-providers. Every CLI it starts inherits the server's environment and the home directory of the account it runs as, so the CLI-backed tools see the providers that account's vouchfx would load. The CLI-free tools do not: they read the artefacts vendored from the pinned engine commit, which describe the bundled step types only. This server does not load or inspect provider assemblies itself, because that would re-implement the engine.

Measured at the v1.2.0 pin with the published rpc.json-rpc provider installed under ~/.vouchfx/providers:

Tool With an external provider loaded
list_step_types, describe_step_type List and describe the provider's types with tier: "community". requiredResources, docsUrl and example are absent, and each of describe_step_type's fields has a null type and description: this server derives those from the vendored schema, and the engine reports no documentation link or example for the type.
plan_coverage, scaffold_suite Use the same provider set as vouchfx plan and vouchfx scaffold.
validate_suite, normalize_suite Report a step whose type comes from the provider as VFX-D-1201 in errors, so the suite is valid: false, although vouchfx validate passes it. The semantic finding names this case instead of only suggesting a rename. normalize_suite leaves that step's keys in their authored order.
run_suite Refuses the suite with VFX-D-1100 before anything runs, because its pre-flight is the same validation. The CLI is never started.
get_schema Serves the vendored schema, which has no clause for the provider's types, with a VFX-D-1106 warning, because the engine's vouchfx schema export now includes them.
search_docs and the documentation resources Cover the bundled step types only.

For a suite that uses an external provider, vouchfx validate and vouchfx run are the authority, and run_suite cannot run it at this pin.

A provider location the engine cannot load, such as a VOUCHFX_PROVIDERS entry naming a directory that does not exist, stops every provider-scanning command with exit code 3 before it does anything else. Troubleshooting shows how each tool reports it.

Tools

validate_suite

Validates an .e2e.yaml suite — either a file on disk or YAML text supplied inline — against the engine's JSON Schema and reports every structural error found, without running the suite. Also returns a structured digest of what the suite contains (step count, step types, service and dependency names, capture variable names, and interpolation tokens used) computed from the single parse, and a separate semanticDiagnostics channel carrying the VFX-D-12xx semantic findings (unknown step types, dangling references, secret literals, and the rest — see the semantic-codes table below). Uses the embedded vendored composed schema (drift-gated to ENGINE_PIN via scripts/sync-vendored.ps1, which is also the only supported way to refresh it — see vendored/README.md). Offline-capable: does not require the CLI.

Relationship to vouchfx validate. This tool evaluates the same schema the engine does, but it is a separate implementation rather than a wrapper, so the two are held to a specific and deliberately-limited contract: they aim to agree on which errors exist and where, and the CLI is authoritative for wording. Measured at the v1.2.0 pin over the engine's own 71-fixture rejected corpus: 43 byte-identical, 19 reporting the same findings at the same locations with less enriched text, 0 where the set of findings differs, and 9 where the CLI short-circuits before schema validation and the two are not comparable. If a message here is terser than you expected, run vouchfx validate for the fuller explanation — the verdict will not change.

That agreement is verified, not guaranteed by construction. Treat vouchfx validate as the authority if the two ever disagree, and please report it — a divergence is a bug in this tool.

  • Parameters:
  • path (string, optional) — absolute or workspace-relative path to the suite file. A relative path resolves against the workspace root when the server was started with --workspace, and against the server process's current directory otherwise. Supply this OR yaml, never both.
  • yaml (string, optional) — the suite's YAML text, validated directly without reading or writing any file, for a draft not yet written to disk. Supply this OR path, never both.
  • level (string, optional, default "full") — which passes to run: "schema" for the JSON Schema pass only, "semantic" for the semantic-rules pass only, or "full" for both. Case-sensitive.

    level: "semantic" does not run the schema pass, so valid reflects only the passes that ran — do not read it as "the engine will accept this suite". valid reports exactly one thing: that the errors array is empty. At "semantic" that array is empty because nothing looked, not because the document conforms. The result echoes the effective level back precisely so this is distinguishable; if you need the engine's verdict, ask for "schema" or "full". - Result shape: { valid: bool, errors: [{ code, instancePath, message, line, column }], semanticDiagnostics: [{ code, severity, message, location, path, fix, docsUrl }], semanticDiagnosticsTruncated: bool, summary: { steps, stepTypes, services, dependencies, captures, placeholders, truncated } | null, level, meta }. valid is true when errors is empty and no semantic finding has severity error — see the caveat under level above. semanticDiagnosticsTruncated is true when the semantic pass produced more than 1 000 findings and the array therefore does not carry all of them. level echoes the level the call actually ran at: the level argument you sent, or "full" when you sent none. summary is null whenever no document was built — see below. The summary object contains: - steps: count of steps in the suite. - stepTypes: distinct type values those steps declare, in first-appearance order. - services: logical names under environment.services. - dependencies: logical names under environment.dependencies. - captures: distinct capture variable names any step's capture map declares. - placeholders: distinct {name} interpolation tokens used in string values, including the reserved-prefix forms {svc::…} and {conn::…} (never ${secret:…} references). - truncated: true when this digest is no longer a complete, exact representation of the document — so do not treat these lists as an exact inventory. Two alterations raise it: a list that hit the 1 000-entry cap and dropped a name it would otherwise have carried (a list cut), or a single entry longer than 128 characters that was clipped to at most that length (a length clip — the trailing … marks it; a clipped entry is 128 characters, or 127 in the rare case where clipping one character shorter avoids splitting a surrogate pair). Real names are short — a step type is ~15 characters, a capture/service/dependency name ~20–30 — so the length clip only ever bites a pathological entry (e.g. an alias-amplified multi-MB scalar), and clipping keeps the entry's presence visible on the wire rather than echoing the whole string.

Every list in summary is capped at 1 000 entries and every entry at 128 characters, and truncated tells you when either cap actually bit, so you never have to infer incompleteness from a list length or an entry length. A summary is a digest for orientation, not an inventory: a suite with more than a thousand distinct step types, service names, capture variables, or placeholder tokens is past the point where reading a flat list helps. truncated is not raised by the secret-hygiene filter: no summary field ever carries a ${secret:…} reference, whether it appeared as a value or as a name (a capture, service, dependency, or step type named after one is omitted from the list rather than echoed), and that omission is a permanent property of every summary rather than a sign of truncation.

summary is null when the document could not be parsed — a successful call, with the reason in errors. That is every case where no document was ever built: VFX-D-1102 (unparseable YAML), VFX-D-1103 (over the size cap), VFX-D-1104 (nested too deep), VFX-D-1105 (too many anchors/aliases), and VFX-D-1107 (a line exceeds the length cap), plus the file-level errors below for a path that could not be read. Unparseable YAML is the likeliest outcome of validating a draft mid-edit, so handle the null rather than assuming a summary accompanies every non-error result. valid: false is not a proxy for it: a schema violation is reported on a document that parsed perfectly well and therefore comes with a summary.

The errors and semanticDiagnostics channels are permanently separate and never merged. - Input validation: - Exactly one of path or yaml must be supplied; both or neither is an error VFX-E-1152. - A path of exactly --yaml-stdin is refused with the same error VFX-E-1152: that literal name collides with the internal marker the server uses to tell its isolated worker "the suite text is on stdin", so the file would never be opened. Pass it in a qualified form (./--yaml-stdin), or send its text as yaml. - An invalid level token (not one of the three case-sensitive values) is an error VFX-E-1006. - Diagnostic codes in errors[].code — all of them VFX-D-…, all of them returned on a successful call (isError is false) because a finding about your suite is data, not a tool failure:

Code Meaning
VFX-D-1101 A JSON Schema violation.
VFX-D-1102 The YAML could not be parsed.
VFX-D-1103 The file exceeds the size cap.
VFX-D-1104 The YAML nests deeper than the cap allows.
VFX-D-1105 More anchors/aliases than the cap allows ("billion laughs" defence).
VFX-D-1107 A single line exceeds the length cap.
VFX-D-1201 A step's type matches no step type the engine defines.

VFX-D-1201 appears in both channels, from one detector — see the note under Semantic codes below.

VFX-D-1103/1104/1105/1107 are the YAML-bomb defences (size, nesting, anchor/alias, and per-line-length caps), applied before any recursive parse. These defences run at every level — a level that could switch them off would be a bypass with a friendly name.

  • Semantic codes in semanticDiagnostics[].code — findings that require the step-type vocabulary, not just the JSON Schema's shape. All are VFX-D-… diagnostics (returned on a successful call). Severity controls whether a finding flips valid to false: a finding of severity error (VFX-D-1207 or VFX-D-1213) makes valid: false; every warning and info finding leaves it true. Note: VFX-D-1201 emits to both errors (with engine-parity wording) and semanticDiagnostics (with a Levenshtein closest-match suggestion) from the same detector — the channels never merge.

These codes only ever appear at level "semantic" or "full". At "schema" the rules pass does not run at all, so semanticDiagnostics is empty for the trivial reason that nothing looked — an absent finding there is not evidence the suite is free of it. Read level back off the result before drawing a conclusion from an empty array, exactly as you must before reading valid.

Severity is a property of the finding, not of the code, so read it off each entry rather than inferring it from the table below: VFX-D-1207 reports at error for its three structural shapes (a private-key PEM header, an AKIA/ASIA key id, an inline Password=) and at warning for its high-entropy-token inference, which never changes valid. The table's Severity column gives the level each code reports at today; where a code has more than one, both are listed.

Code What it flags Severity Notes
VFX-D-1201 Unknown step type warning Dual-channel enrichment: schema entry + suggestion.
VFX-D-1202 Dangling target reference warning Suppressed for unresolved external environment bindings.
VFX-D-1203 Placeholder used before definition warning Order-aware; {svc::…}/{conn::…} tokens count as always-defined.
VFX-D-1204 Unused capture warning
VFX-D-1205 Undeclared dependency type warning Suppressed when the step's target names a declared service or external environment bindings are unresolved.
VFX-D-1206 RETRY without timeout or timeout above 300 s warning Advisory max is server-owned, not an engine limit.
VFX-D-1207 Literal secret material detected error (structural shapes) / warning (entropy inference) Only the error form makes valid: false. Correct practice is ${secret:…} reference.
VFX-D-1208 Duplicate step id warning Reported at each repeat occurrence.
VFX-D-1209 Async step without RETRY warning Carries machine-applicable fix (sets verifyMode: RETRY).
VFX-D-1210 Topology cross-check warning Disabled — catalogued and tested but never emitted; awaits upstream capability.
VFX-D-1211 Missing metadata.owner/tags info
VFX-D-1212 External environment configuration and bindings unresolved info CLI-free analysis does not read driver configuration or determine runtime readiness.
VFX-D-1213 Engine-owned managed dependency env name error Remove the reserved entry or use a service with image: for full environment control.

An environment.source reference leaves service/dependency names unknown to this offline pass; empty summary lists do not establish that those resources are absent. VFX-D-1212 is advice, separate from schema acceptance. The pinned schema accepts environment.source, but does not confirm driver registration, external configuration, bindings or runtime readiness. With a compatible engine, run normal vouchfx validate on the original suite path to validate external configuration. Inline YAML has no filesystem base: save it beside its configuration first. This server does not open driver configuration, resolve secrets, or inspect the environment.

  • Error codes — returned as a tool error (isError true), carrying a single { code, message, docsUrl, retryable } object instead of a validation result, because in each of these cases the suite's validity was never determined:
Code Meaning retryable
VFX-E-1001 The path is a UNC/network location, or (when a workspace is configured) resolves outside its root. false
VFX-E-1002 The suite file does not exist. false
VFX-E-1003 The suite file exists but could not be read. false
VFX-E-1006 An argument was rejected — invalid level token. Valid values: schema, semantic, full. false
VFX-E-1150 The isolated validation worker exceeded its wall-clock budget and was killed. true
VFX-E-1152 Exactly one of path or yaml must be supplied; both, neither, or a path of exactly --yaml-stdin is an error. false
VFX-E-1901 The validation worker could not be started, crashed, or produced unusable output. true

Every code carries a docsUrl of the form https://vouchfx-mcp.vouchfx.io/docs/errors/<code>.html. - Notable behaviour — process-isolated worker, file or inline. The actual YAML/schema evaluation runs inside a disposable child process (the same vouchfx-mcp executable, re-invoked in a hidden worker mode), bounded by a 10-second wall-clock timeout. Inline YAML is transported over stdin (UTF-8, never written to disk) and crosses the same process-isolation boundary as file content — the same timeout, the same whole-tree kill on timeout. A tiny, well-formed-looking YAML input can drive a YAML scanner into an uninterruptible, ~100%-CPU spin that no in-process CancellationToken can recover from — only OS-level process termination can — which is exactly why this tool never parses untrusted YAML directly inside the long-lived server process, whether from a file or inline. Worker stdout/stderr are each capped at 50 MB before being treated as misbehaving. A worker that does not finish in time is killed (its exit is confirmed, not assumed) and reported as VFX-E-1150, never left running. The read-only guarantee holds for both paths: no suite file is ever written, modified, or deleted; inline YAML is never persisted. - The semanticDiagnostics channel — now populated with twelve semantic rules (thirteen codes, one reserved). This channel carries findings that require the step-type vocabulary, not just the JSON Schema's shape: unknown step types, unused captures, undefined placeholders, dangling references, missing metadata, and more — but only when level is "semantic" or "full". The errors and semanticDiagnostics channels remain forever separate: moving a finding from one channel to another would be a breaking change; the names will not change. See the Semantic codes section above for the full rule set and catalogue links.

The channel is capped at 1 000 findings, and semanticDiagnosticsTruncated is true when that cap dropped one — the same cap-plus-flag shape summary.truncated uses, so you never have to infer incompleteness from a list length. A finding is per-node for some rules, so nothing about a valid document bounds how many it can produce. - Never throws. A suite that is merely invalid — including malformed YAML, schema violations, or semantic findings — is a successful call carrying diagnostics; a missing file, a rejected path, an input-validation failure, or a worker timeout is a structured tool error carrying a single VFX-E-… object. Both are structured results; neither is an exception.

normalize_suite

Returns a suite's canonical text and its full validation result to the HOST. The server never writes the file — the canonical text comes back to you as a string, and your file system is untouched. This is the read-only replacement for the spec's dropped write_spec capability: the server produces the bytes and validation, and the host — which already has file access, already shows the user a diff, and is already the thing the user authorised to edit their repository — decides whether and where to write them.

Normalization is opt-in: the normalize parameter defaults to false because the measured comment loss is permanent (see below). Without normalize: true, the tool returns the suite's full validation result exactly as validate_suite would at level: "full", wrapped alongside a null normalizedYaml field. A caller that has not said "I accept losing my comments" gets validation data, not a rewrite of their file.

Comments discarded. The YAML library this server is pinned to (YamlDotNet 16.3.0, matching the engine's own pin) cannot carry comment events through re-serialisation — the stream loader does not consume them, and the emitter corrupts documents on inline comments (a mapping value can be swallowed into a comment). Comment-to-node association is guesswork under reordering. This server evaluated comment-preserving normalization against the pinned library, found it failed, and chose the honest default: normalization drops all # comments. Do not set normalize: true without the user's explicit agreement on a commented suite, and diff before writing the result to disk.

Canonical form.

  • Key order is taken from the vendored schema's own property declarations, ranked with schema ancestors (outermost first) — but only for mappings the schema actually describes. A mapping of your own data is left in the order you wrote it, even when one of its keys happens to share a name with a schema field. Two rules make that true: a mapping reached through a key the schema declares as a free-form container (headers, body, variables, services, dependencies, capture, labels, env, parameters and the rest — the list is derived from the schema, not hand-written) is never reordered at all, and any other mapping is reordered only when a strong majority of its keys belong to the matching schema shape. Measured before those rules existed: headers: {zebra, id, alpha, type, name} came back as {id, type, zebra, alpha, name}, and a services map was reordered because one service was named image.
  • Sequences are never reordered. Step order is the suite's meaning.
  • Quoting follows single→double conventions only; the quoted↔plain boundary is never crossed, because that boundary carries resolved type information ('yes' unquoted is a boolean; "007" unquoted is the integer 7). Two exceptions are forced by the emitter rather than chosen: an empty scalar carrying an explicit tag (!!str) and a plain scalar the emitter will not write plain (measured: text outside the Basic Multilingual Plane, such as emoji, comes back double-quoted with \U… escapes). The value is identical in both cases — only its spelling changes.
  • Layout: every non-empty mapping and sequence is written in block style, two-space indented, with block sequences indented under their key. Empty collections keep their compact {} / [] form — the only thing block style can render them as. Long scalars are never folded onto a second line.
  • Anchors and aliases are preserved, never expanded. An anchor belongs to its NODE, so when reordering moves the aliased node's first occurrence, the &name definition moves with it and the *name reference follows. The graph is identical; the line the anchor sits on may not be.
  • Idempotent: normalize(normalize(x)) == normalize(x), byte-identically, LF line endings and exactly one trailing newline on every platform.
  • Read-only. No suite file is ever written, modified, or deleted; the canonical text is returned to you as a string only. Enforced by ReadOnlySourceGuardTests at source level and by on-disk byte, timestamp and sibling-file proofs.

The canonical text is proved before it is returned. After rendering it, the server parses it back and compares the result with an untouched parse of your input; if it does not re-parse, or re-parses to a different document, you get normalizedYaml: null and a normalizationRefused reason instead of text. There is one known shape that triggers this — an alias used as a mapping key (*anchor : value), which YamlDotNet's emitter writes as *anchor: and cannot read back. A refusal says nothing about your suite: the validation result is complete and unaffected. Never write a file from a response whose normalizationRefused is non-null — there is nothing to write.

Practical ceiling. The 2 MB input cap is set deliberately within the validation worker's 10-second budget: an admitted suite is expected to complete validation. Measured on a reference host at level: "full" over uniform http.rest suites, a worst-case-shape suite at the 2 MB cap validates in ~6.5 s (median) and ~7 s with normalization (slowest run ~7.2 s), leaving ~3 s margin before timeout. Measured curve: 0.5 MB takes ~2.1–2.6 s (validate/normalize); 1.0 MB ~4.0–4.5 s; 1.5 MB ~5.2–5.7 s; 2.0 MB (cap) ~6.5–7.0 s. Normalization is a ~10–15% surcharge. The timeout is deliberately not relaxed for this tool: it exists to bound uninterruptible parser spins. VFX-E-1150 is now reachable essentially only through transient host load or CPU contention rather than suite size — the cap no longer admits a suite that overruns the budget on the measured shape, though a pathological-but-legal YAML that is slow to parse while staying under every hard cap can still hit it.

Always validates at ValidationLevel.Full. There is no level argument. A caller could otherwise ask for schema-only validation and receive canonical text for a suite whose embedded AWS key the semantic pass never looked for — silently turning off the VFX-D-1207 secret-literal check on the one result a host is invited to write back to disk. That gate is structural here, not a rule to remember: the diagnostic appears because the full pass ran, and nothing in this tool can arrange for it not to. Every result carries the full validation outcome (valid, errors, semanticDiagnostics, semanticDiagnosticsTruncated, summary, level) and carries the same meanings documented in validate_suite.

Belongs to the CLI-free class. Like validate_suite, search_docs, and explain_diagnostic, this tool works entirely offline: no engine install, no network, no Docker. The suite is parsed inside the spawned --validate-worker child, under the same wall-clock timeout (10 seconds) and whole-tree process kill as validate_suite, whether the suite arrived as a file path or as inline YAML.

  • Parameters:
  • path (string, optional) — absolute or workspace-relative path to the .e2e.yaml suite file. A relative path resolves against the workspace root when the server was started with --workspace, and against the server process's current directory otherwise. Supply this OR yaml, never both.
  • yaml (string, optional) — the suite's YAML text, normalized directly without reading or writing any file. Supply this OR path, never both.
  • normalize (boolean, optional, default false) — set true to receive the canonical YAML in normalizedYaml. Default false, because normalization discards all comments in the suite. Left at false, normalizedYaml is null and only the validation result comes back. Do not set it to true without the user's agreement on a commented suite, and always diff before writing.

  • Result shape: { normalizedYaml: string | null, commentsDropped: boolean, normalizationRefused: string | null, validation: { valid, errors, semanticDiagnostics, semanticDiagnosticsTruncated, summary, level }, meta }.

  • commentsDropped states a fact about your document, not about the pipeline: it is true only when the suite you supplied contained at least one real YAML comment and canonical text was produced for it. A comment-free suite normalizes with commentsDropped: false — it was normalized, and nothing was lost. It is also false whenever normalizedYaml is null, because nothing was produced. Read it as "did my document lose comments", not "did normalization run".
    • The input is measured with the YAML tokenizer, not a text search for #. A # inside a quoted scalar (name: "a # b"), in an unspaced plain scalar (colour: #ffaa00), in a URL fragment (https://x.test/p#frag), or inside a block scalar is not a comment — none of those set the flag, and all of them survive normalization intact.
    • This was a per-document fix (issue #85). The flag previously reported true on every response carrying canonical text, on the reasoning that producing the text and dropping comments are the same act on the pinned library — true of the act, but wrong about a suite that had no comments to begin with.
  • normalizationRefused is null in every ordinary outcome. It is non-null only when canonical text was rendered and then rejected by the re-parse gate above, and it carries one of two fixed, content-free tokens: canonical-text-did-not-re-parse or canonical-text-changed-the-document. It is deliberately not a VFX-E-#### code: the taxonomy describes what is wrong with your input, and a gate refusal says only that this server's emitter could not render a fine document faithfully.
  • The reasons normalizedYaml can be null are told apart by normalizationRefused and validation — commentsDropped is false in all of them, so it is not the discriminator. A non-null normalizationRefused means the emission was refused and names which half of the gate failed. A null one means either that normalization was not requested or that there was no document to canonicalise; validation separates those two only when the suite is otherwise fine, since a suite that is invalid and was never asked to be normalized presents exactly as one that could not be canonicalised. If you need that distinction, you already have it: you know whether you passed normalize: true.
  • The validation object is the complete validate_suite payload — same field meanings, same structure, same codes in both channels. A suite that is merely invalid still has a canonical form; you get both the errors and the normalized text on a successful call.

  • Input validation:

  • Exactly one of path or yaml must be supplied; both or neither is an error VFX-E-1152.
  • A path of exactly --yaml-stdin is refused with the same error VFX-E-1152 (internal marker collision).

  • Error codes — returned as a tool error (isError true), carrying a single { code, message, docsUrl, retryable } object, because in each of these cases validity was never determined:

Code Meaning retryable
VFX-E-1001 The path is a UNC/network location, or (when a workspace is configured) resolves outside its root. false
VFX-E-1002 The suite file does not exist. false
VFX-E-1003 The suite file exists but could not be read. false
VFX-E-1150 The isolated validation worker exceeded its wall-clock budget and was killed. true
VFX-E-1152 Exactly one of path or yaml must be supplied; both, neither, or a path of exactly --yaml-stdin is an error. false
VFX-E-1901 The validation worker could not be started, crashed, or produced unusable output. true
  • Diagnostic codes in validation.errors[] and validation.semanticDiagnostics[] — identical to validate_suite's, returned as data on a successful call (the tool worked, the suite validity was determined). See validate_suite's tables above for the full catalogue and meanings. Of particular note for this tool: VFX-D-1207 (literal secret detected) always appears at severity error when its structural shapes are found, because validate_suite always runs at level: "full". Never returns canonical YAML without surfacing any detected VFX-D-1207 in the validation result.

  • Notable behaviour — process-isolated worker, file or inline. Identical to validate_suite: the YAML parse, schema evaluation, and normalization all run inside a disposable child process bounded by a 10-second wall-clock timeout, with the same whole-tree kill on timeout and the same stdout/stderr caps. Inline YAML is transported over stdin, never written to disk. A suite that does not parse is a successful call with summary: null, normalizedYaml: null, and error diagnostics; a missing/unreadable file or worker failure is a tool error.

  • Notable behaviour — a level argument is ignored, not honoured. There is no level parameter; a host that sends one anyway still gets validation.level: "full". That is what makes the VFX-D-1207 gate structural rather than a convention.

  • Never throws. Read-only always. A suite that is invalid is a successful call with diagnostics; a missing file, an unparseable worker response, or an input-validation failure is a structured tool error.

list_step_types

Lists every step type the pinned engine supports, in dotted <family>.<provider> form, grouped by family — loaded from the live CLI export vouchfx list --json, not from a hand-maintained or vendored-only catalogue.

  • Parameters: none.
  • Result shape: { families: [{ family, familyIntent, types: [{ type, provider, description, captureSupported, familyIntent, requiredResources, tier, supportsVerifyMode, docsUrl }] }] }, families ordered alphabetically, types ordered alphabetically within each family. requiredResources is a string array of acceptable alternative dependency kinds; any one declared kind satisfies the requirement. For storage-assert.s3, ["s3", "minio"] means either backend, not both. An empty array means "none, derived"; the field is omitted entirely for a step type this server cannot derive it for (e.g. a type the vendored schema does not define).
  • tier, supportsVerifyMode and docsUrl are the engine's own, relayed from vouchfx list --json (engine v1.0.0-rc.6 and later): tier is core or community; supportsVerifyMode is true when the type supports verifyMode: RETRY; docsUrl links the type's section of the language reference. Each is omitted, never defaulted, when the engine reports none — an older engine, or a type the engine cannot answer for. Every type the pinned engine lists is core. The engine's example suite for a type is left out of this list to keep it cheap: describe_step_type carries it.

Deliberately absent from every entry, never defaulted or guessed: vouched, which the engine's ProviderInfo catalogue record also defines. It is the Vouched badge the vouchfx-providers hub awards to a specific version of a Community package — a hub decision rather than an engine fact — so the pinned engine's vouchfx list --json deliberately does not emit it. - Requires the vouchfx CLI on PATH at ENGINE_PIN, with Spec A rich catalogue fields (requiredFields, optionalFields, captureSupported, familyIntent on every entry). A missing CLI, pin mismatch, or thin pre-Spec-A list is a tool error (fail-fast) — never a silent list of type keys without field metadata. - Error codes:

Code Meaning retryable
VFX-E-1401 The pinned vouchfx CLI is missing, version-mismatched, unparseable, not launchable, or its catalogue is too thin. false

This tool takes no arguments, so it has no argument-error code. - Call describe_step_type for the full required/optional field contract of any one type this returns.

describe_step_type

Describes one step type's full contract from the same live engine catalogue export as list_step_types: required and optional field names, capture support, family intent, and required resources.

  • Parameters: type (string, required) — the dotted <family>.<provider> type name exactly as list_step_types reports it, e.g. db-assert.postgres.
  • Result shape: { type, family, provider, description, fields: [{ name, type, description, required }], requiredOneOf, requiredFields, optionalFields, captureSupported, familyIntent, requiredResources, tier, supportsVerifyMode, docsUrl, example }. fields is derived from requiredFields / optionalFields (type/description may be null for live-export entries). requiredResources is a string array of acceptable alternative dependency kinds; any one declared kind satisfies the requirement. An empty array means "none, derived"; the field is omitted entirely for a step type this server cannot derive it for. Excludes the common step envelope fields every step type shares (id, type, description, capture, verifyMode, timeout, continueOnFailure). tier, supportsVerifyMode and docsUrl are relayed from the engine exactly as for list_step_types, and example is a whole minimal suite exercising one step of the type, built by the engine's own scaffolder (every Core example passes vouchfx validate). Each is omitted, never defaulted, when the engine reports none.

Deliberately absent from every result, never defaulted or guessed: vouched, which the engine's ProviderInfo catalogue record also defines. It is the Vouched badge the vouchfx-providers hub awards to a specific version of a Community package — a hub decision rather than an engine fact — so the pinned engine's vouchfx list --json deliberately does not emit it. - Requires the same pinned Spec A CLI as list_step_types. Thin catalogues fail fast. - Unknown type: returns an MCP tool error listing every valid type, rather than crashing. - Error codes:

Code Meaning retryable
VFX-E-1250 The requested type is not in the live engine catalogue. The message lists every valid type. false
VFX-E-1401 The pinned vouchfx CLI is missing, version-mismatched, unparseable, not launchable, or its catalogue is too thin. false

Note VFX-E-1250 rather than the VFX-D-1201 a suite referencing an unknown type produces: asking about a type that does not exist is a call that cannot be performed, not a finding about a suite.

search_docs

Free-text search over the two vendored engine documents (the generated language reference and the recipes library) for a query, returning the most relevant sections.

  • Parameters: query (string, required) — free text, e.g. "how does verifyMode RETRY work".
  • Result shape: { query, matches: [{ source, headingPath, snippet, url }] }. matches is ordered most relevant first and capped at a fixed maximum; snippet is the section's body text, truncated with an ellipsis when long. For a long section where some searched term occurs only beyond that truncation point, the snippet is a window around that term's first occurrence instead of the section's opening — marked with a leading ellipsis — so the snippet always shows at least one of the terms that matched, rather than text mentioning none of them. With a multi-word query the window may therefore omit terms that were visible in the opening. url is a deep link to the matching section on vouchfx.io (the document's published page, plus a #-anchor for the section).
  • Notable behaviour. Scoring is presence-based (which sections contain the query's terms), not raw occurrence-count based — a section that mentions every term once outranks one that repeats a single term many times. Never throws for a search outcome: a query with no matches returns an empty matches list, never an error; only an actual request cancellation is surfaced as cancellation.
  • Error codes: none. This is the only tool with no error shape at all — by design, not by omission. Every query, including one with no matches or an over-long one, is a successful call.

plan_coverage

Runs the engine's deterministic, read-only coverage-and-gap analysis over a declared .e2e.yaml suite set (a directory searched recursively, or a single suite file), an optional JSON Lines event history, and the pinned engine's live step catalogue. Invokes the pinned CLI vouchfx plan --json so CLI and MCP cannot drift (Spec D / M3 Planner). A call that finds gaps is a successful result — gaps are the data this tool exists to surface, never an error condition.

Planner path: plan_coverage → pick a gap finding → scaffold_suite (hints feed it unchanged) → fill semantics → validate_suite → run_suite.

  • Parameters:
  • path (string, required) — absolute or workspace-relative directory to search recursively for *.e2e.yaml suites, or a single suite file — the declared universe to analyse.
  • eventsPath (string, optional) — absolute or workspace-relative path to a JSON Lines event history file, or a directory of *.jsonl files. Omit for no history: every declared suite/step is reported never-run (a valid, successful analysis).
  • staleDays, flakyMinRuns, fragileMinEnvErrors, inconclusiveMin (integer, optional) — override the engine's history-health thresholds (defaults 30 / 2 / 2 / 2).
  • maxFindings (integer, optional) — 1–150. Asks for fewer findings in the response than the budget below would return; it can never raise that budget, and it never reaches the engine — the analysis is always complete. Out of range is refused, not clamped. Passing 150 is not a way to get more: see the fixed ceiling below.
  • Result shape: { schemaVersion, engineVersion, thresholds, inventory: { suites, services, dependencies, stepTypes, runCount, firstEventTs, lastEventTs, skippedEventLines, unmatchedObservations, unanalysableSuites, unmappableDependencies, omittedSuiteCount, omittedServiceCount, omittedDependencyCount, omittedStepTypeCount, omittedUnanalysableSuiteCount, omittedUnmappableDependencyCount }, findings: [{ kind, suite, stepId, target, targetKind, suggestedTypes, suggestedStepId, ambiguous, ambiguityReason, history, detail, relatedSuites }], omittedFindingCount, responseTruncated } — the schema-versioned report document. Every item is the engine's own, relayed verbatim; the seven omitted* counters and responseTruncated are this server's, and say how many items the response budget left out.
  • Response budget. plan_coverage is the one tool whose output scales with repository size: a 50-suite × 10-step repo with no run history yields one suite-never-run per suite plus one step-never-exercised per step — ~550 findings — and the MCP envelope carries every payload twice — once as structuredContent and once as a text block, where the second copy is the first JSON-escaped, so it costs more than the first. The server does not estimate that cost: it measures both copies of each candidate exactly before accepting it. The response is capped at 64 KB of wire envelope by a fixed ladder of list caps (150 → 75 → 50 → 30 → 10 → 0 items, applied to the findings array and to each of the six inventory arrays), each rung measured by serialising it rather than assumed to fit. The analysis itself is never narrowed — only what is relayed back.
  • 150 findings is a fixed ceiling, not just the top rung. No response ever carries more than 150 findings. Treat it as part of the contract: on a repository that produces more, omittedFindingCount is how you learn there were more, and narrowing path is how you see them. It is a true upper bound but a loose one — the byte budget always binds first in practice (measured: 300 minimally-sized findings come back as 75), so do not read 150 as a count you can expect to receive.
  • Everything left out is counted. omittedFindingCount and the inventory's six omitted* counters carry "produced but not in this response" semantics: they are the real remainder, whether the budget or your own maxFindings dropped the items. They are always present, 0 when nothing was omitted. responseTruncated is the single boolean summarising all seven; it means "this response does not carry every finding the engine reported" and is cause-blind — set identically whether the server's budget or your own maxFindings caused the omission, because a caller already knows what it passed and the signal exists for the case where something is missing, not for the case where the server is to blame.
  • What survives truncation is deterministic, most-actionable-first: suite-identity-ambiguous (it says the analysis could not attribute history unambiguously, so every other finding's suite is suspect) → step-fragile / step-flaky / step-inconclusive-prone (evidence-backed instability in steps that do run) → step-stale → the declared-but-unverified seams (dependency-missing-step-type, dependency-not-asserted, service-missing-http-step, each carrying a scaffold_suite hand-off hint) → any kind this build does not recognise (a future engine's addition, kept ahead of the floods rather than dropped first) → suite-never-run → step-never-exercised. The last two are last because they are the flood in a repo with no history, and the suite-level finding is the root cause of every step-level one beneath it.
  • Order on the wire is unchanged. Priority decides which findings survive; the survivors are still emitted in the engine's own deterministic order, so a caller whose response was never truncated sees exactly what it saw before.
  • Inventory bounds are on list length only — an oversized single item (say, a very long parse error on an unanalysable suite) is never trimmed mid-string, because this tool relays the engine's own text rather than inserting its own ellipsis into it. Such an item does not cost one rung: it collapses the response to the floor — every list empty, every finding dropped — since every rung above the floor still includes it. That floor response is still valid and fully self-describing (all seven counters carry the true source totals, responseTruncated is set, the thresholds and scalar counts survive), so you are told what happened and can narrow path. Per-item dropping was considered and rejected: it would need a selection policy within each of the six arrays, bought for an input only a defective engine produces.
  • Finding kinds: suite-never-run, step-never-exercised, dependency-not-asserted, dependency-missing-step-type, service-missing-http-step (coverage/vocabulary gaps — every one carries a suggestedTypes/suggestedStepId hand-off hint feeding scaffold_suite unchanged, except suite-never-run and an unmappable-dependency gap, which name no single step a scaffold call could act on); step-stale, step-flaky, step-fragile, step-inconclusive-prone (history-health, never gaps); suite-identity-ambiguous (a scenario-id collision or a since-renamed file, never a gap).
  • Requires the vouchfx CLI on PATH at ENGINE_PIN with the M3 Planner (vouchfx plan). A missing/mismatched CLI, an invalid suite path, or an out-of-range threshold is an MCP tool error — never a hang.
  • Error codes — note that finding gaps is never one of them; gaps are the data this tool exists to return:
Code Meaning retryable
VFX-E-1001 path or eventsPath named a network/UNC location (always refused), or resolved outside the configured workspace root. false
VFX-E-1006 An argument was rejected — a bad or missing suite path, an empty suite folder, an out-of-range threshold, or a maxFindings outside 1–150. false
VFX-E-1401 The pinned vouchfx CLI is missing, version-mismatched, not launchable, or lacks the M3 Planner. false
VFX-E-1603 The Planner ran but produced no analysis — it failed, timed out, overran its output cap, or returned unreadable output. false

VFX-E-1603 covers several causes, two of which (a timeout, an output-cap overrun) would be transient in isolation. It is nonetheless retryable: false, because its other causes are not, and in both transient cases the message tells you the genuinely useful thing — narrow path or eventsPath and retry, which is a different call. - Path safety. Both path arguments go through the same guard every other path-taking tool uses, and the resolved string is what reaches the engine's command line: a network/UNC location is refused in both workspace modes, and with --workspace configured a relative path is resolved against the root and the result must be inside it. Refusal happens before the CLI is spawned at all. The UNC half is a behaviour change — until issue #76 this tool passed both arguments to vouchfx plan unchecked. - Not a free-text parameter surface: no prompt / goal / natural-language field. Structured only. - Never writes, modifies, or deletes a suite file; never calls a model; never invokes git.

scaffold_suite

Generates a machine-drafted, catalogue-grounded, schema-valid .e2e.yaml suite skeleton from structured arguments only — never free text. Invokes the pinned engine CLI vouchfx scaffold --intent <temp-file> so CLI and MCP cannot drift. Free-text goals belong in the host LLM only; the host chooses step types via list_step_types first.

Generator path: free-text goal (host LLM) → choose types/ids → scaffold_suite → fill semantics → validate_suite → run_suite. This server does not host an LLM.

  • Parameters:
  • steps (array, required) — ordered list of { id, type, label? }. type is a dotted <family>.<provider> key from the live catalogue (e.g. http.rest, db-assert.postgres).
  • services (array, optional) — { name, image? } for environment.services.
  • dependencies (array, optional) — { name, type } for environment.dependencies (e.g. type: postgres).
  • Result shape (success): { yaml: string } — full document text, including a provenance comment block (machine-drafted / human review required). Credential-shaped fields use ${secret:…} references only.
  • Requires the vouchfx CLI on PATH at ENGINE_PIN with Spec B scaffold. Pin handshake matches run_suite / catalogue tools. A missing/mismatched CLI, unknown step type, empty steps, or other scaffold validation failure is an MCP tool error (message names the problem, e.g. nope.fake) — never a hang.
  • Error codes:
Code Meaning retryable
VFX-E-1006 An argument was rejected before anything was spawned — e.g. steps was empty. false
VFX-E-1401 The pinned vouchfx CLI is missing, version-mismatched, not launchable, or lacks Spec B scaffold support. false
VFX-E-1301 The scaffold produced no suite — the engine rejected the intent (an unknown step type or dependency kind), or the CLI timed out, overran its output cap, or produced nothing. false

As with VFX-E-1603 above, VFX-E-1301 unions several causes and stays retryable: false: its dominant cause is an intent the engine rejects, which an identical retry cannot fix. - Not a free-text parameter surface: no prompt / goal / natural-language field. Structured only. - Scaffold alone is not guaranteed run-green without further fill; it is guaranteed schema-valid placeholders for registered Core types.

run_suite

Runs one or more .e2e.yaml suites through the packaged vouchfx CLI and reports the verdict once the run(s) complete. Supply either path (one suite file) or paths (an array of files and/or workspace-relative globs) — exactly one, never both.

  • Parameters: path (string, optional) or paths (string array, optional) — exactly one must be supplied. path is a single suite file (glob syntax not expanded). paths is an array of files and/or workspace-relative glob patterns (e.g. e2e/checkout/** expands to the *.e2e.yaml files it matches under that directory); patterns with .. segments or absolute paths are refused. Globs that match no files are refused with VFX-E-1002 (no suite found). At most 50 entries, expanding to at most 100 suites; exceeding either is refused, never silently truncated. Any entry containing * or ? is read as a pattern, with no escape syntax — on Windows those characters cannot occur in a file name at all, but on Linux they can, so a suite literally named what?.e2e.yaml must be passed as path (which is never glob-expanded) rather than in paths. tags (string array, optional) — restrict all suites to steps/scenarios matching one or more tags. labels (object, optional) — free-form key/value metadata recorded with the run for later correlation (max 20 keys, key/value length bounds 64/256 chars; recorded in the run registry but not yet in the engine's JSON Lines event envelope — awaits engine support). timeoutSeconds (integer, optional, 1–3600, default 300) — abort the run if it has not completed in time; this is the WHOLE-call budget, not per-suite. keepEnvironment (boolean, optional) — only false (default) is implemented; true is refused with VFX-E-1504. wait (boolean, optional) — only true (default) is implemented; false is refused with VFX-E-1504.
  • Result shape (on Completed, in serialised field order): { runId, verdict, exitCode, cancelled, timedOut, remediationHint, steps: [{ stepId, verdict, durationMs, attemptCount, observation }], eventsFilePath, specs: [{ path, outcome, steps: […] }], eventsTruncated }.
  • runId: the id this run was registered under — pass it to get_run_events to read the run's raw event stream. null in exactly the case eventsFilePath is empty (the budget expired before any run was registered, so no id was ever minted). Note this is this server's id; the runId field inside the engine's own events is a different, bare-hex value.
  • exitCode: the CLI's own process exit code when this run covered exactly one suite; null for multi-suite runs (each suite's own outcome is in specs[]).
  • steps: the concatenation of every suite's steps, kept at the top level for backward compatibility; a caller that only reads this field is unaffected by multi-suite runs.
  • eventsFilePath: ONE file per run (not per suite), holding the complete JSON Lines event stream across all suites. Empty only when the call's budget expired before any run was registered (see the timeoutSeconds note below).
  • specs: an entry per suite this run covered, in run order. Each entry carries the suite's path (as resolved), its outcome (Pass/Fail/EnvironmentError/Inconclusive, or null for un-run suites when an earlier suite's cancellation or timeout halted the whole run), and that suite's own step list. A suite that fails (or hits an environment error) does not stop the run — every later suite still executes and reports its own outcome, with the overall verdict elevated across all of them; only cancellation or the whole-call timeout halts the sequence. There is deliberately no fail-fast option today; a host that wants to stop early cancels the call.
  • eventsTruncated: true when what you can read of eventsFilePath is not the whole stream — because it exceeded the reader's byte cap, because a multi-suite run's later parts were dropped once the stream reached that cap, or because appending one part failed. The verdicts in specs[] are computed before any merge and are unaffected.
  • Pre-flight failure shape (on VFX-D-1100, a successful call with isError false): { code: "VFX-D-1100", path, validation: { valid, errors: […] }, invalidSuites: [{ path, errors: […], omittedErrorCount }], omittedInvalidSuiteCount, undeterminedSuiteCount }.
  • invalidSuites: every suite the pre-flight found to be invalid, in validation order — not just the first. The pre-flight walks all of them anyway on the success path, so collecting every failure costs no extra work, and repairing a forty-suite glob takes one call rather than up to forty. Each entry's errors are the same { code, instancePath, message, line, column } findings validate_suite publishes. It can be empty in one situation — the size fit below shed every entry because the first suite's own findings filled the response on their own; those findings are in validation, and omittedInvalidSuiteCount then accounts for all of the entries.
  • path and validation describe the first invalid suite — which is exactly the suite they always described, since the pre-flight used to stop there. They are retained unchanged so a host written against the earlier shape keeps working; validation.errors is the one uncapped copy of that suite's findings.
  • Bounds, all visible. At most 25 invalid suites are collected and at most 10 findings are reported per entry; a measured byte fit then sheds trailing entries so the payload stays inside the 64 KB response class the other bounded tools use. omittedErrorCount says how many of that suite's findings are not listed; omittedInvalidSuiteCount says how many invalid suites are not described at all (the collection cap and the size fit combined). Both are 0 when nothing was omitted, and neither has to be inferred from a list length.
  • All-or-nothing is unchanged: one invalid suite still refuses the whole call and runs nothing. Only the completeness of the answer changed.
  • A pre-flight that could not reach a verdict is still a tool error, not this payload — a missing, unreadable or out-of-workspace file, or a validation worker that timed out, comes back as the matching VFX-E-100…/VFX-E-1150/VFX-E-1901 with the suite's path prefixed onto the message. Which leg you get is decided by the first failing suite, exactly as before, and the pre-flight stops walking there — a call that hits one of these still costs a single validation, as it always did.
  • undeterminedSuiteCount: how many later suites failed that way — validity never determined — while an earlier suite had already put the call on the data leg. They get no invalidSuites entry, because an entry asserts a diagnosis and none was reached, and because the code such a failure carries can be one this server does not catalogue. Counted separately from omittedInvalidSuiteCount on purpose: "known invalid, no room to describe it" and "could not tell" are different problems with different fixes. It is self-correcting — repair the reported suites and call again, and the undetermined one is eventually promoted to first (immediately, unless omittedInvalidSuiteCount is also non-zero), returning the full VFX-E-… explanation.
  • Stdio disconnection: closing the server's stdin cancels an active run through the existing graceful CLI shutdown path. Terminal registry persistence waits for the runner's teardown to finish; the aborted suite is Inconclusive, while the aggregate retains any earlier Fail or EnvironmentError verdict. A response cannot be required after the client disconnects. Continue draining both output streams while waiting for the server to exit.
  • The four taxonomy verdicts, never conflated: Pass, Fail, EnvironmentError, Inconclusive. A cancelled or timed-out suite is reported as Inconclusive, distinguished via cancelled vs. timedOut. The overall verdict is the worst of every suite's verdict (Pass < Inconclusive < Fail < EnvironmentError). remediationHint is populated for EnvironmentError (from the run's own environment-error events, or the CLI's stderr as a fallback — e.g. naming the Docker daemon when that looks like the cause), for Inconclusive when the run timed out, and — since issue #96 — for Inconclusive or EnvironmentError when the engine printed an environment-configuration diagnostic and a suite recorded no step result (the measured case is a dependency env: entry refused before any container starts — see the troubleshooting page): the engine's own sentence, bounded to 1,000 characters plus a truncation marker and sanitised, behind a fixed prefix. That covers both shapes the engine has used for such a refusal: no events file at all (v1.0.0-rc.5), and, from v1.0.0-rc.6, an events file recording one Inconclusive scenario with no step. For an EnvironmentError whose own environment-error events name a failing resource, that resource's hint still comes first; the engine's sentence replaces only the generic "check that Docker is running" text a stream naming nothing would otherwise get. null otherwise — except that a Fail can still arrive with the timeout hint: in a multi-suite run where an earlier suite failed and a later one exhausted the budget, the run-level verdict elevates to Fail while the timeout hint is still set. In a multi-suite run that completes, the hint comes from the most severe suite that produced one (the first of them on a tie), so an earlier suite's refusal hint never masks the hint of a later suite that made the run an EnvironmentError; a failing suite produces no hint, so a Fail can also arrive with a less severe suite's. Treat remediationHint as prose to show, never a field to branch on: it says why the run stopped, not why a failing suite failed.
  • Gate ordering, cheapest first — nothing is spawned unless every earlier gate passes: gated options (wait: false or keepEnvironment: true are refused with VFX-E-1504) → exactly one of path/paths (both or neither is VFX-E-1503) → argument safety (a path/tag beginning with -, out-of-range timeoutSeconds, or label bounds — all rejected with VFX-E-1006) → the same pre-flight validation validate_suite performs on every suite (an invalid suite is returned as a { code: "VFX-D-1100", path, validation, invalidSuites, omittedInvalidSuiteCount, undeterminedSuiteCount } payload with isError false, since an invalid suite is data, not a tool failure — the CLI is never spawned; a missing/unreadable file is the same VFX-E-100… tool error validate_suite returns for it, with the suite's path prefixed onto the message), all-or-nothing per run (one invalid suite refuses the whole call and runs nothing — invalidSuites is what tells you which of a glob's suites they were) → single-flight concurrency (at most one run per workspace at a time, enforced across separate server processes when --workspace is configured; a concurrent call is rejected immediately with retryable VFX-E-1501, never queued; a lock file that cannot be opened at all — planted link, directory in its place, permissions problem — is VFX-E-1502 instead, which is retryable too but for a different reason: not "wait for the other run" but "fix the directory, then the same call works") → CLI presence + version handshake (missing/mismatched returns VFX-E-1401) → the run itself.
  • timeoutSeconds bounds the whole call, starting at the first gate that touches the filesystem: path expansion, the per-suite pre-flight, the CLI handshake and the run itself all spend from the one budget. If it expires before any suite starts, the call returns the ordinary timed-out result — verdict: "Inconclusive", timedOut: true, every resolved suite in specs[] with outcome: null ("not run") — rather than an error, because a timeout is Inconclusive in the taxonomy and never an infrastructure failure. In that one case eventsFilePath is an empty string and runId is null: no run was registered, so no events file was ever created and no id was ever minted. The pre-flight spends from that budget for every suite in the call, including when an early one is already invalid (it no longer stops at the first invalid suite — though it does still stop at the first one whose validity could not be determined, since nothing after that can change the answer). The worst case is unchanged, because a call whose suites are all valid has always walked the whole set, but a large glob of invalid suites under a tight timeoutSeconds can now spend its budget in the pre-flight and return the timed-out result where it previously returned VFX-D-1100. Raise the budget, or name the single suite, if you want the earlier shortcut. (One thing is not interruptible: a single glob walk, because the matcher exposes no cancellation. A ** over a very large tree can therefore overrun the budget by the length of that walk; anchor the pattern with a literal prefix.)
  • Error codes — note that a failing suite is not among them: a run that fails is a successful call reporting verdict: "Fail".
Code Meaning retryable
VFX-E-1001 The path or glob is a UNC/network location, or (when a workspace is configured) resolves outside its root. false
VFX-E-1002 The suite file does not exist; or a glob pattern matched no *.e2e.yaml files. false
VFX-E-1003 The suite file exists but could not be read. false
VFX-E-1006 An argument was rejected before anything was spawned — a path/tag beginning with -, an out-of-range timeoutSeconds, a label past its count/key/value bounds, a glob that is absolute or contains a .. segment, or a path list past its caps (at most 50 entries, expanding to at most 100 suites totalling at most 24,000 characters). false
VFX-E-1150 The pre-flight validation worker exceeded its wall-clock budget and was killed. true
VFX-E-1401 The pinned vouchfx CLI is missing, version-mismatched, or not launchable. false
VFX-E-1501 Another run is already active per workspace (or on this server, if no --workspace is configured). true
VFX-E-1502 The run could not be recorded — the run lock or registry write failed, or the run's own metadata was too large to store. Nothing was run. true
VFX-E-1503 Both path and paths were supplied, or neither was. Supply exactly one. false
VFX-E-1504 wait: false or keepEnvironment: true were requested; only the defaults (true/false) are implemented today. false
VFX-E-1901 The pre-flight validation worker could not be started, crashed, or produced unusable output. true

The path/validation codes (VFX-E-1001, -1002, -1003, -1150, -1901) are shared with validate_suite by design: both tools run the same pre-flight check through the same classifier, so they can never give different answers about one file. run_suite adds one thing to those messages that validate_suite does not need — the suite's own path, prefixed — because its pre-flight covers every suite in the call and the guard that writes the message names none. (VFX-E-1006 is shared in name only: each tool rejects its own arguments, and the list above is run_suite's.) A suite that is merely invalid is the case that is not an error here — see the gate ordering above. - Serialisation & events. Every attempted run writes its own JSON Lines event stream (path returned as eventsFilePath); reading that file is bounded at 50 MB, with eventsTruncated: true when the file exceeded that and had to be read only up to the cap. A multi-suite run concatenates each suite's per-suite events into the single stream; per-suite attribution is tracked in specs[], computed before concatenation. explain_run is designed to read this same file afterwards. - Progress. Reports best-effort progress as the run proceeds (start, each relayed CLI output line, a closing summary) when the calling client requests MCP progress notifications.

Engine event correlation

The server's runId (run- plus 32 hex digits) remains the registry key, tool argument, log/span identifier and resource URI identity. Engine events use a separate runId namespace. A single server run can concatenate several CLI invocations, and engine events can name multiple scenario or advisory identifiers. Do not assume a one-to-one mapping or use an engine identifier as a registry key.

explain_run, diagnose_run's nested diagnosis, get_step_timeline, and environment/all get_run_artifacts results add an optional engineCorrelation: { runIds: [string], partial: boolean }. runIds contains exact distinct event-root identifiers in first-appearance order across the entire read stream, including supported identifiers on unknown event types. It does not attribute timeline attempts, steps or resources to individual suites. get_run_events already relays the engine's per-event runId under its existing raw-event sanitation and bounds; its contract is unchanged.

Correlation retains at most 16 identifiers, each at most 64 printable ASCII characters. Values are never shortened or normalised, so different identities cannot collapse through display sanitation. An unsupported value (null, non-string, blank, non-ASCII/control characters, or too long), a duplicate runId property on one event, an unparseable line, or the existing file/line scan limit makes partial: true when correlation is present. The remaining usable event fields and verdicts are preserved. partial: false describes the successfully read stream; it does not prove the engine finished writing or that a scenario completed.

The field is omitted when no parsed event has a runId property. If all observed properties are unsupported, it instead reports { runIds: [], partial: true }. Reports-only/logs-only artifacts calls, missing/unreadable artifact streams, and get_run_status do not read engine identifiers and omit it. The registry stores no second copy of event content. Derived correlation adds no secretReferences or other reproducibility-envelope data, and requires no engine pin or vendored schema change.

explain_run

Diagnoses a completed suite run in plain language, purely by reading and parsing its JSON Lines event stream. Never re-runs anything — no CLI spawn, no validation worker, no container.

  • Parameters: eventsPath (string, optional) — path to the run's events file; when omitted, the most recent finished run in the run registry is used automatically (spans server restarts when launched with --workspace; session-scoped otherwise).
  • Result shape: { verdict, categoryMeaning, summary, totalStepCount, passedStepCount, notableSteps: [{ stepId, verdict, durationMs, attemptCount, observation, attempts: [{ attempt, tMs, outcome, observation }], omittedAttemptCount, reason: { kind, hint } }], omittedNotableStepCount, environmentErrors: [{ errorKind, resourceName, detail, reason: { kind, hint } }], omittedEnvironmentErrorCount, eventsFilePath, eventsTruncated, responseTruncated, classificationHints: [string], engineCorrelation?: { runIds: [string], partial: boolean } }. See engine event correlation for its separate namespace and bounds.
  • No resourceUri here, unlike get_run_events and get_run_artifacts, and that is deliberate rather than an omission. vouchfx://runs/{runId}/verdict serves exactly this payload, but this tool is keyed by an events file path: the path you pass may belong to no registered run at all, so there is frequently no runId to build a URI from. If you have a runId and want the cacheable form, read that resource directly; eventsFilePath on the result is this payload's own handle.
  • categoryMeaning always accompanies verdict — a short, fixed explanation of what that CATEGORY means (e.g. that EnvironmentError is an infrastructure problem and explicitly not a test defect), so an agent never has to infer the taxonomy's meaning itself.
  • A run that recorded no step. When the engine stops a suite before any step runs, its stream holds a scenario and nothing else — from engine v1.0.0-rc.6 that is how a suite it refuses over its configuration arrives (see "A suite that validates clean aborts Inconclusive over its env: block" in Troubleshooting). Such a run is Inconclusive with totalStepCount: 0 and no notableSteps, and its summary says so and points at the engine's own reason — the message on the scenario-completed event in the file at eventsFilePath, which get_run_events also relays for a run run_suite registered — rather than guessing a timeout. diagnose_run's guidance does the same. "No step" means no step event of any kind: a run that started a step and never recorded its result also has totalStepCount: 0, but it did run, so it gets the ordinary Inconclusive summary.
  • notableSteps names every step whose own verdict is not Pass — a passing step is never "notable" — together with its full RETRY attempt timeline (attempts) and observation/diff evidence. Each step also carries an optional reason: { kind, hint } when the verdict classifier could assign one:
  • kind is one of pull, unhealthy, seed, timeout, capture_unmet, partition, or assertion (the only kind ever assigned to a Fail step); kind may be null for an unrecognised errorKind on an environment error, a fail-closed default rather than a guess. An eighth value, compile, completes the vocabulary but is reserved for a future engine capability — no rule in this build ever emits it. capture_unmet is assigned primarily from the engine's own step-completed observation {"captureUnmet": "<name>"} — MEASURED at engine v1.0.0-rc.5, and ungated, so it applies to a RETRY step too, not only an immediate one. The older inferred shape (a declared expected value paired with a literal null observed value on a step that did not poll) is still recognised as a fallback when the engine's own statement is absent. When that same completed event also lists one unique unmatched entry in captured[] with the same raw variable name, the hint names its declared JSONPath. This private evidence is attached to the exact parsed outcome; equal, sanitised or capped step IDs cannot join another event's captures. Without that proof, the hint says the step reported the unmet capture and does not assume it declared it. Captured values and reproducibility-envelope.secretReferences are not read by this diagnostic channel. Capture evidence is optional and bounded: the first 256 notable completed events, each at most 65,536 characters, at most 32 capture entries, 120 decoded characters per variable name and 2,000 per path. Names and paths are sanitised before their display caps (120/240 characters); the path fragment is further capped to 80 characters in a hint. Malformed, duplicate, mixed, over-limit or ambiguous metadata keeps the existing observation-based classification without declaring-step attribution. Missing metadata is a supported legacy case. Duplicate decoded property names (including case aliases) at the event root, observation or capture entry refuse private evidence before field binding. Unique root observation/step-ID aliases follow the primary parser's case-insensitive binding; capture variable values remain case-sensitive. Unknown fields stay optional and are not interpreted as authority.
  • hint is a bounded (≤ 300 character), never-empty plain-text explanation, with a visible … truncation marker when clipped. Carries only engine-derived text (an image reference, a resource name, a timeout, an observation sentence, a capture name). A ${secret:...} reference in that engine text is relayed verbatim but never resolved — the engine is the sole redaction authority, and this server neither resolves nor re-redacts its output.
  • For any notable step — Fail, EnvironmentError, or Inconclusive alike — reason is null when the rule table did not classify it; a step's own verdict never guarantees a reason. Environment-error records (environmentErrors below) are the surface that always carries one.
  • environmentErrors carries every environment-error event the run recorded. Each record carries a reason: { kind, hint } structured the same way as step reasons, always present (never null), with the same bounded, deterministic hint text.
  • classificationHints is a flat digest of every hint from the reason field on items this response actually includes (one per classified step, one per environment-error record), deduplicated by exact string with first-occurrence order preserved. Ten steps failing the same assertion yield one hint. Sourced from the tier-capped lists, not the omitted-count totals, so this field's size stays bounded by the response budget. Empty when no items carry a classification. This array is a display/summary digest only — it can include the hint of an environment-error record whose kind is null, and the flat string shape carries no kind. To branch programmatically, read notableSteps[].reason.kind and environmentErrors[].reason.kind, never this array.
  • The 32 KB diagnosis budget. The diagnosis payload is trimmed to fit 32 KB of serialised JSON, enforced through three fixed, deterministic detail tiers (rich → compact → minimal), each actually measured by serialising it rather than assumed to fit. responseTruncated: true marks that evidence was trimmed to fit; the full detail always still exists in the events file itself, whose path (eventsFilePath) is included regardless. eventsTruncated: true instead marks that the source events file itself exceeded the 50 MB read cap before any trimming even began.
  • The full MCP response is larger than that budget, and can exceed 64 KB. The 32 KB figure bounds the diagnosis payload, not the wire envelope. Every result is carried twice — once as structuredContent and once as a text content block — and the text copy is a JSON-escaped string, so it costs more than the structured copy. Measured against the largest diagnosis the tiers accept: a 32,229-byte payload produces a 71,335-byte response, a 2.213× multiplier rather than the 2× the halved budget assumes. The shared meta object contributes 384 bytes of that — 6.6% — so it is not the cause; the escaping is. Budget for a worst-case explain_run or diagnose_run response of roughly 70 KB today. Reducing it (via an on-demand resource hand-off for large evidence, rather than a raised cap) is planned work, not a current guarantee.
  • No run to explain: if eventsPath is omitted and the run registry contains no finished run, returns an MCP tool error saying so, rather than fabricating a diagnosis.
  • Path safety: a UNC/network eventsPath is rejected before any filesystem call is made against it, for the same forced-authentication reason validate_suite/run_suite reject one for their own path argument. When a workspace is configured, a relative eventsPath resolves against the workspace root and the result is checked for containment within it, using the same rules that apply to suite paths. No exemptions: containment applies uniformly to a caller-supplied eventsPath and to the default (omitted) one alike. Handing back the eventsFilePath run_suite returned still always works, and now for the ordinary reason — a workspace-configured server writes run artefacts under the workspace's own output directory, so that path is inside the root and passes containment on its merits. Without a workspace, containment does not apply at all.
  • Error codes — identical to diagnose_run's, since both tools read the same events file through the same guards and fail for the same five reasons. Note that a run whose verdict is Fail or EnvironmentError is a successful call: these codes are only about being unable to produce a diagnosis at all.
Code Meaning retryable
VFX-E-1001 The eventsPath is a UNC/network location, or (when a workspace is configured) resolves outside its root. false
VFX-E-1004 The events file does not exist. false
VFX-E-1005 The events file exists but could not be read. false
VFX-E-1601 eventsPath was omitted and the run registry contains no finished run. false
VFX-E-1602 The events file was read but contained no recognisable vouchfx event. false

diagnose_run

Healer (M2 / Spec C): the same taxonomy-faithful diagnosis as explain_run, plus two proposal kinds grounded in the event stream. Deterministic templates only — no LLM inside this server, no auto-apply, no writes to the customer's suite file, no engine healer-suggestion events.

Workflow: run_suite → events file → explain_run / diagnose_run → human (or host under human review) applies any accepted patch → validate_suite → run_suite again. Free text belongs only in the host conversation, not as a tool parameter.

  • Parameters: eventsPath (string, optional) — path to the run's events file; when omitted, the most recent finished run in the run registry is used (spans server restarts when launched with --workspace; session-scoped otherwise). Suite path is not required for v1; proposals are evidence-based from observations when suite YAML is absent.
  • Result shape: { diagnosis: { …same fields as explain_run…, including classificationHints and per-step/per-error reason }, proposals: [{ stepId, rationale, patch }], environmentGuidance: [string], specEditProposals: [{ stepId, scope, rationale, suggestedEdit }], omittedProposalCount: int, omittedSpecEditProposalCount: int }.
  • omittedProposalCount / omittedSpecEditProposalCount: how many proposals of each kind this run produced that are not in this response; 0 when you are seeing all of them. Two things contribute: the builders cap each list at 10, and the response-size ladder can drop proposals outright (it deduplicates entries it has rendered identical, and at its last stages empties both lists). You do not need to know which happened — the number answers "is there more, and how much". Both counters survive every stage, which matters most at the stage that empties the lists. What they do not report is detail shed from a proposal that is still present: an elided body says so itself (# (omitted…)), and diagnosis trimming is diagnosis.responseTruncated. In today's pipeline omittedProposalCount is 0 unless the ladder dropped proposals — the diagnosis tier caps notable steps at 10 before the Fail builder's own cap of 10 can ever bind.
  • proposals (review-only, Fail-only): non-empty only for step-level Fail with non-empty observation/diff evidence. Each proposal has stepId, a short rationale grounded in that evidence, and a patch (unified-diff style review comment / YAML fragment placeholders). Empty for Pass, pure EnvironmentError, and Inconclusive. Structure and semantics are unchanged from earlier versions — EnvironmentError and Inconclusive outcomes never produce a proposal from this list. At most 10 are returned; omittedProposalCount says how many more the run produced.
  • specEditProposals (scoped, EnvironmentError/Inconclusive): a second, distinct proposal list for outcomes where editing the suite is appropriate. Non-empty only for step-level EnvironmentError/Inconclusive or environment-error records where the reason classifier assigned a structured reason.kind. Empty for Pass and for every Fail step (an assertion is never weakened).

Ordering is a guarantee, not an accident: proposals derived from environment-error records come first, then step-derived ones. Note this differs from diagnosis.classificationHints, which follows the diagnosis's own reading order (notable steps, then environment errors) — the two adjacent arrays are deliberately ordered for different purposes, so do not correlate them by position. The environment records name the root cause, and one broken image can cascade into a dozen timed-out steps — putting the steps first let that cascade fill the 10-proposal cap and drop the proposal you actually needed. At most 10 are returned; omittedSpecEditProposalCount says how many more the run produced.

Each proposal carries: - stepId: the affected step's id, or null when the proposal concerns an environment-error record rather than a step (always null for the environment scope, which addresses image tags, dependency versions, and seed targets). - scope: one of exactly four values (a closed set): "environment" (image tag, dependency version, seed target); "timeouts" (raise timeout, adjust verifyMode); "match" (the key or headers a step polls on); "capture" (the extractor JSONPath that produced nothing). No other scope is ever emitted. A capture proposal requires the same event's unique unmatched captured[] entry to identify the declaring step and variable. Its suggestedEdit names the variable ('missing': "$.<path.to.the.value>") and includes the declared JSONPath in a bounded comment (at most 120 characters). It is a review suggestion, not a replacement expression inferred from the response. Consuming-step/observation-only cases get guidance without a declaring-step patch; a patch is also omitted when capping or sanitisation changes a target identifier. This private evidence adds no run_suite, reason or other tool response fields. - rationale: short text grounded in the classified reason (at most 500 characters — the measured worst case is 396 — and never empty; there is no enforced minimum length). The classifier's own hint may be embedded and may end in a visible … truncation marker if it was capped at 300 characters; that original bound is documented in this payload and not recapped. Exception — the response-size ladder (below): when the budget forces bodies to be elided, rationales are cut to 120 characters, and one stage further down every rationale is replaced by a fixed ~60-character truncation notice. Those two shapes are the only ones outside the range above. - suggestedEdit: a YAML fragment (never a unified diff — the same review-only framing Fail proposals use, because this server was never given a file path to diff against). Opens with a comment block explaining it is a suggestion only. Vocabulary comes from the vendored schema (real field names, never invented keys). Never auto-applied, never written to disk. Every identifier spliced into the YAML is single-quoted, so a step id or resource name containing ": ", a leading -, or # round-trips instead of changing the fragment's meaning when pasted; the # comment lines name the same identifier unquoted, for reading. For a health-gate failure the fragment targets environment.dependencies, and its comment says so explicitly: if the resource is a service, the knob is that service's own healthCheck instead. The event stream does not say which the resource was, so the advice names both rather than pointing silently at one. - A step's proposals are atomic. A timeout step yields two — a timeouts edit and a match edit — and the 10-proposal cap never splits them: a step whose proposals do not all fit is left out whole, and both count towards omittedSpecEditProposalCount. Half of that pair would mislead, since the match edit is the one saying values were observed and raising the timeout alone is unlikely to help. - Response trimming never changes which proposals a surviving step gets. A timeout step yields a match proposal exactly when the run actually observed values that did not match, and that fact is established by the classifier from the run's untrimmed attempt data — so the same step always yields the same proposals, however heavily the response was trimmed. A heavily trimmed response does cover fewer items (proposals are built from the tier-capped step and environment-error lists — see omittedNotableStepCount), and the shrink ladder below can elide or drop proposal bodies entirely. - environmentGuidance: infrastructure checklist when environment-error evidence is present (image pull, health, provision, Docker). Never accompanied by YAML rewrite patches for those failures. Inconclusive may include non-patch guidance only: for a run that recorded steps it points at their RETRY attempt timelines, and for one that recorded no step event at all it points at the engine's own reason instead, the message on the scenario-completed event in the file at eventsFilePath (get_run_events relays it for a run run_suite registered). Structure and usage are unchanged. - Never auto-apply: proposals of both kinds are returned in the tool result only — the tool is read-only and does not invoke git or write suite files. - Same path/error behaviour as explain_run: registry-based default (omitted eventsPath uses the most recent finished run), UNC rejection, workspace containment (uniformly applied, with no exemptions, and the same workspace-relative resolution — it shares explain_run's whole path-intake seam), missing/unreadable file, no recognisable events — structured tool errors, no hang. Response size aligned with explain_run's 32 KB diagnosis budget — and with the same caveat about the larger wire envelope documented there; full detail remains in the events file path inside diagnosis. Both proposal lists shed detail at the same points as the budget tightens: first the bodies go (a FailProposal's patch and a SpecEditProposal's suggestedEdit are replaced by a short "omitted" comment, with a shortened rationale kept), then the rationales — at which stage environmentGuidance also collapses to a single truncation notice and proposals that have become identical are deduplicated (Fail proposals by stepId, spec edits by stepId and scope, since one step legitimately yields both a timeouts and a match edit) — and finally proposals, specEditProposals, and environmentGuidance are dropped together, never one while the others survive. In the rarest case, where even that emptied shape plus the response wrapper will not fit, diagnosis itself falls back to explain_run's minimal shape: notableSteps, environmentErrors and classificationHints all empty, responseTruncated: true, the counts and the events file path retained, and the summary saying the detail could not be returned. - Error codes: exactly the five in explain_run's table above — VFX-E-1001, VFX-E-1004, VFX-E-1005, VFX-E-1601, VFX-E-1602, all retryable: false. Deliberately the same codes, not merely similar ones: a host that has learned explain_run's error handling already knows diagnose_run's.

explain_diagnostic

Looks up one catalogued diagnostic/error code and returns its plain-language explanation — the same content served by the errors resource family below, addressable directly from a code seen on any VfxError/Diagnostic this server returns. Never spawns the engine CLI; works fully offline.

  • Parameters: code (string, required) — a VFX-D-####/VFX-E-#### code exactly as seen in a result's code field, e.g. VFX-E-1002.
  • Result shape: { code, title, explanation, commonCauses: string[], fixes: string[], docsUrl }.
  • Error codes:
Code Meaning retryable
VFX-E-1903 The code argument does not name a catalogued code. false
  • Never throws for a bad code — an unrecognised value is a structured tool error, not a crash, and the server keeps advertising every tool afterwards.

get_schema

version selects v1 (the unchanged default) or v2. Both are embedded and available offline; optional live cross-verification applies only to v1. vouchfx://schema/v2 serves the same v2 schema. The existing latest resource alias remains v1 for compatibility.

Validation chooses v2 only when the suite explicitly declares metadata.schemaVersion: v2. That version permits metadata.telemetry: false; v1 rejects that field. Omitted versions use v1, and unsupported versions are refused. validate_suite, normalize_suite, and get_schema stamp their selected language version in meta.schemaVersion. Event wire schema versions remain independent. scaffold_suite continues to generate v1 suites.

Returns the composed JSON Schema — the whole document, one major section (metadata, environment, variables, steps), or one step type's own definition — formatted as a JSON Schema document or as a markdown digest built from the schema's field descriptions only. Works offline from the embedded composed schema this server vendors at its pinned engine commit; when a matching vouchfx CLI is installed, cross-verifies the embedded schema against that engine's own vouchfx schema export.

  • Parameters:
  • section (string, optional, default "full") — which part of the schema to return: "full", "metadata", "environment", "variables", "steps", or "step:<family>.<provider>" for a single step type's definition (e.g. "step:http.rest"). Case-sensitive. Unknown sections (e.g. a step: for a family/provider not in the schema) are rejected with a tool error. Mind the cost of the default: the full document is ~105 KB of JSON, ~220 KB on the wire (the payload is carried twice, as structured content and as text), so prefer a specific section — or format: "summary" — unless you genuinely need the whole contract in one call. The same advisory rides the tool's own description and the VFX-E-1151 catalogue page.
  • format (string, optional, default "json-schema") — the output format: "json-schema" for the schema subtree itself, or "summary" for a markdown digest of the section's field descriptions, capped at 8 KB. Case-sensitive.
  • Result shape: { schemaVersion, section, jsonSchema?, summary?, diagnostics? } — jsonSchema and summary are mutually exclusive, determined by the format parameter. diagnostics appears only when the optional live cross-verification detects a divergence.
  • Dependency class: CLI-optional — a third posture alongside the existing CLI-free and pinned-CLI-backed classes. Offline mode (no pinned CLI installed, or probe fails): serves the embedded composed schema and succeeds. Live mode (pinned CLI present and version matches ENGINE_PIN): cross-verifies the vendored schema against vouchfx schema output; a divergence is surfaced as a diagnostic, never as a tool failure.
  • Error codes:
Code Meaning retryable
VFX-E-1006 The format argument is invalid. Valid values are: json-schema, summary. false
VFX-E-1151 The section argument is not a valid schema section. Unknown step types (e.g. step:fake.provider) are rejected here. false
  • Diagnostic codes:
Code Meaning
VFX-D-1106 The pinned CLI's live vouchfx schema export disagrees with the embedded vendored schema. The matching pinned engine emits redirected UTF-8, so the comparison preserves Unicode differences and reports divergence or an unparseable export. The embedded (validated, byte-pinned) schema is still returned.
  • Notable behaviour — summary size budget. When format: "summary" is requested, the markdown digest is generated only from the schema's own description field annotations and is capped at 8 KB of rendered Markdown. Fields without descriptions are omitted, never placeholder-filled. At the currently pinned engine every section fits with room to spare — measured across all of them, the largest digest is 2,238 bytes, for step:mq-publish.kafka (about a quarter of the budget), and the full section's is 771 bytes — so truncation is not something you will see today; the cap is a postcondition of the renderer (guard-tested against synthetic oversized input) so that a future pin bringing a much larger section still cannot overrun it. Whether or not truncation occurs, the result payload always fits within the budget.
  • Notable behaviour — live cross-verification. The optional probe to vouchfx schema runs regardless of which section or format is requested, because it is a statement about the document this server is serving from (the vendored schema), not about the particular fragment the caller addressed. A host that only ever asks for summaries deserves to hear about drift just as much as one asking for full schemas. The verification fails silently (no CLI, a version-mismatched CLI, or a probe timeout) and reports nothing — absent a CLI is not a finding, and absent a CLI-dependent result is not a failure of this server's contract (the schema is still returned).
  • Never throws; read-only in the strongest sense: no suite file is touched, the optional CLI probe never writes, and nothing outside this server's embedded manifest resources is read for content.

get_run_events

Returns one page of a completed run's raw JSON Lines events, exactly as the engine wrote them — no summarising, no interpretation, no re-running. Use it to build your own timeline or dashboard over a run instead of consuming explain_run's summarised diagnosis, or to inspect an event type this server does not model. Never spawns the engine CLI, and never takes the run lock, so it is safe to call while a run is in flight.

  • Parameters:
  • runId (string, required) — the run to read, as returned on run_suite's own result. Resolved through the run registry, which spans server restarts when the server was launched with --workspace and is session-scoped otherwise. This is this server's id (run--prefixed); the runId field inside the relayed events is the engine's own bare-hex run identifier, a different value, passed through untouched like every other engine field. Do not expect the two to match.
  • types (string array, optional) — only return events whose type is one of these (e.g. ["step-attempt", "step-completed"]). Matched exactly against the token the engine wrote. Omit, or send an empty array, for every type.
  • stepId (string, optional) — only return events belonging to this step id. Matched exactly. Omit for every step.
  • limit (integer, optional) — maximum events to return; default 200, maximum 2000. An out-of-range value is refused (VFX-E-1006), never silently clamped, so a short page is never mistaken for the end of the stream.
  • cursor (string, optional) — a nextCursor from a previous call, passed back unchanged.
  • Result shape: { eventSchemaVersion, events: object[], nextCursor?, truncated, resourceUri } (plus the shared meta object every successful result carries).
  • resourceUri: vouchfx://runs/<runId>/events — the advertised resource for this run's event stream, always present. It identifies a resource; it is not a permalink to this page. A URI template has no slot for a filter or a cursor, so reading it always returns the unfiltered first page at the default limit — identical to this response when you asked for exactly that, and the right stream but a different slice when types, stepId, limit or cursor narrowed it. It is deliberately present on every page rather than only on unfiltered ones, so its absence never has to be read as "no such resource".
  • truncated: true when this page is not everything the stream held — the events file exceeded the 50 MB read cap, the scan hit its 2,000,000-line backstop, an over-long line was passed over with its type unreadable (on a filtered page only), or a mid-file line was not parseable as a JSON object at all. All four are described below. Read it together with nextCursor, never instead of it: nextCursor says more matching events remain within what was read; truncated says what was read is not all there was. The combination to watch for is truncated: true with no nextCursor — the walk has ended at this server's bound, not at the end of the run.
  • Filters are applied before paging. limit bounds the number of matching events returned, not the number of lines scanned. A run of 5000 events with 40 matches and limit: 10 returns ten matching events, four pages in total.
  • Wire vocabulary, not response strings. Events carry the engine's own tokens — PASS / FAIL / ENV_ERROR / INCONCLUSIVE — never the Pass / Fail / EnvironmentError / Inconclusive strings that run_suite, explain_run and diagnose_run put on their results. This is the raw wire boundary; the two vocabularies name the same four-way taxonomy and must not be conflated when you consume both tools.
  • Unknown event types and unknown fields pass through untouched. The v1 event contract is additive-frozen, and a raw-event tool that dropped what it did not recognise would be strictly less useful than reading the file yourself.
  • Every relayed value is sanitised, so the text is not byte-identical to the file. String values and property names, at every depth, are rendered through the same control-character escaping explain_run applies: every character outside printable ASCII (0x20–0x7E) comes back as a literal six-character \uXXXX escape, so é in the file reads as é in the result. Nothing is re-redacted or re-resolved — the engine is the sole redaction authority and these bytes have already passed through it.
  • eventSchemaVersion is read from the stream's own version marker when it declares one, and otherwise reports the vendored composed schema's version. Measured against the currently pinned engine (v1.0.0-rc.5, 2026-09-12: a 16-line events file from a full passing run, 16 of 16 lines carrying the prefix), every event carries a "v":1,"schemaVersion":"v1" prefix, so the marker path is what fires in practice and v1 is what you receive. The vendored-version fallback covers a stream that declares nothing — an older engine's file, or one whose first 50 lines are all unparseable — and at the currently pinned engine it happens to produce the same string v1, so the value alone does not tell you which path ran. Either way it is identical on every page of one run.
  • nextCursor is opaque and single-purpose. Pass it back unchanged as cursor; do not construct, parse, or edit it, and do not carry it between tools or between runs. It is bound to the runId, types and stepId it was issued under and is refused (VFX-E-1506) if any of those change — limit may change freely between pages. It is absent, not null, on the last page: the server looks one matching event ahead before minting it, so its presence always means at least one further matching event exists, and you never learn the walk is over by fetching an empty page.
  • A page may be shorter than limit. Beyond the count, a page is bounded by a 32 KB serialised payload budget, and for realistic events that budget binds first. Measured against a step-attempt carrying a step id, an attempt number, a tMs and a small observation object: 147 bytes per event, so limit: 2000 actually returns 224 events in 32,920 bytes; the full 2000 would have been about 294,000 bytes, roughly 9x the budget. (resourceUri accounts for a constant 75 bytes of that total — a run id is a fixed length — and does not change the event count, since the budget is spent against the events array.) When the budget stops a page early you still get a nextCursor — check that, not the event count, to decide whether the walk is over. (As with explain_run, the 32 KB figure bounds the payload rather than the wire envelope, which is larger because every result is carried twice and the text copy is escaped.)
  • A single oversized event is replaced, not trimmed. An event whose relayed form would exceed 4 KB comes back as a small marker object carrying its type and stepId plus "_vfxTruncated": true and "_vfxOriginalBytes": <n>. The underscore prefix marks these as this server's own fields, never the engine's. Trimming the event field by field instead would produce something that looks like the engine's event but silently is not. The same marker is what you get for every other "cannot reproduce this faithfully" case: an event with more than 256 properties or array elements at one level, one nested deeper than 24 levels, one whose line exceeds 1 MB (refused before it is parsed at all — with one exception: on a types/stepId-filtered page an over-long line is passed over with no marker, because its type was never readable and asserting it matched your filter would corrupt the timeline you narrowed on purpose; that page reports truncated: true, since the line was dropped without ever establishing whether it matched), and one carrying an unpaired \uD800–\uDFFF surrogate escape — in a value or in a property name, neither of which can be decoded to text.
  • Every other bound is marked too — nothing is shortened silently. A single string value or property name is kept to 2000 characters; when any string in an event was cut, that event carries "_vfxStringsCapped": true alongside the engine's own fields. (The cap is applied before escaping, so a capped string can still be longer than 2000 characters in the result.)
  • Malformed lines are skipped, not fatal — and a mid-file one sets truncated. A line that is not valid JSON, or is JSON but not an object, is passed over — the same tolerance explain_run applies, so one forward-incompatible line never makes an otherwise-good run's events unreadable. An empty events file is an empty page, not an error. If such a line has further lines behind it, the page reports truncated: true, because the scan completed but silently omitted something: it is a hole in the middle of the stream, not the partial last line a byte-capped read routinely ends on (that case is already reported by truncated for its own reason, and is not double-counted). Note the deliberate asymmetry with the previous bullet: a line this server cannot parse is skipped and flagged (nothing about it is knowable — not its type, not its step, not whether it is one event or half of one — so minting a marker for it would fabricate an event), while an event it can parse but cannot reproduce becomes a visible marker rather than a hole.
  • Error codes:
Code Meaning retryable
VFX-E-1001 The run's events path is a UNC/network location, or (when a workspace is configured) resolves outside its root. false
VFX-E-1004 The run is in the registry but its events file no longer exists. false
VFX-E-1005 The events file exists but could not be read. false
VFX-E-1006 An argument failed validation — a missing runId, an out-of-range limit, an over-long or over-numerous filter value. false
VFX-E-1505 No run with that runId is in the run registry. false
VFX-E-1506 The cursor could not be verified — malformed, from a different build or tool, or issued under different filters. false

get_run_status

Returns one run's current lifecycle state from the persisted run registry. Use it to poll a run you started, to re-find a run after your own process restarted, or to turn a runId into the eventsFilePath the diagnosis tools read. Never spawns the engine CLI, and never takes the run lock, so it is safe to call while a run is in flight.

  • Parameters:
  • runId (string, required) — the run to report on, as returned on run_suite's own result or by list_runs. Resolved through the run registry, which spans server restarts when the server was launched with --workspace and is session-scoped otherwise.
  • Result shape: { run } (plus the shared meta object every successful result carries), where run is the registry's own record:
  • runId — this server's id for the run (run- followed by 32 hex characters). Note this is this server's id; the runId field inside the engine's own events is a different, bare-hex value.
  • status — running, completed, or cancelled. cancelled appears only for a run stopped by cancel_run; a run stopped by its own timeoutSeconds budget or by the MCP caller cancelling the call is completed with an Inconclusive outcome, which is what those have always reported.
  • outcome — the run's overall verdict (Pass / Fail / EnvironmentError / Inconclusive), or null while it is still running. These are the response strings, not the engine's wire tokens that get_run_events relays.
  • startedAt / finishedAt — UTC timestamps; finishedAt is null until the run reaches a terminal status.
  • specPaths — every suite this run covered, in the order they ran (a multi-path or glob run_suite call is one run with several entries here).
  • eventsFilePath — the single JSON Lines event stream for the whole run. Hand it to explain_run, or hand the runId to get_run_events.
  • labels — the labels run_suite recorded, verbatim; {} when none were sent. This is the only place a run's labels are readable, and what list_runs' label filter matches against.
  • remediationHint — run_suite's own hint for this run (vouchfx-mcp#114), persisted verbatim at the run's completing write; null when the run produced none (the ordinary case for Pass, and for most other outcomes). This is what makes a pre-topology engine refusal's explanation — or the timeout hint of a run that was registered — recoverable after the original run_suite result has left your context: see "A suite that validates clean aborts Inconclusive over its env: block" in Troubleshooting for the case this closes. A run recorded before this field existed reports null here regardless of what it actually produced, because the registry format version at the time carried no such field to read back. So, rarely, does a run whose registry entry would cross that cap once completed: the completion is then recorded without the hint rather than not at all, whatever the hint's source. Ordinarily that hint survives in the original run_suite result; it does 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 other copy to recover. A timeoutSeconds budget that expires before any suite starts registers no run at all (runId is null; see run_suite above), so its hint is never stored here. On the way out the hint is escaped the way specPaths are — control characters and non-ASCII come back as \uXXXX — and clipped at 4,096 characters plus a truncation marker, because the registry directory is not a trusted boundary. Every hint run_suite writes is already escaped and far shorter, so it comes back unchanged.
  • This is the registry's record, not a second status model. explain_run, diagnose_run and get_run_events resolve a runId through the same entry, so this tool can never disagree with them about a run's state or about where its events live.
  • Per-step detail is deliberately not here. Steps, attempts, observations and environment detail live in the events file, because the registry stores run metadata only and never copies event content into persistent storage. Call explain_run for a diagnosis, or get_run_events for the raw stream.
  • A running status is recorded bookkeeping, not a liveness check. Readers never take the workspace claim. Startup and pre-run maintenance can reconcile validated orphan records as completed / Inconclusive; finishedAt then records recovery time and remediationHint states that engine cleanup is unconfirmed. A held claim, scan/mutation limits or unsafe metadata can leave an orphan unreconciled. cancel_run can report VFX-E-1508 when no MCP claimant owns the workspace; that does not establish that a crashed server's engine child or containers are gone.
  • History is retained automatically in workspace mode. Defaults are 1,000 eligible terminal runs and 30 days by trustworthy finishedAt; --keep-run-history disables pruning but not recovery. See retention settings and limits. After pruning, tools return VFX-E-1505 for the removed run ID.
  • Error codes:
Code Meaning retryable
VFX-E-1006 runId was missing, blank, or over 2000 characters. false
VFX-E-1505 No run with that runId is in the run registry. false

cancel_run

Asks a run that is still in flight to stop, and reports what could be done about it. Unlike every other tool in this section, cancel_run is not read-only — it exists to change a run's lifecycle — though it deletes nothing and overwrites nothing. (It is not quite "writes nothing" either: its liveness probe goes through the workspace run lock, which creates the output directory and an empty <outputDir>/.lock if they do not exist yet. Both are this server's own inert artefacts — the lock file carries no payload by construction — and the next run_suite call would have created them anyway. Nothing of yours is touched.)

  • Parameters:
  • runId (string, required) — the run to stop.
  • reason (string, optional) — free-form context for why. Held in this server's memory for the operator's benefit only: it is never persisted, never echoed back, and never appears in any other tool's result. Bounded at 2000 characters.
  • Result shape: { runId, status } (plus the shared meta object), where status is one of:
  • "cancelled" — the run was in flight in this server process and has been asked to stop. This call does not wait for the stop to complete: the engine is given its full grace period first (see below), so blocking here would hold the MCP request open for tens of seconds. To observe the terminal state, poll get_run_status until the status is terminal — completed or cancelled — never until it reads cancelled specifically. Two windows make the narrower condition non-terminating: a cancellation delivered in the instant the run was already composing its completing record is signalled honestly and still ends the run as completed; and a completing write that fails outright (announced on the server's stderr) leaves the entry at running until a later eligible startup/pre-run maintenance pass reconciles it; that pass does not prove engine cleanup.
  • "already_finished" — the run had already reached a terminal status. isError is false — this is a normal answer, not a failure. A polling host loses this race routinely.
  • The stop is the same one run_suite performs, not a second path. Cancellation fires the very cancellation token the run is already executing under, so what happens next is exactly what happens when a run_suite call is cancelled: the engine's stdin is closed (its --shutdown-on-stdin-eof cue to shut down cleanly and tear its containers down), it is given ~35 s of grace, and only then is the process tree killed. There is deliberately no separate kill path here to drift from that one.
  • Cancelling a test is never reported as a test failure. The four-verdict taxonomy is preserved. What changes is the run's status (cancelled rather than completed); its outcome remains whatever the run genuinely reached. For the ordinary case — a run cancelled before any suite failed — that is Inconclusive. For a multi-suite run where an earlier suite had already produced a real Fail, the outcome stays Fail: overwriting it would discard a verdict the engine demonstrably produced, and the cancelled status already records that the run was cut short.
  • Cancellation is same-process only, and says so rather than pretending. A run held by a different server process against the same workspace cannot be signalled from here: runs are serialized by a FileShare.None file handle at <outputDir>/.lock, and the exclusivity that makes it a lock is the same thing that stops one process sending anything through it. Rather than inventing a side-channel — a second cancellation path competing with the graceful stop above — the call is refused by name with VFX-E-1507. You are never told a run was cancelled when nothing was signalled.
  • VFX-E-1507's message is specific about what it actually established. The lock answers per workspace, not per run, so "the lock is held" never on its own proves another process is running the run you named. The message distinguishes three cases: another server process is running it (cancel it from there); the run has already finished here and its completing record was lost or is being written right now (ask again); or this server is busy with a different run, whose lock masks the probe entirely (list_runs, then cancel that runId — or wait and ask again).
  • It is also how you identify a phantom running entry. When the entry says running but that lock is free, no MCP claimant owns it and the entry is residue — from a server killed mid-run, or from a run whose completing registry write failed; that is VFX-E-1508. Reading the lock means briefly acquiring it, which is why only this (non-read-only) tool does it — and why, for the microseconds it holds the probe, a run_suite call starting at exactly that instant can be rejected with retryable VFX-E-1501. The probe is reached only when a running entry exists that this process is not holding, so it never runs during an ordinary run.
  • Error codes — note that already_finished is not among them:
Code Meaning retryable
VFX-E-1006 runId was missing, blank, or over 2000 characters; or reason was over 2000 characters. false
VFX-E-1505 No run with that runId is in the run registry. false
VFX-E-1507 The entry says running but this server is not holding it, and the run lock did not prove it residue — another process has it, its completing record was lost, or this server's own different run masks the probe. true
VFX-E-1508 The entry says running but the workspace's MCP claim is free. The entry is residue; engine cleanup is unconfirmed. false

list_runs

Lists the runs in the run registry, newest first, one page at a time. Use it to find a runId you no longer hold, to see what has run recently, or to correlate runs by the labels run_suite recorded. Never spawns the engine CLI, and never takes the run lock.

  • Parameters:
  • limit (integer, optional) — maximum runs to return; default 200, maximum 2000. An out-of-range value is refused (VFX-E-1006), never silently clamped.
  • cursor (string, optional) — a nextCursor from a previous call, passed back unchanged.
  • label (string, optional) — key=value matches runs carrying that key with exactly that value; a bare key matches runs carrying that key whatever its value. Both halves are matched exactly — there is no wildcard, prefix or substring matching, because a label is correlated by a machine rather than searched by a human.
  • since (string, optional) — an ISO-8601 timestamp; only runs whose startedAt is at or after it. A value carrying no offset is read as UTC, never as the server's local zone.
  • Result shape: { runs, nextCursor?, truncated } (plus the shared meta object). Each entry carries exactly five fields — runId, status, outcome, startedAt, finishedAt — with the same meanings get_run_status documents above. Spec paths, the events file, labels and the remediationHint vouchfx-mcp#114 added are deliberately not here, and #114 did not grow this five-field list to add it: call get_run_status for one run's full record.
  • truncated: true when the registry scan behind this page stopped at its 10,000-run bound, so the workspace may hold runs this walk can never reach (see below). Read it together with nextCursor, never instead of it — the same rule get_run_events states for its own identically named field: nextCursor says more matching runs remain within what was scanned, truncated says what was scanned is not everything. It describes the scan, not the page, so every page of a capped walk reports true. Always false without --workspace, where the registry is in memory and has no scan to cap.
  • Each page is a fresh scan; its position is a timestamp. The cursor resumes at "the first run started strictly before the last one you were given", not at an index — so runs started during a page walk (which always land at the head of a newest-first list) can never shift the page under you or cause a row to be served twice. The trade is stated rather than hidden: those newer runs are not inserted into the walk in progress. Start a fresh walk to see them.
  • nextCursor is opaque and single-purpose, exactly as get_run_events' is. Pass it back unchanged; do not construct, parse, or edit it, and do not pass a get_run_events cursor here or vice versa (that is refused, not misread). It is bound to the label and since it was issued under and is refused (VFX-E-1506) if either changes — limit may change freely between pages. It is absent, not null, on the last page: the server looks one matching run ahead before minting it.
  • A very large workspace is listed from a bounded slice, and paging one is slow. The registry examines at most 10,000 run directories per read, in the filesystem's own enumeration order — i.e. before the newest-first sort — so a workspace holding more than that many runs is paged over an arbitrary 10,000-run subset, and successive pages need not come from the same subset. Every page re-scans the whole registry, so a walk costs pages × scan. Measured (Windows 11, .NET 8, warm cache): 1,000 runs page in 132 ms and walk fully in 0.7 s; 10,000 runs page in 1.4 s and walk fully in 70 s; at 12,000 runs the read still returns 10,000 and the walk still yields 10,000 rows, so 2,000 runs are unreachable — but not silently: every page of such a walk reports truncated: true, sourced from the registry's own scan rather than guessed from the row count (10,000 rows back is otherwise indistinguishable from a workspace holding exactly 10,000 runs). Those historical timings predate automatic retention and are not a new benchmark of this build. Startup/pre-run maintenance now prunes eligible terminal history under the workspace claim, with the configured retention and bounded-pass limits. Preserved entries, opt-out or incomplete maintenance can still reach the scan caps. Repeated full metadata parsing and sorting remain; this change does not implement indexing.
  • Error codes:
Code Meaning retryable
VFX-E-1006 limit was out of range, since was not an ISO-8601 timestamp, or label was empty-keyed or over-long. false
VFX-E-1506 The cursor could not be verified — malformed, from a different build or tool, or issued under different filters. false

get_step_timeline

Returns one step's complete attempt timeline from a finished run — every RETRY poll the engine recorded for it, with what each attempt observed. Use it when explain_run showed a step that retried and you need the whole history rather than its first few attempts. Never spawns the engine CLI, never re-runs anything, and never takes the run lock, so it is safe to call while a run is in flight.

  • Parameters (all three are required):
  • runId (string) — the run to read, as run_suite returned it or list_runs reported it. Resolved through the run registry, which spans server restarts when the server was launched with --workspace and is session-scoped otherwise.
  • specPath (string) — the suite the step belongs to. Must be one of the paths this run covered; get_run_status lists them under specPaths. A relative path is resolved against the workspace root first, both sides are then normalised to full paths, and the comparison follows the platform's own file-name rules — so you do not have to reproduce the registry's exact string. See "specPath is validated, and only sometimes a filter" below for what it does and does not do.
  • stepId (string) — the step whose timeline you want, matched exactly and ordinally. Take it from explain_run's notableSteps[].stepId, or from get_run_events with types: ["step-completed"].
  • Result shape: { specPath, stepId, verifyMode, timeoutMs, declaredVerifyMode, attempts, conclusion, truncated, omittedAttemptCount, observedCapped, specPathAttributed } (plus the shared meta object). Each entry in attempts is { n, at, delayMs, tMs, outcome, observed?, error? }.
  • This tool is immune to explain_run's truncation, and that is why it exists. explain_run budgets its whole-run diagnosis in three fixed tiers, and the attempt arrays inside notableSteps[] are what those tiers shrink first: ten attempts per step at the largest tier, five at the next, none at the smallest. A forty-attempt poll loop is therefore cut to its first ten before you ever see it. get_step_timeline inverts that order — under budget pressure it shortens per-attempt evidence text and keeps every attempt, saying so with observedCapped. Both tools read the same parsed event stream, so they can never disagree about what happened; they differ only in what they are prepared to drop.
  • outcome is this tool's own three-value vocabulary, and it is not the verdict taxonomy. An attempt is:
outcome Meaning
matched This poll found what the step was waiting for.
unmatched It did not. This is the ordinary state of every poll before the last one under verifyMode: RETRY, and not a failure — the engine expects unmatched attempts and keeps polling.
error No match/no-match determination was reached at all: infrastructure prevented the assertion from being evaluated, the engine reported an indeterminate result, no outcome was recorded for the attempt, or the outcome token is one this build does not recognise. The attempt's error field says which.

These are not the Pass/Fail/EnvironmentError/Inconclusive verdicts that run_suite, explain_run and diagnose_run put on their results, and not the PASS/FAIL/ENV_ERROR/ INCONCLUSIVE wire tokens get_run_events relays. The step's own verdict appears in conclusion, which is the one field on this payload that legitimately names the four-way taxonomy. - attempts[].at is populated — but it is not a per-attempt instant. Every event the engine writes carries a ts property (a 33-character ISO-8601 instant with offset), step-attempt included, and it is relayed verbatim. What it is not is a stamp of when the attempt happened: the engine buffers its event stream and stamps ts as it writes the report, so every event in one run tends to carry the same handful of values. Do not difference two at values to time anything.

Use attempts[].tMs to order and time the timeline. It is the engine's own figure for how long that attempt took, in milliseconds, relayed verbatim and named exactly as the engine names it. - One field is always null: attempts[].delayMs — the backoff before the attempt. It is reported as an explicit null rather than omitted, and nothing is synthesised to fill it: the stream carries no inter-attempt delay on any event, and consecutive tMs values cannot substitute — tMs is each attempt's own duration, not a running elapsed figure, so differencing two of them is not a backoff. - timeoutMs and declaredVerifyMode are the suite's DECLARED shape, sourced from the step's step-started event (vouchfx-mcp#81). timeoutMs is the step's declared timeout in milliseconds; declaredVerifyMode is the step's declared verify mode, relayed verbatim: IMMEDIATE or RETRY from the pinned engine, which writes IMMEDIATE, its default, for a step that declared none. Both are null when this run's events carry no step-started line for the step at all — for example, an events file truncated before it — and declaredVerifyMode also when that line carries no verifyMode, which the pinned engine never writes. A multi-suite run whose suites happen to declare a step under the same id is a different, non-null case: timeoutMs/declaredVerifyMode come back first-wins, keeping the first suite's step-started event for that step id and silently ignoring later occurrences (see "specPath is validated, and only sometimes a filter" below for the identical collision applied to attempts). timeoutMs is independently null when the suite declared no explicit timeout: for the step — the engine then omits the property entirely rather than writing a default, so a null here means "no timeout was declared", not "not measured". Nothing is derived in either case: the largest tMs observed is the time the step actually took, which would be actively misleading under either name. Where a host needs certainty about which of the two nulls applies, get_run_events relays the raw step-started line untouched. - verifyMode describes what this run EVIDENCED; declaredVerifyMode describes how the step was DECLARED — the suite's own value, or the engine's IMMEDIATE default when it named none — two different questions, kept as two separate fields on purpose. verifyMode is RETRY when more than one attempt was recorded — only engine-owned polling produces that, so it is a fact. ONCE when exactly one was: the honest name for "this step was verified once in this run", and deliberately not a claim that the suite declared verifyMode: IMMEDIATE, since a RETRY step that matched on its first poll is indistinguishable here — that step reports verifyMode: "ONCE" beside declaredVerifyMode: "RETRY" simultaneously, and both are correct. verifyMode is null when the stream recorded no attempt at all for the step, in which case attempts is empty and conclusion says so; declaredVerifyMode does not depend on attempts at all, since step-started fires whether or not the step ever polls.

A null verifyMode is the ordinary shape for a step that did not retry, not an edge case. Measured against the pinned engine: an IMMEDIATE step emits a step-started and a step-completed event and no step-attempt event at all, so it arrives here with an empty timeline and a null verifyMode (but a populated declaredVerifyMode: "IMMEDIATE") — a normal, successful result. ONCE is therefore reported for the narrower population of steps that really did record exactly one attempt event, which in practice means a RETRY step that matched on its first poll. Read verifyMode: RETRY as "this step retried" and treat ONCE and null alike as "it did not".

Note that ONCE is this server's own response token, not a suite-language value; the suite language's verifyMode values are IMMEDIATE and RETRY — the same two tokens declaredVerifyMode reports verbatim. Do not copy verifyMode's ONCE into a suite, and do not treat declaredVerifyMode as a substitute for verifyMode in code that already keys on the latter's ONCE token — they answer different questions and a future engine could in principle widen declaredVerifyMode's vocabulary beyond IMMEDIATE/RETRY (it is relayed verbatim, not validated against a closed set). - specPath is validated, and only sometimes a filter. A path the run never covered is refused (VFX-E-1509), so naming the wrong suite is caught rather than answered. What it cannot always do is narrow the timeline: a multi-suite run_suite call concatenates each suite's stream into the run's single events file, and no line carries a suite attribution. So: - the run covered one suite ⇒ every event came from it, specPathAttributed is true, and the attribution is a certainty; - the run covered several ⇒ specPathAttributed is false, conclusion says so in words, and what comes back is the run-wide timeline for that step id. If two of the run's suites declare a step with the same id, their attempts are interleaved and cannot be separated. timeoutMs and declaredVerifyMode have the analogous — but not identical — collision: since each is a single declared fact rather than a list, only the FIRST suite's step-started event for that step id is kept, and the second is silently ignored rather than merged. - An unknown stepId is an error, not an empty timeline (VFX-E-1510). "This step ran and recorded no individual attempts" is a real and different state, returned as a successful result with an empty attempts array — if a typo produced the same shape, the two would be indistinguishable. Any one of step-started, step-attempt or step-completed is evidence that a step exists, so a step that started and never finished also gets a successful timeline, holding whatever attempts it recorded before the run was cut short (often none). - Bounds, and all of them are visible. observedCapped is true when at least one attempt's observed text was shortened or dropped to fit the response budget; the attempt itself is still present with its n, tMs and outcome intact. truncated carries the same meaning it does on get_run_events — what you got may not be all there is — and is set when the events file exceeded the 50 MB read cap, or when the response budget dropped attempts from the end of the list, in which case omittedAttemptCount says how many. That second path is unreachable for any realistic RETRY timeline, and the numbers are measured rather than estimated (Windows 11, .NET 8): forty attempts each carrying a 1,000-character observation come back whole, at 12,114 bytes against a 32,768-byte budget, with the evidence text cut to 200 characters per attempt; carrying no evidence text, 469 attempts fit — 47× explain_run's largest tier of ten. An exponential-backoff poll loop against a declared step timeout produces attempts in the tens. The bound exists because an events file is untrusted input, not because a real one is expected to reach it. - Evidence text is relayed, never re-redacted. observed is the engine's own observation field for that attempt, already redacted at its source (the engine is the sole redaction authority), then bounded and control-character-escaped by this server exactly as explain_run does it. No ${secret:…} reference is ever resolved. - Error codes:

Code Meaning retryable
VFX-E-1006 runId, specPath or stepId was missing, blank, or over its length bound. false
VFX-E-1505 No run with that runId is in the run registry. false
VFX-E-1509 The specPath names a suite this run did not cover. false
VFX-E-1510 The run's event stream records no step with that stepId. false
VFX-E-1001 The run's recorded events path is a UNC location, or resolves outside the configured workspace. false
VFX-E-1004 The run is in the registry but its events file no longer exists. false
VFX-E-1005 The events file exists but could not be read. true

get_run_artifacts

Reports what a finished run left behind — its event-stream artefact, and whatever environment resources the run's own events named. It reads only the run registry and that run's JSON Lines event stream: it never re-runs anything, never spawns the engine CLI, and never takes the run lock, so it is safe to call while another run is in flight.

This tool is deliberately partial today, and says so on every result. The engine owns its artifacts directory and this server is not told where it is; there is no container log access at all. Rather than return an empty section and leave you to guess, every result carries partial: true and a gaps array naming each field this build cannot populate, why, and the upstream ask that would close it (U4 — stable run ids, a detached run lifecycle, an artifacts directory, container log access).

  • Parameters:
  • runId (string, required) — the run to inspect, as run_suite returned it or list_runs reported it. Resolved through the run registry, which spans server restarts when the server was launched with --workspace and is session-scoped otherwise.
  • kind (string, optional) — which section to return: "reports", "logs", "environment", or "all". Omit (or send a blank) for "all". Matched case-insensitively and echoed back in its canonical lower-case spelling; any other value is refused with VFX-E-1006. A section you did not select is omitted from the result entirely, not returned empty — which is what lets you tell "I did not ask for logs" from "there are no logs".
  • container (string, optional, ≤ 256 characters) — which container's logs to tail. Accepted and validated, and it selects nothing today; it is echoed back on the result, sanitised, so you can confirm the server read it.
  • tailLines (number, optional, 1–5000, default 200) — how many log lines to tail. Accepted and validated, and it bounds nothing today (logs is always empty). A value outside the range is refused with VFX-E-1006 naming the maximum, not clamped — so you never believe you received more lines than you did, and the bound you code against now is the bound that will apply once log access lands.
  • Result shape: { runId, kind, partial, reports?, logs?, environment?, container, tailLines, gaps } (plus the shared meta object).
  • reports: { events: { path, available, resourceUri } }. html and junit are omitted, not null. resourceUri is vouchfx://runs/<runId>/events — the advertised resource that serves this artefact's content. resources/templates/list carries the template (vouchfx://runs/{runId}/events); this field is that template expanded for the run in hand, produced from the same single source, so the two cannot disagree. It is always present, including when available is false: the URI names a resource, and whether the bytes still exist is what available (plus a reports.events entry in gaps) tells you. Reading it for a run whose stream has been swept fails on the same condition, and with the same wording, that get_run_events reports — but not in a coded envelope: the VFX-E-1004 form is what the tool returns, while a resource read surfaces that same message as a plain protocol error. A blank URI would instead have made "no such resource" and "the file was cleaned up" indistinguishable. path is the registry's recorded path sanitised for display: identical to get_run_status's raw eventsFilePath for an ASCII path, and for one containing any other character a strictly narrower rendering of it (each becomes a literal \uXXXX escape, capped at 1,000 characters). It is therefore not openable verbatim when the path is non-ASCII — use get_run_status if you need to open the file.
  • logs: an array of { container, lines, truncated, resourceUri }. Always empty in this build.
  • environment: { services, dependencies, resources, truncated, omittedResourceCount }. Each entry in resources is { id, role, health, errorKind, detail?, occurrences, source }. id is null exactly when the event named no resource, and source then reads "environment-error-unnamed" instead of "environment-error" — the failure is still reported, with its errorKind and occurrences, but no identity is invented for it.
  • gaps: an array of { field, reason, awaits? }, where field is the dotted path of the missing field within this payload and awaits is the upstream ask ("U4") when one applies.
  • What is actually derivable, and what is not. The honest inventory, from the two sources this tool has:
Field Today Why
reports.events Populated The run registry mints and records the events file's path. available says whether that file still exists.
reports.html, reports.junit Omitted The engine writes its reports where its own flags direct; this server neither passes those flags nor is told the resulting paths. Awaits U4.
logs Always [] There is no container log access in this build: no engine flag exposes it and this server never talks to a container runtime. Never an error, and never a fabricated line. Awaits U4.
environment.resources Populated when the run had environment errors An environment-error event names the resource that failed. That is the only environment identifier the v1 event stream carries.
environment.services, environment.dependencies Always [] The event names a resource without saying whether it is a service or a dependency — see below. Awaits U4.
resources[].health Always null Live health needs a probe against a running environment, which this server has no channel to make and would not make after the fact. null means not observed, never "unhealthy". Awaits U4.
  • A healthy run reports no environment resources at all, and that is a correct answer. Identifiers reach this server only on environment-error events, so a run in which nothing went wrong has none to report. An empty environment.resources is not a failure of the tool.
  • Nothing is classified as a service or a dependency, on purpose. The suite language has both (environment.services and environment.dependencies), and an environment-error event carries a bare resource name that could be either. Rather than guess, every identifier is reported under the additive resources array with role: "unclassified", and both spec arrays stay empty. The one derivation that would classify them — reading the suite file's own environment: block — is refused for the same reason get_step_timeline refuses to read a suite for verifyMode: the file on disk today is not necessarily the file that ran, so the answer would be an assertion about the run sourced from something that is not the run.
  • A resource that failed repeatedly is one entry. Entries are deduplicated by the id the event carried — before any display cap, so two long ids sharing a prefix stay two entries — in first-appearance order, keep the first event's errorKind and detail (the failure that started the trouble), and carry an occurrences count. For the individual events, call get_run_events with types: ["environment-error"], which relays them raw and paged.
  • A swept events file is reported, not refused. If the run's stream has since been deleted or cannot be read, reports.events.available comes back false with a matching gaps entry, the environment section comes back empty with its own, and the other sections still answer. This differs from get_step_timeline and get_run_events, which return VFX-E-1004/VFX-E-1005 for the same condition — for those two the file is the answer, while here it is one input of three.
  • Bounds, and all of them are visible. At most 25 distinct environment resources come back per result, with each id, errorKind and detail individually capped — and then the assembled payload is measured, with resources shed from the end until it fits a 32 KB budget. The measurement is what guarantees the bound: character caps cannot see the wire encoder, which expands +, <, >, & and " sixfold, so a stream made of those characters is over budget at 25 capped entries (measured: 64,830 B, shed to 12 entries at 31,747 B). environment.truncated and environment.omittedResourceCount say when the resource list, the shed, or the 50 MB events-file read cap cut something short, and the omission count is against every distinct resource the parse saw. Nothing is dropped silently.
  • Relayed text is never re-redacted. A resource's id, errorKind and detail are the engine's own event fields, already redacted at their source, then bounded and control-character-escaped by this server. No ${secret:…} reference is ever resolved, and the environment section means the run's environment as its events described it — never this server process's.
  • Error codes:
Code Meaning retryable
VFX-E-1006 runId was missing or over its length bound, kind was not one of the four accepted values, container was over 256 characters, or tailLines was outside 1–5000. false
VFX-E-1505 No run with that runId is in the run registry. false
VFX-E-1001 The run's recorded events path is a UNC location, or resolves outside the configured workspace. false

Resources

Four concrete resources (advertised via resources/list) and seven URI templates covering six families, with error pages served under two schemes (advertised via resources/templates/list).

The concrete four are the two vendored engine documents, the workspace suite index (vouchfx://workspace/specs) and the DSL guide (vouchfx://docs/dsl-guide). The seven templates are vouchfx-docs:///errors/{code} and its vouchfx://docs/errors/{code} scheme alias, vouchfx://schema/{version}, vouchfx://examples/{name}, and the three vouchfx://runs/{runId}/… families. The counts are what resources/list and resources/templates/list actually return — a resource goes in the first listing when its URI has no {…} expansion and in the second when it has one, which is the protocol's own split and not a choice this server makes.

Static resources (advertised via resources/list)

DSL guide for agents

  • URI: vouchfx://docs/dsl-guide
  • Name: vouchfx DSL guide for agents
  • Read this one first when authoring. A single-read introduction to the .e2e.yaml language, written for a model reader — short, imperative, example-dense — covering the file's four blocks, state threading with capture and {placeholder}, verifyMode: RETRY with an explicit timeout, secrets as ${secret:...} references only, the four-outcome verdict taxonomy, and a do/don't list.
  • Every YAML block in it is a complete, schema-valid document rather than a fragment: copy one and edit it. This repository's own tests validate each block against the same vendored composed schema validate_suite uses, so the guide cannot drift from what the engine accepts.
  • This is this server's own writing, not a vendored copy — it is the one served document that is not byte-gated against the engine repository, which is exactly why its examples are schema-gated instead. It is served under the vouchfx://docs/ prefix beside the error pages; the two vendored documents keep their original vouchfx-docs:/// scheme.

Language reference

  • URI: vouchfx-docs:///language-reference
  • Name: vouchfx Language Reference
  • The generated .e2e.yaml language reference: every common step field (id, type, verifyMode, timeout, …) and every registered step type's required/optional fields. Byte-identical to the pinned engine's docs/language-reference.md.

Recipes

  • URI: vouchfx-docs:///recipes
  • Name: vouchfx Recipes: Common Patterns and Examples
  • Task-oriented recipes for common integration-testing scenarios — seeding fixtures, test doubles, secrets, engine-owned verifyMode: RETRY polling, message-queue verification, CI integration, and more — each a runnable .e2e.yaml example with explanation. Byte-identical to the pinned engine's docs/recipes.md.

Both documents are also what search_docs searches; reach the same content either as a resource your client reads directly, or as search results with deep links back to vouchfx.io.

Workspace specs index

  • URI: vouchfx://workspace/specs
  • Name: vouchfx workspace suite index
  • An index of the .e2e.yaml suite files under the configured workspace's specsDir. Each entry carries the suite's path (relative to specsDir, forward-slashed), name (metadata.name), tags, stepTypes, steps, and readable. Read it before authoring a new suite, so you extend or reuse an existing scenario instead of duplicating it.
  • Availability: always advertised, whether or not a workspace is configured. Without --workspace there is no specs directory to index, and the resource says so rather than failing or looking empty: it returns workspaceConfigured: false, specsDir: null, specs: [], and reason: "no-workspace-configured" with a detail naming the flag that would populate it. That distinction matters — specs: [] alone is indistinguishable from a workspace whose e2e/ directory is genuinely empty, which is a different situation with a different next action.
  • reason is omitted entirely for an ordinary complete answer. The complete set of values is:
  • no-workspace-configured — this server was launched without --workspace, so there is no specs directory to index.
  • specs-dir-missing — a workspace is configured but <root>/e2e does not exist.
  • specs-dir-unreadable — it exists but could not be enumerated (permissions, an I/O fault).
  • spec-limit-reached — the directory holds more than 500 suites and the walk stopped there; truncated is also true.
  • spec-worker-unavailable — the suites were found, but the child process that parses them never ran or never produced output. No suite was examined and no suite is blamed: every entry is listed with readable: false and a parseError that says nothing about the file itself. This is a fact about the machine, not about the workspace.
  • A suite that cannot be parsed is still listed, with readable: false and a one-sentence parseError. That is not a truncation: the list is complete, one entry is just thin. truncated reports only that suites are MISSING from the list.
  • Read-only: the enumeration and the containment check run in this server, and the per-suite YAML parse runs in a disposable child process — so one malformed suite cannot wedge the resource.
  • Uncached by design — do not poll it. Every read re-enumerates the specs directory and spawns up to three worker processes to parse what it finds. That is the right trade for the workflow it serves (write a suite, then ask what suites exist — a cached answer would be stale exactly there), but it means a host that polls this resource multiplies process churn on the developer's machine. Read it when you are about to author or review a suite. For anything you genuinely need to watch, poll a tool built for it, such as list_runs or get_run_status.

Templated URI families (advertised via resources/templates/list)

Schema versions

  • URI template: vouchfx://schema/{version}
  • Name: vouchfx composed JSON Schema
  • The composed .e2e.yaml JSON Schema — works offline from the vendored schema this server pins to the engine commit at ENGINE_PIN. Supported {version} values are:
  • The literal schema version string (e.g. v1) — returns the schema for that version if available.
  • latest — an alias resolving to whatever version the embedded schema declares, allowing a host to discover the version without prior knowledge.
  • Both resolve to the same byte-identical document; latest is never stale.

Error pages (two URI schemes)

  • URI templates: vouchfx-docs:///errors/{code} (the original) and vouchfx://docs/errors/{code} (scheme alias)
  • Name: vouchfx diagnostic catalogue page
  • One page per catalogued VFX-D-####/VFX-E-#### code — title, explanation, common causes, and fixes, in Markdown — served from the exact same embedded bytes explain_diagnostic parses (single source of truth: one file, two access paths). Historically served under vouchfx-docs:///; both URIs now work.
  • Example: vouchfx-docs:///errors/VFX-E-1002 and vouchfx://docs/errors/VFX-E-1002 return identical content for the same code.
  • An unrecognised code returns an MCP protocol-level error, not a crash; the server keeps advertising every tool and resource afterwards.

Example suites

  • URI template: vouchfx://examples/{name}
  • Name: vouchfx example suite
  • Three complete, comment-annotated sample suites teaching common integration-test patterns:
  • http-smoke — The smallest complete suite: two HTTP steps and explicit status assertions. Start here: every top-level block, both required step fields, no additional pattern.
  • capture-and-retry — State threading with capture, placeholder substitution, and verifyMode: RETRY polling. Includes a managed postgres dependency and parameterised SQL.
  • secrets-and-messaging — Secret references (never literals), a message-queue round trip, and a cache-assertion step. Demonstrates secret redaction and asynchronous verification.
  • Each file is complete, schema-valid, and comment-annotated. All three name sample container images that do not exist; hosts copying them must substitute their own images and environments. All are drawn from patterns in the pinned engine's own docs/recipes.md.

Run resources (read-only replicas of tool output)

Three separate families, each with its own advertised name.

  • URI template: vouchfx://runs/{runId}/verdict — Name: vouchfx run verdict
  • URI template: vouchfx://runs/{runId}/events — Name: vouchfx run events (first page)
  • URI template: vouchfx://runs/{runId}/logs/{container} — Name: vouchfx run container logs
  • Read-only resources serving the same payloads the corresponding tools return — explain_run, get_run_events, and get_run_artifacts — without requiring a tool call. Useful for caching or bookmarking a run's analysis.
  • verdict — same diagnosis structure explain_run returns for that run.
  • events — same first page get_run_events returns (without limit or cursor, defaults to 200 events per page).
  • logs/{container} — same log inventory get_run_artifacts returns, which is always empty in this build: there is no container log access at all (no engine flag exposes it and this server never talks to a container runtime — upstream ask U4). The response carries logs: [], partial: true, and a gaps entry naming the field, the reason and the ask. Nothing is ever fabricated into it. {container} is validated and echoed back and currently selects nothing; it is accepted today so this URI's contract does not change when log access lands.
  • You do not have to build these URIs yourself for the events family. get_run_events' result and get_run_artifacts' reports.events both carry a resourceUri field holding vouchfx://runs/<runId>/events for the run in hand — the same string advertised here, produced from the same single source, so a tool payload and this listing cannot disagree. explain_run carries no such field; see its own section for why.
  • {runId} is the run id as returned by run_suite (the server's own id, run- prefixed). Every template argument — {runId} and {container} alike — must be a single flat identifier: UNC or network-shaped values, path separators, and .. traversal are refused before anything resolves them, and the run's own recorded events path is separately checked against the configured workspace.
  • A swept or unreadable events file is handled ASYMMETRICALLY across the three, deliberately:
  • verdict and events return an error (VFX-E-1004 / VFX-E-1005 equivalents at the protocol level). For those two the events file IS the answer, so there is nothing to return without it.
  • logs/{container} succeeds with partial: true and a gap saying the events file is unavailable. For an artefacts inventory that file is one input of three, so its absence is a reportable fact rather than a failure — the same stance get_run_artifacts itself takes.

Prompts

Four MCP prompts, advertised via prompts/list and rendered by prompts/get. This is the complete set.

A prompt is a reusable, parameterised instruction a host can invoke on the user's behalf. These encode the method — the procedure a trained vouchfx operator follows — so any MCP host behaves like vouchfx's own authoring agent without a separate installation. Each one ships as a markdown file with YAML front matter under src/Vouchfx.Mcp/Prompts/, embedded into the assembly; the front matter is the single source of truth for the prompt's name, description and arguments, so what prompts/list advertises is built from the same file a human edits.

Every step in every prompt names a tool or resource this server actually serves. The spec's aspirational procedure references several that do not exist here (write_spec, compile_spec, suggest_scenarios, validate_spec, run_scenario, get_verdict, list_providers, get_topology); none of them appears in any rendered prompt, and a render test asserts their absence.

author_scenario

  • Name: author_scenario
  • Title: Author a vouchfx scenario
  • Walks a host through authoring a passing .e2e.yaml scenario using only this server's tools.
Argument Required Meaning
flowDescription yes The user-facing flow to cover, in plain language — e.g. "provision a customer".
flowId no An existing flow or scenario identifier to align with.
specPath no Where the finished suite should be written. Omitted, the prompt explains how to choose one under specsDir.
constraints no Limits to respect — e.g. "max 8 steps", "HTTP and Postgres only".

The rendered procedure is nine steps:

  1. Ground yourself — get_schema, plus the vouchfx-docs:///language-reference, vouchfx-docs:///recipes and vouchfx://examples/http-smoke resources.
  2. Find the gap, then scaffold — plan_coverage, then scaffold_suite; never start from a blank file. If the system's contract is unknown, ask for it rather than guessing.
  3. Copy the step contracts — list_step_types, then describe_step_type per step type; copy parameter names from the registry, never from memory.
  4. Write the YAML — capture + {placeholder} for state, verifyMode: RETRY with an explicit timeout for anything asynchronous, ${secret:...} references only.
  5. Validate and loop — validate_suite at level: full, reading both the errors and semanticDiagnostics channels. Maximum 5 iterations, then stop and report verbatim.
  6. Normalize, then write the file yourself — normalize_suite with normalize: true (the flag is required: it defaults to false, and without it normalizedYaml comes back null and there is nothing to write), then the host writes the file with its own file-editing tools. This server never writes a suite file. If normalization is refused (normalizedYaml: null with a normalizationRefused reason), or the result comes back with commentsDropped: true and those comments are worth keeping, the host writes the validated YAML it already has instead.
  7. Run it — run_suite, then explain_run.
  8. Interpret by taxonomy — Pass / EnvironmentError / Inconclusive / Fail, each with its own next action, and the rule that a Fail is a defect: do not change the assertion to make it pass.
  9. Final answer — path written, outcome, what the scenario proves, what it does not cover, open questions.

Omitting a required argument is an MCP protocol error, not a partial render; the server keeps serving afterwards.

heal_run

  • Name: heal_run
  • Title: Heal a vouchfx run
  • Walks a host through diagnosing a run that ended in EnvironmentError or Inconclusive and applying the smallest scoped fix — and forbids acting on a Fail except to explain it.
Argument Required Meaning
runId yes The run to heal, as run_suite returned it and list_runs reports it.
allowedScopes no Which proposal scopes may actually be applied, comma-separated. Defaults to every scope this server's Healer can emit: environment, timeouts, match, capture.

The rendered procedure states the taxonomy rule before the procedure, then walks six steps:

  1. Resolve the run — get_run_status with the runId, for its eventsFilePath and outcome. This step exists because explain_run and diagnose_run are events-file-native and accept no run id; the vouchfx://runs/{runId}/verdict resource is the one-step alternative.
  2. Diagnose — diagnose_run with that eventsPath; read each non-Pass step's reason.kind and the run-level classificationHints.
  3. Read the proposals, filtered by scope — diagnose_run's specEditProposals, applying one only when its scope is in allowedScopes. Out-of-scope proposals are reported, never applied. There is deliberately no scope for an assertion.
  4. Apply the edit yourself — with the host's own file-editing tools. This server never writes a suite file.
  5. Re-run once — run_suite with wait: true (the synchronous result is what makes the comparison possible). Once, not a loop.
  6. Compare and report — root cause, evidence quoted from get_run_artifacts or get_run_events, change made or recommended, and confidence.

allowedScopes' default is the same set SpecEditProposal.scope can ever carry (Diagnosis/SpecEditScopes), and a test asserts the two are identical in both directions — a prompt that listed a scope the Healer never emits would send a host looking for proposals that cannot exist, and one that omitted a scope the Healer does emit would silently forbid a whole class of legitimate fix.

review_spec

  • Name: review_spec
  • Title: Review a vouchfx suite before running it
  • A pre-flight review of an existing suite, producing a checklist rather than prose.
Argument Required Meaning
path yes The suite to review, absolute or workspace-relative.

The rendered procedure runs the mechanical checks first and then applies judgment where no tool can:

  1. Let the machine go first — validate_suite at level: full, reading both channels. Three of the seven review categories are already decided there — unused captures (VFX-D-1204), secret literals (VFX-D-1207) and async steps missing RETRY (VFX-D-1209, with VFX-D-1206 for RETRY without a timeout) — so the review reports those findings rather than re-deriving them.
  2. Find the coverage gaps — plan_coverage, which is this repository's gap-finding tool.
  3. Review by hand for all seven categories: tautological assertions, missing verifyMode: RETRY on async steps, hard-coded ids, missing negative-path coverage, coverage gaps, unused captures and secret literals.
  4. Produce the checklist — one row per finding with a severity (error/warning/info), the step it applies to, one sentence, and a concrete edit (the YAML to change, written out). A category with no findings is stated explicitly, so silence is never ambiguous.

Read-only: the review recommends, and the host applies anything it agrees with using its own tools.

explain_failure

  • Name: explain_failure
  • Title: Explain one step's outcome in plain language
  • Explains a single step to a developer who has never used vouchfx, in 200 words or fewer.
Argument Required Meaning
runId yes The run the step belongs to.
stepId yes The step to explain, matched exactly.

The rendered procedure gathers evidence, then writes:

  1. get_run_status for the run's outcome and specPaths.
  2. get_step_timeline with runId, stepId and a specPath from step 1 — all three are required, and the path must be one the run covered or the call is refused with VFX-E-1509. An unrecorded stepId is refused with VFX-E-1510 rather than answered with an empty timeline, and the prompt says so, because a host will otherwise read that refusal as a fault in its own call.
  3. Optionally explain_run for the run-level reason and classificationHints.

The explanation covers what the step attempted, what it observed (quoting the timeline's values, whose per-attempt outcome is matched/unmatched/error — the attempt's own vocabulary, not the run's verdict), and what the outcome means by taxonomy using the response strings Pass, Fail, EnvironmentError and Inconclusive — never the engine's wire tokens.