toolwrit
Toolwrit
English · Deutsch · 简体中文 · 日本語 · Español
A written authority for AI agents. Allowlist the tools, cap the budget, prove what happened.
Apache-2.0 · Node >= 20 · Python >= 3.10
A writ is a written order from an authority. The policy file is that order; the return of the writ — the historical record of what was done under it — is the audit log. Both halves of the product are in the name.
Toolwrit is a library and a sidecar, not a gateway. Your traffic never leaves your infrastructure. You do not point your agent at someone else's cloud — you wrap your tool handlers in three lines, or you put toolwrit run --policy toolwrit.yaml -- in front of an MCP server you already run.
The problem in 5 lines
An agent with tool access has whatever authority its tools have.
A fs.write tool is a write primitive; a billing.refund tool is a refund primitive. The model decides when to pull them.
Prompt-level guardrails — "never touch files outside the workspace" — are advisory. They live inside the same text channel the attacker (or the confused model) controls.
You need enforcement that sits outside the model: a check that runs on the call itself, cannot be argued with, and leaves a record.
That is all Toolwrit is.
Related MCP server: mcp-policy-gateway
60-second start
npm install toolwritThe package installs a binary named toolwrit. The Python port speaks the same
policy language and writes a compatible audit chain — see
js/docs/python.md. Until it is on PyPI, install it from the
repository; it installs the same toolwrit binary:
pip install "git+https://github.com/emektor/toolwrit.git#subdirectory=python"toolwrit.yaml
version: "1"
name: support-agent
default: deny
budget:
calls: 50
usd: 2.00
rules:
- id: read-workspace
tools: ["fs.read", "fs.list"]
effect: allow
when:
path:
startsWith: ["/srv/workspace/"]
excludes: [".."]
- id: no-secrets
tools: ["fs.*"]
effect: deny
when:
path:
matches: "\\.env|id_rsa"The three-line code change. Wherever you currently execute a tool call, wrap it:
import { Toolwrit, ToolwritDenied, loadPolicyFile } from 'toolwrit';
const toolwrit = new Toolwrit({
policy: loadPolicyFile('./toolwrit.yaml'),
auditFile: './audit.jsonl',
});
// before: const result = await tools[name](args);
// after:
const result = await toolwrit.guard(name, args, () => tools[name](args));That is the whole integration. guard evaluates the call, writes the decision to the audit chain, and only then invokes your handler. A refusal throws ToolwritDenied — never a silently skipped side effect:
try {
return await toolwrit.guard(name, args, () => tools[name](args));
} catch (err) {
if (err instanceof ToolwritDenied) {
// Hand the boundary back to the model so it can adapt.
return { isError: true, content: [{ type: 'text', text: err.message }] };
}
throw err;
}Metering is explicit, because the pricing assumption should be visible in your code rather than guessed by ours:
toolwrit.meter(inputTokens, outputTokens, { input: 0.003, output: 0.015 }); // USD per 1k tokens
toolwrit.spend(0.02); // non-token spendAnd at the end of the run:
console.log(toolwrit.usage()); // { calls: 1, tokens: 1500, usd: 0.0281, bytes: 512, startedAt: 1789035198799 }
console.log(toolwrit.head()); // a 64-char sha256 — the head of this run's audit chainThe MCP one-liner
If your agent already talks to an MCP server, you do not need to touch application code at all. Put Toolwrit in front of the server process. Every tools/call that crosses the boundary is enforced against your policy.
Before, in your MCP client config:
{ "command": "npx", "args": ["-y", "@acme/filesystem-mcp", "/srv/workspace"] }After:
{
"command": "toolwrit",
"args": [
"run", "--policy", "./toolwrit.yaml", "--audit", "./audit.jsonl",
"--", "npx", "-y", "@acme/filesystem-mcp", "/srv/workspace"
]
}Or from a shell:
toolwrit run --policy ./toolwrit.yaml --audit ./audit.jsonl -- npx -y @acme/filesystem-mcp /srv/workspaceA refusal is returned to the agent as an MCP tool error, not a protocol error. That distinction matters: a protocol error looks like a broken server and the model retries blindly, while a tool error is content the model reads — it sees the boundary, sees which argument was wrong, and adapts. The stated reason is the same text a human operator gets.
The rest of the CLI
toolwrit run --policy <file> [--audit <file>] [--run <id>] -- <command> [args...]
toolwrit verify <audit.jsonl> [--against <anchor.jsonl> [--require-timestamp] [--max-lag <seconds>]]
toolwrit receipt <audit.jsonl> [--json]
toolwrit anchor <audit.jsonl> --to <anchor.jsonl> [--tsa [url]] [--tsa-ca <roots.pem>]
toolwrit check --policy <file> --tool <name> [--args <json>]
toolwrit explain --policy <file>
toolwrit drift <audit.jsonl>... [--run <id>] [--factor <n>] [--json]
toolwrit stamp <file>... [--out <dir>] [--tsa <url>] [--check]Command | Purpose | Exit codes |
| Wrap an MCP server process; enforce every | passthrough |
| Verify the hash chain of an audit file. With |
|
| Summarise a run as one object — plan, usage, outcome, warnings. |
|
| Append that receipt to an append-only anchor file. |
|
| Evaluate a single hypothetical call against a policy. |
|
| Human-readable policy summary for a reviewer. |
|
| Compare one run against the runs before it. See Drift. |
|
| Timestamp any file, or with |
|
check exists for CI. A policy is a security control, so regressions in it should break the build:
toolwrit check --policy toolwrit.yaml --tool fs.read --args '{"path":"/srv/workspace/notes.md"}' # expect 0
toolwrit check --policy toolwrit.yaml --tool fs.write --args '{"path":"/etc/passwd"}' # expect 1
toolwrit check --policy toolwrit.yaml --tool shell.exec # expect 1Assert what your policy must refuse, not only what it permits. Allowlists rot in the permissive direction.
How a decision is made
evaluate(call, ctx) is a pure function of policy + call + ledger state. No clock reads, no I/O, no model in the loop. The same three inputs always produce the same Decision, which is what makes the audit log replayable months later.
The ladder, in order:
Budget. An exhausted budget denies everything, whatever the rules say.
calls,tokens,usd,bytesandsecondsare checked in that order; the first one at or over its limit wins.Rate limits. Among rules whose tool glob and argument constraints matched, any rule with a spent
limitdenies immediately.Effect precedence among matching rules:
denybeatsaskbeatsallow. Rule order in the file does not matter. You cannot accidentally shadow a deny by putting an allow above it.No match at all falls through to
policy.default, which isdenyunless you set it otherwise.
Deny-by-default is the whole posture. An unknown tool is denied. A known tool with arguments no rule accepts is denied. An ask rule with no onAsk handler configured is denied — an unattended run must never silently upgrade itself to allowed.
Two details worth internalising:
A rule with
effect: denywhosewhenconstraints do not hold simply does not apply. It is not a trapdoor to allow; the call still has to find an allow rule of its own.Rate limits count entries attributed to that rule id in the audit history. Denied calls are not counted — being refused does not burn your quota.
A worked near-miss
Using the policy above, the agent asks for fs.read with {"path": "/etc/passwd"}. The read-workspace rule targets fs.read, so it is considered — but its startsWith constraint rejects the argument. No other rule allows it, so the default applies:
{
"effect": "deny",
"rule": null,
"reason": "no rule allows \"fs.read\" with these arguments",
"violations": [
{
"rule": "read-workspace",
"path": "path",
"constraint": "startsWith",
"message": "argument \"path\" must start with one of [\"/srv/workspace/\"]"
}
]
}Note the shape. When a rule wanted this tool but rejected the arguments, Toolwrit reports that near-miss instead of the useless "no rule matched" — so the model is told which argument was wrong and can retry inside the boundary. When nothing targeted the tool at all, you get the blunter version:
{
"effect": "deny",
"rule": null,
"reason": "no rule allows tool \"shell.exec\"",
"violations": [
{
"rule": "policy",
"constraint": "default",
"message": "tool \"shell.exec\" is not in the policy; the default effect is deny"
}
]
}ToolwritDenied.message is that reason prefixed with the tool name:
toolwrit: fs.read denied — no rule allows "fs.read" with these argumentsRun plans
A plan: block is the envelope a run declares before it starts and a human approves once.
budget:
calls: 200
usd: 5.00
bytes: 20000000
seconds: 3600
plan:
purpose: nightly CRM export for the EU region
approvedBy: ergin
# warnAt: [0.8, 0.95] # the defaultThe argument for it is worth stating plainly, because it is the whole reason the feature exists. A global threshold has to guess at every job at once. "More than 50MB is suspicious" is either too loose to catch anything — the number has to clear your biggest legitimate job — or too tight to leave switched on, in which case somebody mutes it, and an alert people mute is the same as no alert.
A declared envelope does not guess. A render job that asked for an hour and two gigabytes gets exactly that, uninterrupted. The signal stops being "this looks like a lot" and becomes "this run left the envelope its operator approved" — which is a fact, not a hunch. Approve once at the start, hear nothing until a threshold.
The plan is written into the audit chain as entry 1, before the run can consume anything. The Toolwrit constructor records it; no guarded call can precede it. What was authorised is therefore part of the tamper-evident record, not just what happened, and nobody can later claim a different budget was approved than the one the run started under:
{
"seq": 1,
"at": 1789178401000,
"run": "nightly-2026-09-12",
"tool": "toolwrit:plan",
"args": {
"purpose": "nightly CRM export for the EU region",
"approvedBy": "ergin",
"budget": { "calls": 200, "usd": 5, "bytes": 20000000, "seconds": 3600 },
"warnAt": [0.8, 0.95]
},
"decision": { "effect": "allow", "rule": "plan", "reason": "run plan recorded", "violations": [] },
"usage": { "calls": 0, "tokens": 0, "usd": 0, "bytes": 0 },
"prev": "0000000000000000000000000000000000000000000000000000000000000000",
"hash": "e375bde8b22e4e1d2726a1a9d4110a5ae1fd922329689f2aa2b1f64d1ed43382"
}Note the zero usage: the envelope is committed before anything can be spent against it. The defaults Toolwrit will actually use are recorded too (warnAt: [0.8, 0.95], approvedBy: null when unset), so the entry is self-describing rather than a document you have to re-derive from the policy file.
Warnings
warnAt is a list of fractions of the budget. Each threshold fires at most once per dimension, across calls, tokens, usd, bytes and seconds — repetition is what gets an alert muted. A warning is delivered to onWarn and recorded in the chain, so "nobody told me it was at 95%" is answerable from the log rather than from whether a Slack message happened to get delivered.
const toolwrit = new Toolwrit({
policy: loadPolicyFile('./export.yaml'),
auditFile: './audit.jsonl',
// Keep this non-blocking: it runs inline with metering. Queue the post,
// do not await it.
onWarn: (w) => console.log(w.message),
});run "nightly-2026-09-12" (nightly CRM export for the EU region) has used 16,000,000/20,000,000 bytes — 80% of the approved envelopeThe BudgetWarning handed to onWarn carries dimension, threshold, used, limit and that ready-to-send message. The chain entry carries the same fields:
{
"seq": 6,
"at": 1789178410000,
"run": "nightly-2026-09-12",
"tool": "toolwrit:warning",
"args": {
"dimension": "bytes",
"threshold": 0.8,
"used": 16000000,
"limit": 20000000,
"message": "run \"nightly-2026-09-12\" (nightly CRM export for the EU region) has used 16,000,000/20,000,000 bytes — 80% of the approved envelope"
},
"decision": { "effect": "allow", "rule": "plan", "reason": "run \"nightly-2026-09-12\" (nightly CRM export for the EU region) has used 16,000,000/20,000,000 bytes — 80% of the approved envelope", "violations": [] },
"usage": { "calls": 4, "tokens": 0, "usd": 0, "bytes": 16000000 },
"prev": "da02708df6bc2338bf03089dd7356bf1e81a0b1c3e6895216a655f877bc4e905",
"hash": "4cd48e58f9b83f20a143534960983d5d72062507d8c356b35b2292845ce479cd"
}A warning is a report, not an enforcement action: crossing 95% does not refuse anything. The budget does the refusing, at 100%.
Three load-time rules, all of which fail loudly:
purposeis required and must be non-empty.A
planrequires abudgetto measure against. A plan with nothing to measure would report nothing, and silence reads as "all clear" rather than "not configured".warnAtentries must be greater than0and at most1. They are sorted ascending at load, so the order you write them in does not matter.
The bytes ceiling
budget.bytes caps the total size of what tools return over a run. It is the volume half of containment, and it catches a different attack from the rules.
Bulk exfiltration is rarely a forbidden action. It is a permitted action repeated until something is drained — page 1, page 2, page 3, up to page nine thousand. Every one of those calls passes the allowlist, because reading customer records is exactly what the agent is for. Only the running total gives it away.
budget:
bytes: 20000000 # 20 MB of tool output for the whole runcall 1: ok, bytes now 4000000
call 2: ok, bytes now 8000000
call 3: ok, bytes now 12000000
call 4: ok, bytes now 16000000
call 5: ok, bytes now 20000000
call 6: toolwrit: crm.search denied — data budget exhausted: 20000000/20000000 bytes returned by toolsThe limitation, stated up front
A result's size cannot be known before the tool runs. So the measurement happens after the fact, and the ordering it imposes is unavoidable:
The call that blows the ceiling completes, and the next one is refused. A bytes ceiling bounds a run at the limit plus one call, never exactly the limit.
If a single tool call can return your entire database, a bytes ceiling will not save you — cap the page size on the tool itself, with maxLength or a server-side limit. What the ceiling bounds is iteration: the second, tenth and hundredth call in a drain.
A second honest limit: an unserialisable result is under-counted. Sizes are measured as the UTF-8 byte length of a string, the byte length of a Uint8Array/ArrayBuffer, or the UTF-8 byte length of JSON.stringify(result) for anything else. A value that cannot be stringified (a cycle, a throwing toJSON) falls back to its String() form, which is usually far smaller than the data it holds. null and undefined count as zero. This fails open — it is documented rather than hidden, because throwing an exception from inside the enforcement layer would be worse.
Two smaller notes:
Bytes are attributed to the call's own timestamp, not to a fresh clock read, so the ledger does not depend on how long a tool happened to take.
Under
toolwrit run, what is measured is the whole JSON-RPC response object the downstream MCP server sent, not just the text inside it. In a measured run a 100-character text result cost 173 bytes. Size the budget against the protocol envelope, not the payload. The proxy also handles requests concurrently, so calls already in flight are not counted until they return.
Policy reference
Full reference with a worked example per constraint: js/docs/policy-reference.md.
Policy
Field | Type | Required | Meaning |
|
| yes | Schema version. Only |
|
| no | Free-text label carried into audit exports. |
|
| no | Effect when no rule matches. Defaults to |
|
| no | Run-scoped ceilings. Absent means unlimited. |
|
| no | The declared, pre-approved envelope. Requires |
|
| yes | The clauses. May be empty, which with the default means "deny everything". |
Unknown top-level keys are a hard load error. In a security policy a typo is not a harmless no-op — a silently ignored startWith would turn a scoped filesystem rule into an unscoped one.
PolicyRule
Field | Type | Required | Meaning |
|
| yes | Stable identifier. Must be unique within the policy. Surfaced in decisions and audit entries, and used to attribute rate-limit counts. |
|
| no | Human-readable justification. Becomes the |
|
| yes | Non-empty list of tool-name globs. |
|
| yes | What this rule does when it matches. |
|
| no | Argument constraints keyed by dotted path. All must hold for the rule to match. |
|
| no | Throttle for calls attributed to this rule. Exceeding it denies. |
Glob semantics. * matches any run of characters except the separators . and /. ** matches anything, separators included. Everything else is literal; matching is case-sensitive and whole-string.
Pattern |
|
|
|
| match | — | — |
| match | — | — |
| match | match | — |
| — | — | match |
| — | — | — |
| match | match | match |
Use **, not *, when you mean "every tool".
ArgConstraint
Applied to one argument, addressed by a dotted path (path, body.recipients.0). Numeric segments index into arrays. A path that runs off the end of the object — or through a null — resolves to missing rather than throwing, so a malformed model argument becomes a policy violation and not a crash.
Every constraint present must hold. An absent value fails every constraint except optional.
Field | Type | Applies to | Meaning |
|
| any | Allow the argument to be missing entirely. Default |
|
| any | Runtime type check. A mismatch short-circuits: no other constraint on that path is reported. |
|
| any | Value must deep-equal one of these. |
|
| any | Value must deep-equal none of these. |
|
| strings | Must match this regular expression. Not anchored for you — write |
|
| strings | Must start with one of these prefixes. The filesystem-scoping primitive. |
|
| strings | Must not contain any of these substrings. Cheap traversal guard ( |
|
| strings | Must parse as a URL whose hostname is in this list. An unparseable string fails. |
|
| numbers | Inclusive lower bound. |
|
| numbers | Inclusive upper bound. |
|
| strings, arrays | Inclusive lower bound on |
|
| strings, arrays | Inclusive upper bound on |
A constraint that does not apply to the value's runtime type produces a type violation rather than being skipped: max: 100 against the string "9999" fails. Skipping it would fail open, since a satisfied constraint lets the rule match and the call through.
urlHosts and the leading dot. An entry beginning with . matches that domain and every subdomain: .example.com covers example.com and api.example.com. Anything else must match the hostname exactly, so an allowlist never widens by accident. example.com (no dot) does not admit api.example.com, and — importantly — .example.com does not admit notexample.com.
RateLimit
Field | Type | Required | Meaning |
|
| yes | Maximum calls attributed to this rule inside the window. |
|
| no | Sliding window length in seconds. Omit for "per run". |
max: 0 is legal and means "never" — a way to disable a rule without deleting it.
BudgetLimits
Run-scoped. Every field is optional; each must be a positive number. All are checked before any rule, and use >=: a limit of calls: 50 permits calls 1 through 50 and refuses the 51st.
Field | Type | Meaning |
|
| Hard ceiling on total permitted tool calls in the run. |
|
| Hard ceiling on input+output tokens attributed to the run, via |
|
| Hard ceiling on estimated spend, from |
|
| Hard ceiling on bytes returned by tools across the run. Measured after each call, so the breaching call completes and the next is refused — see The bytes ceiling. |
|
| Wall-clock ceiling, measured from the first metered event, not from process start. |
Toolwrit does not count tokens or dollars for you — it cannot see your model calls. meter() and spend() are how consumption enters the ledger. A tokens or usd budget with no meter() calls is inert; a seconds budget only begins once something has been metered or a call has been permitted. calls and bytes are the two dimensions Toolwrit fills in by itself, from guard().
RunPlan
Requires a budget; a plan with nothing to measure against is a load error.
Field | Type | Required | Meaning |
|
| yes | What the run is for, in the operator's words. Must be non-empty. Carried into the audit log and every warning message. |
|
| no | Who approved the envelope. Recorded, never verified by Toolwrit. Logged as |
|
| no | Fractions of the budget at which the run reports. Each entry must be |
BudgetWarning
Passed to onWarn and recorded as a toolwrit:warning audit entry. Fires at most once per threshold per dimension.
Field | Type | Meaning |
|
| Which budget dimension crossed. |
|
| The |
|
| Consumption and ceiling for that dimension. |
|
| Ready-to-send summary. |
Toolwrit options
new Toolwrit({
policy, // Policy — required
run: 'run-2026-09-10-a', // string — stamped on audit entries; a UUID when omitted
auditFile: './audit.jsonl', // string — append entries here; they are buffered either way
redact: ['headers.authorization'], // string[] — dotted paths blanked before hashing
onAsk: async (call, decision) => confirmWithHuman(call), // ApprovalHandler
onWarn: (warning) => postToSlack(warning.message), // (w: BudgetWarning) => void
now: () => Date.now(), // () => number — injectable clock; tests pass a fixed one
});onWarn runs inline with metering. Keep it non-blocking — queue the notification rather than awaiting it. If the policy has a plan, the constructor writes the toolwrit:plan entry immediately, so constructing a Toolwrit with an auditFile always appends entry 1 before you call anything.
Method | Returns | Notes |
|
| Evaluates without recording or executing. For dry runs and operator previews. |
|
| Evaluate, record, then execute. Throws |
|
| Record a model turn. |
|
| Record non-token spend. |
|
|
|
|
| The chain recorded so far. |
|
| Hash of the newest entry — what an anchor commits to. |
ToolwritDenied carries tool, decision and auditHash — the hash of the entry recording the refusal, worth quoting in a support ticket.
The library also exports the pieces directly for embedding: evaluate, loadPolicyFile, parsePolicy, validatePolicy, PolicyError, matchesGlob, resolvePath, Ledger, AuditLog, canonicalize, hashEntry, GENESIS, verifyChain, verifyFile, summarize, verifyAgainstReceipt.
Audit and verification
Every decision — allowed, denied, or approved-after-ask — is appended to a hash chain. The format is JSONL, one entry per line, so it survives a crash mid-run and can be tailed, grepped and shipped to any log pipeline unchanged.
A real entry:
{
"seq": 2,
"at": 1789035198799,
"run": "f37b4b88-19ed-42ad-914f-b45e163a3874",
"tool": "fs.read",
"args": { "path": "/srv/workspace/README.md" },
"decision": {
"effect": "allow",
"rule": "read-workspace",
"reason": "allowed by rule \"read-workspace\"",
"violations": []
},
"usage": { "calls": 1, "tokens": 0, "usd": 0, "bytes": 13 },
"prev": "4e700ae4f574319b9c49ea9da35265a498ed0e156551a5e0e3251aca76a8665e",
"hash": "bf67c74f8195def87444a9a5bc02a2535d768c8d70a4658920679eaf1e7b4e5f"
}usage is consumption at the moment of the decision, before this call is counted — which is exactly the state the engine saw, so a reviewer can replay the decision from the entry alone. bytes lags by one call for the same reason: it is the total before this tool has returned anything.
Two entries describe Toolwrit itself rather than a guarded tool call: toolwrit:plan (the approved envelope, always entry 1 when a policy declares a plan) and toolwrit:warning (a threshold that fired). They share the ordinary entry shape, so one chain and one verifier cover everything.
How the chain works
prevon entry n is thehashof entry n−1. The first entry'sprevisGENESIS, sixty-four zeroes.hashissha256over a canonical serialisation of the entry excludinghashitself: object keys sorted, no incidental whitespace,undefineddropped. Without canonicalisation the chain would depend on V8's key ordering and a log written by one process could fail verification in another.Editing, deleting or reordering any past entry invalidates that entry's hash and every link after it.
toolwrit verify audit.jsonlExit 0 if the chain is intact, 1 if it is not. verifyFile returns the same result programmatically, and it stops at the first inconsistency with a specific cause:
{
"ok": false,
"count": 1,
"head": "0000000000000000000000000000000000000000000000000000000000000000",
"failure": {
"seq": 1,
"reason": "bad-hash",
"detail": "entry hashes to 604bcf30e8eb but claims 5785cdfb11e3"
}
}reason is one of bad-hash (contents were edited), broken-link (an entry was inserted or removed), bad-sequence (entries reordered), or malformed (a line is not valid JSON).
Redaction
Secrets must never reach the log. Pass dotted paths to redact and the value is replaced with the literal string "[redacted]" before the entry is hashed:
new Toolwrit({ policy, auditFile: './audit.jsonl', redact: ['headers.authorization'] });"args": { "url": "https://api.example.com", "headers": { "authorization": "[redacted]" } }Two properties follow, and both matter:
The redacted shape is what gets hashed. A redaction is therefore part of the committed record, not a later edit — you cannot retroactively redact a logged secret and still pass
verify, and you cannot forge a redaction to hide what an argument was.Your caller's object is untouched. Toolwrit clones before redacting, so the real argument value is still available to the tool.
Redaction is declared per-Toolwrit, not per-rule. If a value is sensitive anywhere, redact it everywhere.
Run receipts
A hash chain proves what one run did. Nobody reads chains at scale. A fleet of 100,000 agents leaves hundreds of millions of entries behind, and "review the logs" is not an instruction anyone can act on.
A receipt is one small summary object per run. The fleet becomes 100,000 receipts instead of 100,000,000 entries, with the runs that left their approved envelope already flagged in the summary rather than buried in it.
toolwrit receipt audit.jsonlrun: nightly-2026-09-12
entries: 9 2026-09-12T02:00:01.000Z → 2026-09-12T02:00:14.000Z
chain: ok
head: 6a5900e535789622d82e8b78374597e412d95c016fa408401697fcff01da0ea6
purpose: nightly CRM export for the EU region (approved by ergin)
usage: 5 calls, 0 tokens, $0.0000
budget: calls 3%, usd 0%, bytes 100% of the approved envelope
outcome: 5 allowed, 1 denied, 0 asked
warnings: bytes at 80%, bytes at 95%
denied: crm.search ×1
exceeded: YES — the run hit a budget ceiling--json prints the same thing as one line, which is the form a console ingests:
{
"run": "nightly-2026-09-12",
"from": 1789178401000,
"to": 1789178414000,
"entryCount": 9,
"head": "6a5900e535789622d82e8b78374597e412d95c016fa408401697fcff01da0ea6",
"chainOk": true,
"plan": {
"purpose": "nightly CRM export for the EU region",
"approvedBy": "ergin",
"budget": { "calls": 200, "usd": 5, "bytes": 20000000, "seconds": 3600 },
"warnAt": [0.8, 0.95]
},
"usage": { "calls": 5, "tokens": 0, "usd": 0, "bytes": 20000000 },
"consumed": { "calls": 0.025, "usd": 0, "bytes": 1 },
"allowed": 5,
"denied": 1,
"asked": 0,
"warnings": [
{ "dimension": "bytes", "threshold": 0.8, "used": 16000000, "limit": 20000000 },
{ "dimension": "bytes", "threshold": 0.95, "used": 20000000, "limit": 20000000 }
],
"deniedTools": { "crm.search": 1 },
"exceeded": true
}The three fields to sort a fleet by: exceeded (a call was refused because a budget ceiling was reached), consumed (fraction of each budgeted dimension used — bytes: 1 is 100% of the approved envelope), and deniedTools (which tools were refused, and how often). chainOk: false means the file did not verify; receipt still prints — a receipt over a broken chain has to say what it saw — but exits 1 so a CI step cannot accept it.
summarize is pure: a function of the chain alone, no I/O and no clock. The same entries always produce the same receipt, so one recomputed during an audit can be compared byte for byte with the one issued at the time.
import { readFileSync } from 'node:fs';
import { summarize } from 'toolwrit';
const entries = readFileSync('./audit.jsonl', 'utf8').split('\n').filter(Boolean).map((l) => JSON.parse(l));
const receipt = summarize(entries);
if (receipt.exceeded || receipt.denied > 0) escalate(receipt);One accounting detail worth knowing. Each entry records usage as of the decision, before the call it authorises is counted, so the last entry's call count always lags by one. usage.calls in a receipt is therefore the count of allowed entries, not the last entry's figure — otherwise every receipt would read "9 allowed, 8 calls", which a reviewer notices immediately. Tokens, dollars and bytes still come from the last entry, since metering lands there between calls. Toolwrit's own toolwrit:plan and toolwrit:warning entries are excluded from allowed/denied/asked.
Anchoring
Here is the hole that verify alone cannot close.
Verifying a chain in isolation cannot detect that its tail was cut off. A prefix of a valid chain is itself a valid chain: every link intact, every hash correct, seq running 1..n. An agent that deletes its last twenty entries hands you a log that passes.
toolwrit verify truncated.jsonl
ok: 6 entries verified
head: 4cd48e58f9b83f20a143534960983d5d72062507d8c356b35b2292845ce479cdExit 0. Three calls and both budget warnings are gone, and nothing inside the file says so.
What closes it is a receipt issued at the time, kept somewhere else. toolwrit anchor appends one receipt line — including the head — to an append-only file:
toolwrit anchor audit.jsonl --to anchors.jsonlanchored run nightly-2026-09-12 (head 6a5900e535789622d82e8b78374597e412d95c016fa408401697fcff01da0ea6) to anchors.jsonl
note: an anchor only proves anything where the agent cannot rewrite it.toolwrit verify --against then runs two checks and reports them separately, because only the second can catch a truncation and an operator has to see which one failed:
toolwrit verify audit.jsonl --against anchors.jsonl
ok: chain check — 9 entries verified
ok: anchor check — head matches the receipt anchored for run nightly-2026-09-12
head: 6a5900e535789622d82e8b78374597e412d95c016fa408401697fcff01da0ea6The same command on the truncated log:
toolwrit verify truncated.jsonl --against anchors.jsonl
ok: chain check — 6 entries verified
FAILED: anchor check — log does not match the anchored receipt (6 entries read)
seq: 6
reason: broken-link
detail: chain head 4cd48e58f9b8 does not match the receipt's head 6a5900e53578 (6 entries present, receipt recorded 9)Exit 1. The head quoted at the time cannot be reached by any shorter or rewritten chain. A run with no anchor at all fails too, rather than passing quietly:
FAILED: anchor check — no receipt for run "nightly-2026-09-12" in anchors.jsonl
detail: an unanchored run cannot be shown to be completeAnchoring the same run more than once is normal — mid-run and again at the end. The last matching line for a run wins, because the newest receipt is the strongest claim about how far the chain got. anchor refuses to write at all if the chain does not verify.
The programmatic form is verifyAgainstReceipt(entries, receipt), which returns the same VerifyResult shape as verifyChain.
The part Toolwrit cannot do for you
An anchor is only worth something where the agent cannot rewrite it. An anchor file sitting next to the audit file, writable by the same process, is not evidence — an attacker who truncates one can truncate the other. Toolwrit cannot enforce where the anchor lives. It can only make putting it there one command:
toolwrit anchor audit.jsonl --to /mnt/worm/anchors.jsonl # a volume the agent's user can append to and not rewriteAnother host, an object store with object-lock or append-only retention, a log pipeline under credentials the agent process does not hold, a ticket, a colleague's inbox. Pick by asking who has to be convinced. js/docs/threat-model.md lays out the options in ascending order of effort.
Timestamping the receipt
--tsa closes most of that gap. The receipt's head is sent to an outside RFC 3161 Time-Stamp Authority, which signs "this head existed at time T" with a key the agent does not hold. The signed token goes into the anchor line:
toolwrit anchor audit.jsonl --to anchors.jsonl --tsaanchored run tsa-fixture (head 834459b62224281a5f118865e6fd8f44d4518cf7adc8b4da04dff01d061fca16) to anchors.jsonl
timestamped by http://timestamp.digicert.com at 2026-10-07T13:33:08.000ZThe default TSA is DigiCert's public service: free, no account, and its certificate chains to a root that ships with Node. Any other RFC 3161 service works with --tsa <url>; one whose root Node does not ship (FreeTSA, an internal TSA) needs --tsa-ca <roots.pem>. If the TSA cannot be reached or its answer does not verify, anchor exits 1 and writes nothing — a receipt you asked to have timestamped never lands without one.
verify --against then runs a third check whenever the receipt carries a timestamp:
toolwrit verify audit.jsonl --against anchors.jsonl --require-timestamp --max-lag 60
ok: chain check — 4 entries verified
ok: anchor check — head matches the receipt anchored for run tsa-fixture
ok: timestamp check — DigiCert SHA256 RSA4096 Timestamp Responder 2026 1 vouches the head existed at 2026-10-07T13:33:08.000Z (0s after the last entry)
head: 834459b62224281a5f118865e6fd8f44d4518cf7adc8b4da04dff01d061fca16What this buys, precisely:
The receipt cannot be forged or backdated. Editing the head, the time, or the signature in the anchor line fails the check, and a token cannot be moved onto another receipt. The anchor file can now live next to the log without being decoration.
A rewrite after the fact shows up as lateness. Someone who truncates or rewrites the log can only obtain a timestamp now.
--max-lag <seconds>fails any receipt timestamped longer than that after the run's last entry, which is what a log rewritten later looks like. Anchor at the end of every run and a tight bound costs nothing.A missing timestamp is not a pass. Deleting the line fails the anchor check, as before; stripping only its
timestampfield fails with--require-timestamp.
What it does not buy: a forger who rewrites the entry times too, and timestamps the result promptly, produces a log that is internally consistent and sealed on time — for the wrong time. Catching that needs one fact from outside the log: when the run actually happened (the scheduler, the billing record, the ticket). Compare it with the time the TSA vouched for.
The head is already a SHA-256 digest, so it is the timestamp's message imprint unchanged. Anyone can check a token without Toolwrit:
openssl ts -verify -digest <head> -token_in -in token.der -CAfile roots.pemwhere token.der is the base64-decoded timestamp.token field. Programmatically: requestTimestamp(head, { tsa }) and verifyTimestamp(token, head).
Stamping files
The same timestamp works for any file, not just a receipt. toolwrit stamp hashes the file, sends only the SHA-256 to the TSA, and writes the signed token next to it:
toolwrit stamp render.mp4
stamped render.mp4 (sha256 6e14cf36…) at 2026-10-12T22:53:03.000Z → render.mp4.stamp.json
toolwrit stamp render.mp4 --check
ok: render.mp4 — DigiCert SHA256 RSA4096 Timestamp Responder 2026 1 vouches it existed at 2026-10-12T22:53:03.000ZThe stamp proves these exact bytes existed no later than the signed time: an agent's output, a design, a contract, a video before it was re-uploaded by someone else. One changed byte and --check fails. The file never leaves the machine and large files are hashed as a stream. --out <dir> keeps the stamps in one folder instead of beside each file. Programmatically: stampFile(path) and checkFileStamp(path, stamp).
A public TSA's timestamp is strong technical evidence, not a qualified electronic timestamp in the eIDAS sense. Where a court must presume it valid without further proof, point --tsa at a qualified, usually paid, TSA.
Drift
A budget catches a run that crosses a line drawn in advance. It cannot catch a run that is strange but stays under the line: ceilings are set for the busiest legitimate day, and a hijacked agent that keeps below them passes.
toolwrit drift compares one run against the runs before it. Give it the audit logs oldest first, one file per run:
$ toolwrit drift mon.jsonl tue.jsonl wed.jsonl thu.jsonl
DRIFT: run thu is unlike the 3 runs before it
- calls 263 against a usual 20
- denied 3 against a usual 0
- fs.read called 260 times against a usual 20
- net.upload was called 2 time(s); no earlier run called it
- shell.exec was called 1 time(s); no earlier run called itIt flags calls, denials, bytes and spend more than --factor (default 3) times their median over earlier runs, any single tool called that much more often than usual, and any tool no earlier run ever called, even when that call was denied. Small absolute rises are ignored, so going from zero denials to one does not page anyone. With fewer than three earlier runs it gives no verdict. Every file's chain is verified first; a broken one fails the command instead of being compared.
It reads finished logs and decides nothing at call time, so run it from cron or CI and page someone on exit 1. It also has limits. A run that makes the usual number of the usual calls with a different intent does not drift, and anything that never crosses the tool boundary is invisible to it. detectDrift(entries) is exported for use in code.
What Toolwrit is not
Being straight about the boundary is worth more than overclaiming.
It does not inspect prompts or model output. There is no classifier, no jailbreak detector, no content filter. Toolwrit sees tool names and arguments, and nothing else.
It cannot stop an allowed tool from doing something harmful. If your policy permits
fs.writeunder/srv/workspace/, Toolwrit will let the agent write nonsense there all day. The policy is the security boundary; Toolwrit only enforces it faithfully.It is not a network egress filter.
urlHostsconstrains a URL-shaped argument you chose to constrain. A tool that opens its own sockets is invisible to Toolwrit. If you need egress control, you need it at the network layer.It governs the tool boundary only. Anything a tool does internally, anything the model does without calling a tool, and anything another process on the host does are all outside its remit.
The audit log is tamper-evident, not tamper-proof. An attacker with write access to the file can rewrite the entire chain consistently, and the result verifies perfectly. Nothing inside a file distinguishes an honest chain from a consistently forged one. Evidence requires that the head hash be anchored somewhere they do not control. See
js/docs/threat-model.md.verifyalone cannot detect truncation. A prefix of a valid chain is a valid chain. Onlyverify --againstan anchored receipt catches a tail that was cut off — and only if the anchor lives somewhere the agent cannot rewrite, or is timestamped (anchor --tsa) and checked with--max-lag.A timestamp proves when, not what really happened. It shows a head existed by time T. A forger who also rewrites entry times and timestamps promptly is caught only by comparing T with when the run actually took place.
A bytes ceiling bounds a run at the limit plus one call, not at the limit. A result's size is not knowable before the tool runs, so the breaching call completes and the next one is refused. It bounds iteration, not a single oversized response.
Byte measurement under-counts an unserialisable result. A cyclic object or a throwing
toJSONfalls back to its string form, which is usually much smaller than the data it holds. This fails open, and is documented rather than hidden.approvedByis recorded, not verified. A plan's approver is a string Toolwrit writes into the chain. It is evidence of what the policy file claimed, not proof that a particular human agreed.A warning is a report, not a control. Crossing a
warnAtthreshold refuses nothing. The budget does the refusing, at 100%.The budget is an estimate, not a bill.
usdis whatever yourmeter()price table andspend()calls say it is.
Comparison
Describing categories, not products.
Deterministic | Self-hosted | Adds latency | Jailbreakable | Audit artefact | |
Prompt-level guardrails (system-prompt rules, tool descriptions) | No — the model decides | Yes | No | Yes, directly — it is text in the same channel | No |
LLM-judge guardrails (a second model approves each call) | No — sampled output | Usually a vendor API | Yes — an extra model call per tool call | Yes — the judge takes a prompt too | Only if the vendor keeps one; not verifiable by you |
API gateways / egress proxies | Yes, for what they can see | Sometimes; often a hosted hop | Yes — a network round trip | No | Request logs, not decision logs; usually not hash-chained |
Toolwrit | Yes — a pure function of policy + call + ledger | Yes — in-process or a local sidecar | Negligible — no network, no model | No prompt to jailbreak | Hash-chained JSONL, verifiable offline with one command |
The wedge is narrow and deliberate: Toolwrit does one layer, deterministically, in your infrastructure, and hands you a file you can prove things with. It complements the other rows rather than replacing them — an LLM judge on top of a deny-by-default allowlist is a reasonable architecture; an LLM judge instead of one is not.
One runtime dependency (yaml). Apache-2.0. Node >= 20.
Documentation
js/docs/policy-reference.md— exhaustive constraint reference, theplanblock, and common policies.js/docs/threat-model.md— what Toolwrit defends against, what it does not, and how to anchor the audit chain.js/docs/python.md— the Python package (toolwrit, imports astoolwrit), its API, and the chain-compatibility guarantee.SECURITY-REVIEW.md— two independent reviews, every finding they produced, the four that were published as open before being fixed, and the one that is still open.
Commercial support
Evidence pack. Send your policy file and get back a PDF report and a sealed evidence folder: every rule tested with legitimate calls and with attacks on its conditions, rate limits and budgets exercised, and every decision in a hash-chained log timestamped by an independent RFC 3161 authority, verifiable with one command. Launch price €49 for the first 10 packs (then €99). A pack covers one exact policy; when it changes we tell you, and a re-test is €10 (three for €25). Sample report.
Integration help, custom policy work or a port to another language: erginsakoglu@gmail.com.
Licence
Apache License 2.0. See LICENSE.
Contributing
See CONTRIBUTING.md. The short version: no new runtime dependencies, and any change to enforcement semantics needs a test that fails without it.
Available Tools
9 toolsdirectory_treeDirectory TreeBRead-only
Get a recursive tree view of files and directories as a JSON structure. Each entry includes 'name', 'type' (file/directory), and 'children' for directories. Files have no children array, while directories always have a children array (which may be empty). The output is formatted with 2-space indentation for readability. Only works within allowed directories.
| Name | Required | Description | Default |
|---|---|---|---|
| path | Yes | ||
| excludePatterns | No |
Output Schema
| Name | Required | Description |
|---|---|---|
| content | Yes |
TDQS
Does the description disclose side effects, auth requirements, rate limits, or destructive behavior?
Annotations already declare readOnlyHint=true and openWorldHint=false, so the safety profile is covered without help from the description. The description does add the practical constraint that the tool is scoped to allowed directories, which is useful behavioral context, but it says nothing about recursion depth limits, handling of unreadable subdirectories, or volume of output for deep trees.
Agents need to know what a tool does to the world before calling it. Descriptions should go beyond structured annotations to explain consequences.
Is the description appropriately sized, front-loaded, and free of redundancy?
Four tight sentences, front-loaded with the core purpose, and the per-entry structure is spelled out compactly. It is slightly padded by return-format detail (indentation, children arrays) that duplicates the existing output schema.
Shorter descriptions cost fewer tokens and are easier for agents to parse. Every sentence should earn its place.
Given the tool's complexity, does the description cover enough for an agent to succeed on first attempt?
Because an output schema exists, the description's lengthy explanation of return structure is redundant rather than harmful. Meanwhile the real gap — no explanation of the excludePatterns filter for a recursive walk over potentially huge trees — is unaddressed, so the definition is only partially complete.
Complex tools with many parameters or behaviors need more documentation. Simple tools need less. This dimension scales expectations accordingly.
Does the description clarify parameter syntax, constraints, interactions, or defaults beyond what the schema provides?
Schema description coverage is 0% for two parameters, so the description carries the full burden and fails it: neither 'path' nor 'excludePatterns' is mentioned anywhere. The excludePatterns parameter in particular is completely undocumented, leaving the agent to guess its syntax, matching semantics, and interaction with the recursive walk.
Input schemas describe structure but not intent. Descriptions should explain non-obvious parameter relationships and valid value ranges.
Does the description clearly state what the tool does and how it differs from similar tools?
The description states a specific verb and resource ('recursive tree view of files and directories') and the recursion itself implicitly separates it from flat siblings like list_directory. It never names an alternative, so the differentiation is left for the agent to infer.
Agents choose between tools based on descriptions. A clear purpose with a specific verb and resource helps agents select the right tool.
Does the description explain when to use this tool, when not to, or what alternatives exist?
There is no explicit when-to-use or when-not-to-use guidance, nor any named alternative among the eight sibling listing/reading tools. The only usage constraint is 'Only works within allowed directories', which implies a prerequisite (discover directories via list_allowed_directories) but frames it as a limitation rather than routing guidance.
Agents often have multiple tools that could apply. Explicit usage guidance like "use X instead of Y when Z" prevents misuse.
get_file_infoGet File InfoARead-only
Retrieve detailed metadata about a file or directory. Returns comprehensive information including size, creation time, last modified time, permissions, and type. This tool is perfect for understanding file characteristics without reading the actual content. Only works within allowed directories.
| Name | Required | Description | Default |
|---|---|---|---|
| path | Yes |
Output Schema
| Name | Required | Description |
|---|---|---|
| content | Yes |
TDQS
Does the description disclose side effects, auth requirements, rate limits, or destructive behavior?
Annotations already declare readOnlyHint=true and openWorldHint=false, so the safety profile is covered. The description adds a real behavioral constraint ('only works within allowed directories') and notes the non-destructive nature of not reading content. It omits error behavior (missing file, symlinks), which keeps it from a 5.
Agents need to know what a tool does to the world before calling it. Descriptions should go beyond structured annotations to explain consequences.
Is the description appropriately sized, front-loaded, and free of redundancy?
Purpose is front-loaded in the first sentence and the detail list is useful. The sentence 'This tool is perfect for...' is mild promotional filler, but overall the text is tight and earns most of its length.
Shorter descriptions cost fewer tokens and are easier for agents to parse. Every sentence should earn its place.
Given the tool's complexity, does the description cover enough for an agent to succeed on first attempt?
With an output schema present, the description needn't explain return values, yet it does so helpfully. Combined with read-only annotations and the allowed-directory constraint, an agent has enough to call it correctly; only the undocumented path parameter leaves a gap.
Complex tools with many parameters or behaviors need more documentation. Simple tools need less. This dimension scales expectations accordingly.
Does the description clarify parameter syntax, constraints, interactions, or defaults beyond what the schema provides?
Schema description coverage is 0% and the single 'path' parameter is undocumented in both schema and description. The description never clarifies path syntax, whether relative paths are accepted, or how directory vs file paths behave, so it does not compensate for the coverage gap.
Input schemas describe structure but not intent. Descriptions should explain non-obvious parameter relationships and valid value ranges.
Does the description clearly state what the tool does and how it differs from similar tools?
States a specific verb and resource ('Retrieve detailed metadata about a file or directory') and even enumerates what metadata (size, timestamps, permissions, type). The phrase 'without reading the actual content' implicitly separates it from read_file, though no sibling is named explicitly.
Agents choose between tools based on descriptions. A clear purpose with a specific verb and resource helps agents select the right tool.
Does the description explain when to use this tool, when not to, or what alternatives exist?
The line 'perfect for understanding file characteristics without reading the actual content' implies when to use it, and 'Only works within allowed directories' gives a precondition. But there is no explicit routing to or away from siblings like list_directory_with_sizes or read_file, so usage is only implied.
Agents often have multiple tools that could apply. Explicit usage guidance like "use X instead of Y when Z" prevents misuse.
list_allowed_directoriesList Allowed DirectoriesARead-only
Returns the list of directories that this server is allowed to access. Subdirectories within these allowed directories are also accessible. Use this to understand which directories and their nested paths are available before trying to access files.
| Name | Required | Description | Default |
|---|---|---|---|
No parameters | |||
Output Schema
| Name | Required | Description |
|---|---|---|
| content | Yes |
TDQS
Does the description disclose side effects, auth requirements, rate limits, or destructive behavior?
Annotations already declare readOnlyHint=true and openWorldHint=false, so safety is covered. The description adds real behavioral value beyond them: subdirectories of the allowed roots are also accessible, which is a semantic detail an agent could not infer from the annotations or the empty schema.
Agents need to know what a tool does to the world before calling it. Descriptions should go beyond structured annotations to explain consequences.
Is the description appropriately sized, front-loaded, and free of redundancy?
Three short sentences, front-loaded with what it returns, then the subdirectory scope, then the usage hint. No filler or restatement of the name.
Shorter descriptions cost fewer tokens and are easier for agents to parse. Every sentence should earn its place.
Given the tool's complexity, does the description cover enough for an agent to succeed on first attempt?
An output schema exists, so return-format details need not be described. For a no-arg, read-only tool with full annotation coverage, the description supplies everything needed to call it correctly.
Complex tools with many parameters or behaviors need more documentation. Simple tools need less. This dimension scales expectations accordingly.
Does the description clarify parameter syntax, constraints, interactions, or defaults beyond what the schema provides?
The tool takes zero parameters, so 4 is the baseline. There is nothing for the description to clarify beyond confirming the operation is server-scoped and takes no arguments.
Input schemas describe structure but not intent. Descriptions should explain non-obvious parameter relationships and valid value ranges.
Does the description clearly state what the tool does and how it differs from similar tools?
States a specific verb ('Returns') and a precisely scoped resource ('directories that this server is allowed to access'), which is clearly distinct from the file/directory-listing siblings. It stops short of explicitly contrasting with list_directory, but the 'allowed directories' framing makes the distinction inferable.
Agents choose between tools based on descriptions. A clear purpose with a specific verb and resource helps agents select the right tool.
Does the description explain when to use this tool, when not to, or what alternatives exist?
Explicitly states when to use it: 'before trying to access files,' to learn which directories and nested paths are available. No when-not guidance or named alternatives are given, but the usage context is unambiguous for a zero-param introspection tool.
Agents often have multiple tools that could apply. Explicit usage guidance like "use X instead of Y when Z" prevents misuse.
list_directoryList DirectoryARead-only
Get a detailed listing of all files and directories in a specified path. Results clearly distinguish between files and directories with [FILE] and [DIR] prefixes. This tool is essential for understanding directory structure and finding specific files within a directory. Only works within allowed directories.
| Name | Required | Description | Default |
|---|---|---|---|
| path | Yes |
Output Schema
| Name | Required | Description |
|---|---|---|
| content | Yes |
TDQS
Does the description disclose side effects, auth requirements, rate limits, or destructive behavior?
Annotations cover the safety profile (readOnlyHint=true, openWorldHint=false), so the bar is lower; the description still adds two genuinely useful traits: the output tagging convention ([FILE]/[DIR] prefixes) and the sandbox constraint that it only works inside allowed directories. It omits whether traversal is recursive or paginated, which is the main behavioral question left open.
Agents need to know what a tool does to the world before calling it. Descriptions should go beyond structured annotations to explain consequences.
Is the description appropriately sized, front-loaded, and free of redundancy?
Front-loaded with the core behavior and the output format detail. The sentence 'This tool is essential for understanding directory structure...' is mildly promotional filler, but the rest is dense and earns its place.
Shorter descriptions cost fewer tokens and are easier for agents to parse. Every sentence should earn its place.
Given the tool's complexity, does the description cover enough for an agent to succeed on first attempt?
An output schema exists, so return-value documentation is not strictly required, and the sandbox note plus prefix convention cover the essentials. What is missing is the recursion/scope boundary in a sibling set that contains directory_tree and list_directory_with_sizes, which is the one thing an agent must know to avoid picking the wrong listing tool.
Complex tools with many parameters or behaviors need more documentation. Simple tools need less. This dimension scales expectations accordingly.
Does the description clarify parameter syntax, constraints, interactions, or defaults beyond what the schema provides?
Schema description coverage is 0% for the single 'path' parameter, so the schema alone says nothing. The description partially compensates by indicating the path is a directory location constrained to allowed directories, but it gives no format, relative-vs-absolute, or symlink semantics.
Input schemas describe structure but not intent. Descriptions should explain non-obvious parameter relationships and valid value ranges.
Does the description clearly state what the tool does and how it differs from similar tools?
States a specific verb and resource ('Get a detailed listing of all files and directories') plus the input scope ('in a specified path'), which is enough for an agent to know what the tool returns. However, it never distinguishes itself from close siblings like list_directory_with_sizes or directory_tree, so the agent must infer that this is the plain, non-recursive, size-free variant.
Agents choose between tools based on descriptions. A clear purpose with a specific verb and resource helps agents select the right tool.
Does the description explain when to use this tool, when not to, or what alternatives exist?
'Essential for understanding directory structure and finding specific files' implies the use case but names no alternative and gives no when-not guidance. The one explicit scoping rule ('Only works within allowed directories') is a prerequisite rather than usage routing, and it does not tell the agent when to prefer list_directory_with_sizes, directory_tree, or search_files.
Agents often have multiple tools that could apply. Explicit usage guidance like "use X instead of Y when Z" prevents misuse.
list_directory_with_sizesList Directory with SizesARead-only
Get a detailed listing of all files and directories in a specified path, including sizes. Results clearly distinguish between files and directories with [FILE] and [DIR] prefixes. This tool is useful for understanding directory structure and finding specific files within a directory. Only works within allowed directories.
| Name | Required | Description | Default |
|---|---|---|---|
| path | Yes | ||
| sortBy | No | Sort entries by name or size | name |
Output Schema
| Name | Required | Description |
|---|---|---|
| content | Yes |
TDQS
Does the description disclose side effects, auth requirements, rate limits, or destructive behavior?
Annotations already declare readOnlyHint=true and openWorldHint=false, so the safety profile is covered. The description still earns credit by adding the sandbox constraint ('Only works within allowed directories') and the [FILE]/[DIR] output convention, which are not derivable from the annotations.
Agents need to know what a tool does to the world before calling it. Descriptions should go beyond structured annotations to explain consequences.
Is the description appropriately sized, front-loaded, and free of redundancy?
Four sentences, and the third ('useful for understanding directory structure and finding specific files') is largely filler that restates the obvious purpose. The useful constraint and output-format details are buried at the end rather than front-loaded.
Shorter descriptions cost fewer tokens and are easier for agents to parse. Every sentence should earn its place.
Given the tool's complexity, does the description cover enough for an agent to succeed on first attempt?
An output schema exists, so return values need not be explained, and annotations carry the safety profile. The description covers purpose, output convention, and the sandbox constraint, leaving only the sibling differentiation from list_directory unaddressed.
Complex tools with many parameters or behaviors need more documentation. Simple tools need less. This dimension scales expectations accordingly.
Does the description clarify parameter syntax, constraints, interactions, or defaults beyond what the schema provides?
Schema coverage is only 50%: sortBy is documented in the schema but never mentioned in the description, and path has no schema description at all. The description implies a 'specified path' and mentions sizes, but adds no format, path-syntax, or sort-order guidance to compensate for the coverage gap.
Input schemas describe structure but not intent. Descriptions should explain non-obvious parameter relationships and valid value ranges.
Does the description clearly state what the tool does and how it differs from similar tools?
The description gives a clear verb ('Get a detailed listing') and resource (files/directories in a path) and adds the distinguishing 'including sizes' qualifier. However, it never names or contrasts with its near-identical sibling list_directory, so an agent must infer the difference from the name alone.
Agents choose between tools based on descriptions. A clear purpose with a specific verb and resource helps agents select the right tool.
Does the description explain when to use this tool, when not to, or what alternatives exist?
'This tool is useful for understanding directory structure and finding specific files' implies a usage context, but no when-to-use vs. when-not guidance is offered. With siblings like list_directory, directory_tree, and search_files present, the absence of routing guidance is a real gap.
Agents often have multiple tools that could apply. Explicit usage guidance like "use X instead of Y when Z" prevents misuse.
read_fileRead File (Deprecated)ARead-only
Read the complete contents of a file as text. DEPRECATED: Use read_text_file instead.
| Name | Required | Description | Default |
|---|---|---|---|
| head | No | If provided, returns only the first N lines of the file | |
| path | Yes | ||
| tail | No | If provided, returns only the last N lines of the file |
Output Schema
| Name | Required | Description |
|---|---|---|
| content | Yes |
TDQS
Does the description disclose side effects, auth requirements, rate limits, or destructive behavior?
Annotations already declare readOnlyHint=true and openWorldHint=false, covering the safety profile. The description adds the deprecation status and that content is returned as text, but gives no further behavioral detail such as error handling, permissions, or large-file behavior.
Agents need to know what a tool does to the world before calling it. Descriptions should go beyond structured annotations to explain consequences.
Is the description appropriately sized, front-loaded, and free of redundancy?
Two short sentences, front-loaded with the core action and immediately followed by the critical deprecation warning. Every word earns its place.
Shorter descriptions cost fewer tokens and are easier for agents to parse. Every sentence should earn its place.
Given the tool's complexity, does the description cover enough for an agent to succeed on first attempt?
For a simple read tool with annotations and an output schema, the definition is nearly complete: it states the action, return type, and replacement. It only misses explicit head/tail behavior, which is covered by the schema.
Complex tools with many parameters or behaviors need more documentation. Simple tools need less. This dimension scales expectations accordingly.
Does the description clarify parameter syntax, constraints, interactions, or defaults beyond what the schema provides?
Schema coverage is only 67%, and the description adds no information about the head or tail parameters. Worse, 'complete contents' implies the entire file is always returned, which is misleading when head/tail are supplied.
Input schemas describe structure but not intent. Descriptions should explain non-obvious parameter relationships and valid value ranges.
Does the description clearly state what the tool does and how it differs from similar tools?
The description states a specific verb and resource ('Read the complete contents of a file as text') and directly names the replacement sibling ('Use read_text_file instead'), so an agent can distinguish it without opening the schema.
Agents choose between tools based on descriptions. A clear purpose with a specific verb and resource helps agents select the right tool.
Does the description explain when to use this tool, when not to, or what alternatives exist?
It explicitly marks the tool as deprecated and names the alternative tool to use instead. This is the strongest possible routing guidance for a deprecated tool.
Agents often have multiple tools that could apply. Explicit usage guidance like "use X instead of Y when Z" prevents misuse.
read_multiple_filesRead Multiple FilesARead-only
Read the contents of multiple files simultaneously. This is more efficient than reading files one by one when you need to analyze or compare multiple files. Each file's content is returned with its path as a reference. Failed reads for individual files won't stop the entire operation. Only works within allowed directories.
| Name | Required | Description | Default |
|---|---|---|---|
| paths | Yes | Array of file paths to read. Each path must be a string pointing to a valid file within allowed directories. |
Output Schema
| Name | Required | Description |
|---|---|---|
| content | Yes |
TDQS
Does the description disclose side effects, auth requirements, rate limits, or destructive behavior?
Annotations already declare readOnlyHint=true and openWorldHint=false, so safety is covered. The description adds genuine behavioral context beyond that: per-file failure isolation ('won't stop the entire operation') and the allowed-directory constraint, both of which an agent needs to interpret partial results correctly.
Agents need to know what a tool does to the world before calling it. Descriptions should go beyond structured annotations to explain consequences.
Is the description appropriately sized, front-loaded, and free of redundancy?
Four short sentences, purpose front-loaded, no filler. The sentence about content being returned with its path slightly duplicates what the output schema already conveys, but it is brief and harmless.
Shorter descriptions cost fewer tokens and are easier for agents to parse. Every sentence should earn its place.
Given the tool's complexity, does the description cover enough for an agent to succeed on first attempt?
For a one-parameter batch read with annotations covering safety and an output schema covering returns, the description covers everything an agent needs: batching rationale, permission boundary, and partial-failure behavior. Nothing material is missing.
Complex tools with many parameters or behaviors need more documentation. Simple tools need less. This dimension scales expectations accordingly.
Does the description clarify parameter syntax, constraints, interactions, or defaults beyond what the schema provides?
Schema coverage is 100% on the single 'paths' parameter, so the schema already carries the semantics (array of strings, minItems 1, must be inside allowed directories). The description adds only the 'allowed directories' reminder, which is already in the schema, so baseline 3 applies.
Input schemas describe structure but not intent. Descriptions should explain non-obvious parameter relationships and valid value ranges.
Does the description clearly state what the tool does and how it differs from similar tools?
States a specific verb (read) and resource (contents of multiple files) with explicit scope. The plurality plus 'more efficient than reading files one by one' signals how it differs from the single-file siblings like read_file/read_text_file without needing the schema.
Agents choose between tools based on descriptions. A clear purpose with a specific verb and resource helps agents select the right tool.
Does the description explain when to use this tool, when not to, or what alternatives exist?
Gives a clear condition for use ('when you need to analyze or compare multiple files') and contrasts it with sequential reads. It does not, however, name read_file/read_text_file as the alternatives for the single-file case, so the routing is implied rather than explicit.
Agents often have multiple tools that could apply. Explicit usage guidance like "use X instead of Y when Z" prevents misuse.
read_text_fileRead Text FileARead-only
Read the complete contents of a file from the file system as text. Handles various text encodings and provides detailed error messages if the file cannot be read. Use this tool when you need to examine the contents of a single file. Use the 'head' parameter to read only the first N lines of a file, or the 'tail' parameter to read only the last N lines of a file. Operates on the file as text regardless of extension. Only works within allowed directories.
| Name | Required | Description | Default |
|---|---|---|---|
| head | No | If provided, returns only the first N lines of the file | |
| path | Yes | ||
| tail | No | If provided, returns only the last N lines of the file |
Output Schema
| Name | Required | Description |
|---|---|---|
| content | Yes |
TDQS
Does the description disclose side effects, auth requirements, rate limits, or destructive behavior?
Annotations already declare readOnlyHint=true and openWorldHint=false, so safety is covered. The description adds real value beyond that: encoding handling, detailed error messages on failure, and the constraint 'Only works within allowed directories'. Return format is not described, but an output schema exists so that is acceptable.
Agents need to know what a tool does to the world before calling it. Descriptions should go beyond structured annotations to explain consequences.
Is the description appropriately sized, front-loaded, and free of redundancy?
Every sentence earns its place and the core purpose is front-loaded in the first sentence. Encoding behavior, usage context, head/tail semantics, and directory constraint each add distinct information with no redundancy or filler.
Shorter descriptions cost fewer tokens and are easier for agents to parse. Every sentence should earn its place.
Given the tool's complexity, does the description cover enough for an agent to succeed on first attempt?
With an output schema present, the description needn't explain return values, and it covers encoding, errors, and the allowed-directory constraint. The one omission is explicit differentiation from the sibling read_file, which an agent must otherwise infer.
Complex tools with many parameters or behaviors need more documentation. Simple tools need less. This dimension scales expectations accordingly.
Does the description clarify parameter syntax, constraints, interactions, or defaults beyond what the schema provides?
Schema description coverage is 67%. The head/tail descriptions in the description largely restate the schema's own wording rather than adding meaning. The path parameter has no schema description and the description does not clarify it either, so the gap is only partially compensated.
Input schemas describe structure but not intent. Descriptions should explain non-obvious parameter relationships and valid value ranges.
Does the description clearly state what the tool does and how it differs from similar tools?
States a specific verb and resource ('Read the complete contents of a file... as text') and clarifies it operates as text regardless of extension. It implicitly separates itself from read_multiple_files via 'examine the contents of a single file', but never explicitly contrasts with the ambiguous sibling read_file, so sibling differentiation is incomplete.
Agents choose between tools based on descriptions. A clear purpose with a specific verb and resource helps agents select the right tool.
Does the description explain when to use this tool, when not to, or what alternatives exist?
'Use this tool when you need to examine the contents of a single file' gives clear situational context, and head/tail usage is spelled out. However, there is no explicit when-not guidance or named alternative (e.g., read_multiple_files for batches), so it stops short of full routing guidance.
Agents often have multiple tools that could apply. Explicit usage guidance like "use X instead of Y when Z" prevents misuse.
search_filesSearch FilesARead-only
Recursively search for files and directories matching a pattern. The patterns should be glob-style patterns that match paths relative to the working directory. Use pattern like '.ext' to match files in current directory, and '**/.ext' to match files in all subdirectories. Returns full paths to all matching items. Great for finding files when you don't know their exact location. Only searches within allowed directories.
| Name | Required | Description | Default |
|---|---|---|---|
| path | Yes | ||
| pattern | Yes | ||
| excludePatterns | No |
Output Schema
| Name | Required | Description |
|---|---|---|
| content | Yes |
TDQS
Does the description disclose side effects, auth requirements, rate limits, or destructive behavior?
Annotations already declare readOnlyHint=true and openWorldHint=false, and the description adds a meaningful constraint beyond them: 'Only searches within allowed directories,' which signals sandboxing behavior. It also states the return shape ('full paths to all matching items'), though an output schema already exists, so this adds modest value.
Agents need to know what a tool does to the world before calling it. Descriptions should go beyond structured annotations to explain consequences.
Is the description appropriately sized, front-loaded, and free of redundancy?
Front-loads the core purpose and pattern syntax efficiently, but 'Great for finding files when you don't know their exact location' is filler that repeats the implied value. Still generally tight and well ordered.
Shorter descriptions cost fewer tokens and are easier for agents to parse. Every sentence should earn its place.
Given the tool's complexity, does the description cover enough for an agent to succeed on first attempt?
Return values are covered by the output schema and safety by annotations, so the description only needs to cover search semantics and scope, which it does well. The gap is the undocumented path and excludePatterns parameters.
Complex tools with many parameters or behaviors need more documentation. Simple tools need less. This dimension scales expectations accordingly.
Does the description clarify parameter syntax, constraints, interactions, or defaults beyond what the schema provides?
Schema description coverage is 0%, but the description does clarify the most important parameter, pattern, with glob semantics ('*.ext' vs '**/*.ext') and the relative-to-working-directory rule. It says nothing about path or excludePatterns, so it only partially compensates for the coverage gap.
Input schemas describe structure but not intent. Descriptions should explain non-obvious parameter relationships and valid value ranges.
Does the description clearly state what the tool does and how it differs from similar tools?
States a specific verb and resource ('Recursively search for files and directories matching a pattern') and the recursion scope clearly distinguishes it from the flat listing siblings like list_directory and directory_tree. It does not name those siblings explicitly, so it falls short of a 5.
Agents choose between tools based on descriptions. A clear purpose with a specific verb and resource helps agents select the right tool.
Does the description explain when to use this tool, when not to, or what alternatives exist?
'Great for finding files when you don't know their exact location' gives implied usage context, but there is no when-not guidance and no routing to alternatives such as list_directory or directory_tree. Usage is inferable rather than explicit.
Agents often have multiple tools that could apply. Explicit usage guidance like "use X instead of Y when Z" prevents misuse.
Tool Schema Changelog
Recent tool additions, removals, and schema changes observed during successful MCP inspections.
9 tool updates
- First observed
directory_tree - First observed
get_file_info - First observed
list_allowed_directories - First observed
list_directory - First observed
list_directory_with_sizes - First observed
read_file - First observed
read_multiple_files - First observed
read_text_file - First observed
search_files
This server cannot be deployed
TDQS
Scored across 9 tools
Most tools have clearly distinct purposes (read, list, search, metadata). However, read_file is a deprecated alias for read_text_file, creating a genuine overlap, and list_directory vs list_directory_with_sizes differ only by the sizes column. The descriptions do enough to steer selection, but these pairs are confusable.
Names mostly follow a verb_noun convention (read_file, read_text_file, list_directory, search_files, get_file_info, list_allowed_directories). The lone deviation is directory_tree, which is a noun phrase rather than verb_noun. Overall predictable and readable.
Nine tools is well-scoped for a filesystem server, covering read, list, search, and metadata operations without bloat. Every tool except the deprecated read_file earns its place.
The surface is purely read-oriented: there is no write_file, create_directory, move_file, or edit operation despite the server name suggesting writing. For a general filesystem domain this is a notable gap, though for a read-only browsing use case it would be near-complete.
Maintenance
Related MCP Connectors
MCP server for mandates, delegation, policy-gated execution, credential grants, and audit.
A paid remote MCP for CLI tool MCP, built to return verdicts, receipts, usage logs, and audit-ready
Security & DLP proxy for MCP: tool-poisoning scans, PII redaction on tool args/results. Beta.
- gatewayOAuthai.sealgate
MCP gateway with runtime security policy, tool-call-level control, and audit of agent actions.
Related MCP Servers
- AlicenseBqualityCmaintenanceSecurity gateway that wraps any MCP server with per-tool policies, approval gates, and optional Ed25519-signed decision receipts. Shadow mode logs every tool call without blocking; enforce mode applies block, rate-limit, and minimum-tier rules. Receipts are independently verifiable offline with no accounts needed.5926 npm11MIT
- AlicenseNot gradedqualityAmaintenanceAn authorizing reverse proxy for MCP servers that enforces per-call policy rules on tool arguments with audit logging, dry-run, and rate limiting.Apache 2.0
- AlicenseNot gradedqualityCmaintenanceEnforces deterministic security policies as an inline firewall for MCP server tool calls, with AST-based validation, cryptographic audit logging, and CLI-based evaluation and verification.MIT
- AlicenseNot gradedqualityAmaintenanceProvides a governance proxy layer for MCP servers, enforcing per-tool allowlists, human approval for write operations, quotas, secret redaction, and a hash-chained audit log of all calls.MIT