VERIFISDK docsengine v2 · contract 1.5.1 · harness-sdk 1.2.0
Harness authoring

Tools and slots

ctx.mcp() — the metered tool path, ToolResult semantics, session handling, timeouts, and automatic failover.


ctx.mcp(slot) is the only way a harness reaches a tool. There is deliberately no way to obtain a raw HTTP client from the context.

The reason is metering. Cost caps were removed from this platform, so metering is now the entire control surface — a harness that could open its own socket would move tool spend off the ledger, and the metering that replaced budget caps would be measuring a subset of reality. A bypass is not an overspend risk; it is a run nobody can see.

Source: verifi-harness-sdk/src/mcp-client.ts.

The slot handle

export interface McpSlot {
  readonly slot: string;
  readonly endpoint: string;
  listTools(): Promise<string[]>;
  call(tool: string, args?: Record<string, unknown>): Promise<ToolResult>;
}
export interface ToolResult {
  /** The tool ran and answered. False means it refused, errored, or was unreachable. */
  ok: boolean;
  /** Flattened text content of the response. */
  text: string;
  /** True when the SERVER reported a tool-level error (as opposed to a transport failure). */
  isError: boolean;
  /** Wall-clock for this call, for the run timeline. */
  latencyMs: number;
  /** Populated when ok is false. */
  error?: string;
  raw?: unknown;
}

call() never throws

This is the single most important thing to internalise.

const result = await kali.call('run_command', { command: 'nmap -sV example.com', timeout: 300, workspace: ctx.runId });

if (!result.ok) {
  ctx.log(`nmap failed: ${result.error}`);
  // carry on — this says nothing about whether the DNS checks work
}

A tool that fails returns ok: false; it does not throw. Tools are independent — a dead nikto says nothing about whether the DNS posture check works — so one failure must not abort a sweep that can still produce real findings. The failure is recorded as a tool_error event so it surfaces in the run timeline instead of vanishing.

The three failure shapes, all of which give you ok: false:

CauseisErrorerror
JSON-RPC error from the servertrue"<tool>: <server message>"
Tool-level error (isError: true in the MCP result)truefirst 500 chars of the response text
Transport failure, timeout, or run stoppedtruethe thrown error's message

What happens on the wire

McpClient speaks MCP streamable-http against ${endpoint}/mcp:

  1. POST initialize with protocolVersion: '2024-11-05' and clientInfo: { name: 'verifi-harness-sdk', version: SDK_VERSION }
  2. POST notifications/initialized (a notification — no id, no response expected)
  3. POST tools/call with { name, arguments }

Every request carries accept: application/json, text/event-stream. Both types are required — a streamable-http server answers 406 if you omit the SSE type. Any mcp-session-id response header is captured and sent on subsequent requests.

Responses are parsed as either JSON or SSE. For SSE the client takes the last data: line as the response to its request. content blocks are flattened to text: type: 'text' blocks contribute their text, anything else is JSON.stringify'd.

SDK_VERSION is an exported constant, currently '1.0.0', and it is the version your MCP will see in its logs. Note it is the constant that lags: the published package is at 1.2.0, but SDK_VERSION was never bumped alongside it, so the MCP handshake reports a version behind the package it shipped in. Read it as "which handshake string", not "which package".

Initialization is lazy, once, and never poisoned

The handshake happens on the first listTools() or call(), is memoized, and a rejected handshake is never cached:

})().catch((err) => {
  // Never cache a rejected handshake — the same poisoned-cache bug that took
  // prod Mongo down. A retry must get a real attempt, not the stored failure.
  this.#initialized = null;
  throw err;
});

Timeouts

The per-call timeout defaults to 300 000 ms (5 minutes).

McpClientOptions.timeoutMs is now a real option and createMcpResolver passes it through, so a harness that builds its own resolver can raise it — a recon sweep against a slow estate legitimately exceeds five minutes.

The production ASM harness clamps every command it sends to the Kali box at 285 seconds server-side so the tool is killed there and the MCP call always returns comfortably under the client's 300s ceiling:

// Clamp under the SDK's MCP client-call ceiling. ctx.mcp(...).call aborts with
// "tool call timed out" after 300s (hardcoded, no config hook). A tool whose own
// timeout is longer would blow past that: the client gives up while the box keeps
// running the tool orphaned, and the step is recorded as a timeout with zero output.
const MCP_CALL_CEILING_S = 285;

Do the same. Coverage on a genuinely long tool is capped, not lost.

Metering

Every call is metered whether or not it succeeded — a failed call still cost a round trip and the server still did work.

this.#o.ledger.record({ toolCalls: 1, evidenceBytes: Buffer.byteLength(clean, 'utf8') });

On a transport failure the client records { toolCalls: 1 } with no evidence bytes.

Redaction — and the one thing it must never scrub

Response text is scrubbed before it is metered or returned.

export function collectRunSecrets(env?: NodeJS.ProcessEnv): string[];
export function redact(text: string, secrets: string[]): string;

collectRunSecrets() reads the container's own environment and collects values whose variable name looks like a secret:

RuleEffect
name matches SECRET·TOKEN·PASSWORD·PASSWD·API_KEY·CREDENTIAL·PRIVATE_KEYcandidate
name matches ACCESS_KEY_ID or ends _URL / _ENDPOINT / _REGION / _ARNexcluded — an identifier or an address, not a secret
value shorter than 8 charactersexcluded — short values scrub fragments out of unrelated text
value is disabled, none, null, unset, changeme, placeholderexcluded — a placeholder is not a credential

Verified against harness-sdk@1.0.0 with a synthetic environment: MY_API_KEY and DB_PASSWORD are collected; AWS_ACCESS_KEY_ID, KB_ENDPOINT_URL, a three-character SHORT_TOKEN and NVIDIA_API_KEY=disabled are all correctly left alone.

redact() skips any secret shorter than 4 characters. It is a last-chance scrub, not a design: a secret should never reach a tool payload in the first place. Do not pass credentials as tool arguments.

Slots, failover, and why it is loud

harness ctx.mcp('kali') FailoverSlot every slot is wrapped in one, even with no fallback declared #primaryDown = false CALLS 1..n primary MCP slotFills.primary first ToolResult with ok: false #primaryDown = true a latch — it never un-sets every later call, without retrying the primary fallback MCP slotFills.fallback WHAT THE ENGINE HEARS mcp.fallback_used severity warn · once per slot per run · run continues mcp.no_healthy_tool severity error · nothing healthy behind the slot at all
Failover is a latch, not a retry. One failed call moves the slot to the fallback for the rest of the run — the primary is never probed again, so a flapping endpoint cannot cost the run n timeouts. Both events fire at most once per slot per run, and mcp.no_healthy_tool is the one that matters: it is what stops a run whose scanner was dead from reading as a clean bill of health.

ctx.mcp(slot) resolves from manifest.slotFills[slot] first, falling back to the older flat manifest.endpoints[slot]. Missing from both throws:

SlotNotFilledError: slot 'kali' has no endpoint in this run's manifest.
Filled slots: recon, web_proxy. The Service composition binds an MCP to every
required slot; a missing one is a provisioning fault, not something to work around.

Slots are memoized per run, so one MCP session is reused across the whole run rather than re-initializing on every call — and so failover state persists.

Every slot is wrapped in a FailoverSlot, even when there is no fallback:

export class FailoverSlot implements McpSlot { … }

It lives in src/failover.ts and is exported from the package root. (In 0.5.0 it lived in mcp-client.ts with a failover.test.ts beside it and no matching source file, which cost newcomers real time; it was moved in 1.0.0 and re-exported from its old home so no consumer broke for a filing decision they do not care about.)

SituationBehaviourEvent
Primary answersresult returnedtool_call (info)
Primary fails, fallback existsswitch permanently to fallback for the rest of the runmcp.fallback_used (warn), once per slot per run
Primary fails, no fallbackkeep calling the primarymcp.no_healthy_tool (error), once
Both failfallback result returnedmcp.no_healthy_tool (error), once

Once the primary is known dead, later calls go straight to the fallback rather than paying its timeout again on every tool.

The reason this class exists is written into it:

Before it, a slot resolved to ONE server, so a dead primary meant every tool call returned ok:false and the run simply produced fewer findings. Nothing said the scanner had gone — which is "Burp never served and nobody was told" reappearing one layer below the probe built to prevent it.

Failover is announced, not silent. mcp.fallback_used fires ONCE per slot per run: a run that quietly succeeded on the fallback every time is a broken primary wearing a green tick, and the count is what makes that visible.

mcp.no_healthy_tool is your cue to re-plan around a missing capability, not to treat it as one unlucky tool call.

listTools()

const tools = await kali.listTools();   // string[] of tool names

Unlike call(), listTools() throws on a JSON-RPC error (tools/list failed: …). FailoverSlot.listTools() catches a primary failure and retries on the fallback, but a fallback failure propagates.

A complete, real usage pattern

This is how the production ASM harness routes its entire toolchain through one slot, condensed:

async run(ctx: HarnessContext) {
  const kali = ctx.mcp('kali');

  const exec = async (command: string, timeoutMs: number) => {
    const timeoutS = Math.min(Math.max(1, Math.ceil(timeoutMs / 1000)), 285);
    const res = await kali.call('run_command', { command, timeout: timeoutS, workspace: ctx.runId });
    const raw = res.ok ? res.text : (res.error ?? 'run_command failed');
    // run_command returns "$ <cmd>\n(exit <N>)\n<real output>" — unwrap it, and use
    // the TOOL's exit code, not the MCP call's success.
    const m = raw.match(/^\$ [^\n]*\n\(exit (-?\d+)\)\n([\s\S]*)$/);
    if (m) return { output: m[2]!, ok: Number(m[1]) === 0 };
    return { output: raw, ok: res.ok };
  };

  // … drive the scan through exec() …
}

Note workspace: ctx.runId. The Kali MCP is shared across concurrent runs scanning different tenants; the workspace argument is what keeps one run from reading another's tool output on the shared disk. If you use a shared exec MCP, always pass the run id.