Tools and HITL
Writing tools a harness can use safely — descriptions, return shapes, destructive tools, and the approval token.
Declaring a tool
FastMCP derives the tool name, description and input schema from the function:
@mcp.tool()
async def port_scan(host: str, ports: str = "1-1024", workspace: str = "") -> str:
"""Scan a host for open TCP ports.
`host` must be inside the run's authorized scope. `ports` is an nmap range.
`workspace` is the run id; output is written into that run's private directory.
"""
...The docstring is not decoration. It is the tool description a model sees when deciding whether to call the tool, and it is stored on the registry entry when the probe runs. Write it for a reader who has never seen your server: what the tool does, what each argument means, what units, and what it returns.
Return a string a parser can read
The harness receives the flattened text content of your response. It has no access to your Python types. So the return shape is your API.
The Kali MCP wraps every command in a self-describing envelope:
return f"$ {command}\n(exit {proc.returncode})\n{out}"Because the exit code is in there, a harness can tell "the MCP call worked" from "the tool succeeded" — which are different questions:
const m = raw.match(/^\$ [^\n]*\n\(exit (-?\d+)\)\n([\s\S]*)$/);The get_file tool takes the same approach with a discriminated prefix, so the caller can
tell the cases apart without guessing:
OK:<b64> the file's contents
ERR:notfound nothing was written there
ERR:toolarge:N over the size cap
ERR:escape the path left the workspaceTwo rules fall out of these:
- Make failure distinguishable from empty success.
""and "the tool found nothing" and "the tool crashed" must not be the same string. - Never return an empty success on error. Report it. A swallowed exception becomes "no result" becomes "fine".
Truncate, and say that you did
if len(out) > MAX_OUTPUT:
out = out[:MAX_OUTPUT] + f"\n…[truncated at {MAX_OUTPUT} chars]"Silent truncation is a parser bug waiting to happen — and worse, it can look like a clean result.
Destructive tools and the HITL token
The contract models this on the registry entry:
export const zToolDescriptor = z.object({
name: z.string().min(1),
description: z.string().optional(),
/** Destructive tools require an approved HITL token in the CallContext. */
destructive: z.boolean().default(false),
});And the TypeScript SDK's ToolDefinition makes the flag mandatory, with the reasoning
in the comment:
Defaulting to false would make forgetting the flag the dangerous direction, so every tool must state it.
Copy that rule. In Python it becomes a review discipline plus an explicit registry declaration:
- Decide, for each tool, whether it can modify, delete, disrupt or exfiltrate anything on the target. If yes, it is destructive.
- Say so in the docstring, in the first line, so it is visible to a model and to a reviewer.
- Declare
destructive: truefor it in the registry entry.
The call context
A destructive tool requires an approved HITL token in the call context:
export interface CallContext {
runId: string;
agentId: string;
correlationId: string;
/** Present only when a HITL approval was granted for a destructive tool. */
approvalToken?: string;
}The run policy sets the mode: zHitlMode is 'auto' | 'approve_destructive' | 'approve_all'.
The approval round-trip is carried on the gRPC channel as ApprovalRequest up and
ApprovalDecision down.
Refuse what you should not do
Independently of the token mechanism, refuse structurally dangerous operations outright. The Kali MCP does exactly this:
@mcp.tool()
def end_workspace(workspace: str) -> str:
"""Delete a run's workspace once its scan is done."""
d = _workspace_dir(workspace)
if d.resolve() == RUNS_ROOT.resolve():
return "refused: will not delete the runs root"
shutil.rmtree(d, ignore_errors=True)
return f"workspace {workspace!r} cleared"Note the shape of the refusal: it returns a string saying what was refused and why, rather than raising or silently doing nothing.
Also note the idempotency: a workspace that is already gone is a success, not an error. Teardown paths should always be safe to call twice.
Drop privileges
The Kali MCP may start as root — to bind its port and create its runs root — but every tool runs dropped to an unprivileged user:
argv = ["bash", "-lc", command]
if EXEC_USER:
argv = ["sudo", "-n", "-u", EXEC_USER, "-E", "bash", "-lc", command]There is a subtlety worth stealing. Because the service creates the workspace as root but tools run as another user, it hands the run's own directory (never the runs root) over:
if EXEC_USER:
try:
shutil.chown(d, user=EXEC_USER)
except Exception:
passWithout that, every tool that writes under $HOME — httpx's resolver cache, nuclei's
config dir, dnsx's temp dir — dies on "permission denied" before doing any work, and the
scan reads as "clean: 0 findings" when in truth nothing ran.
File transfer, if your tools need files
A harness that drives tools remotely still owns its files. It writes a target list on its
own disk, a tool on your box has to read it, and whatever the tool writes has to come back.
Without a transfer path, every file-based tool (nuclei -l, httpx -l, an -o output)
silently fails on a path that does not exist on your host.
The Kali MCP provides three tools scoped to the workspace: put_file(workspace, path, content_b64),
get_file(workspace, path), and list_workspace(workspace, since).
list_workspace returns <mtime>\t<size>\t<relpath> per line, so a harness can snapshot a
timestamp before a tool runs and pull back exactly what the tool created rather than the
whole workspace every time.
Every one of them is guarded by _safe_join, which is the single control the entire
file-transfer surface rests on: a caller cannot use .. or an absolute path to read or
plant a file outside its own run.
Do not accept credentials as tool arguments
If your tool needs a secret, read it from the environment on the server. A credential passed as a tool argument travels through the model's context, the harness's ledger accounting, the evidence byte count, and the run event log.
The harness SDK has a last-chance redact() scrub for exactly this case, and as of
harness-sdk@1.0.0 it is actually populated — but it scrubs only the values that
container was injected with, and it is a last chance, not a design. Anything the harness
was not given, it cannot scrub.
Note the deliberate limit on that scrub, because it applies to your return values too: a credential the scan found on the target is the finding, not a leak, and must survive to be reported. Do not filter one out of your own tool output either.