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:
| Cause | isError | error |
|---|---|---|
| JSON-RPC error from the server | true | "<tool>: <server message>" |
Tool-level error (isError: true in the MCP result) | true | first 500 chars of the response text |
| Transport failure, timeout, or run stopped | true | the thrown error's message |
What happens on the wire
McpClient speaks MCP streamable-http against ${endpoint}/mcp:
POST initializewithprotocolVersion: '2024-11-05'andclientInfo: { name: 'verifi-harness-sdk', version: SDK_VERSION }POST notifications/initialized(a notification — no id, no response expected)POST tools/callwith{ 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:
| Rule | Effect |
|---|---|
name matches SECRET·TOKEN·PASSWORD·PASSWD·API_KEY·CREDENTIAL·PRIVATE_KEY | candidate |
name matches ACCESS_KEY_ID or ends _URL / _ENDPOINT / _REGION / _ARN | excluded — an identifier or an address, not a secret |
| value shorter than 8 characters | excluded — short values scrub fragments out of unrelated text |
value is disabled, none, null, unset, changeme, placeholder | excluded — 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
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.)
| Situation | Behaviour | Event |
|---|---|---|
| Primary answers | result returned | tool_call (info) |
| Primary fails, fallback exists | switch permanently to fallback for the rest of the run | mcp.fallback_used (warn), once per slot per run |
| Primary fails, no fallback | keep calling the primary | mcp.no_healthy_tool (error), once |
| Both fail | fallback result returned | mcp.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:falseand 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_usedfires 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 namesUnlike 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.