airlock scan-tools — a static contract / type-checker for MCP tool calls¶
scan-tools is a static type-checker for AI tool declarations. It answers one
question per tool:
Does this tool's declared contract fit inside a least-privilege
SecurityPolicyenvelope?
It is deliberately not two things it is often confused with:
- Not the runtime
@Airlockdecorator.@Airlockvalidates and enforces at call time (ghost-arg stripping, strict Pydantic validation, policy checks, sandboxing).scan-toolsruns before your agent ever loads the tools — on the declarations themselves — so you catch an over-broad or trust-widening contract in CI, not in production. - Not a content-signature tool-poisoning scanner. MCP-Scan (Invariant
Labs) and eSentire's
MCP-Scanner scan tool metadata for known
poisoning signatures.
scan-toolschecks the type contract against a policy. The two overlap only on the description-poisoning subset (whichscan-toolscovers by reusing the shipped Server-Card guard); the distinct value is the contract checks — argument surface, type constraints, capability caps — that a signature scanner does not perform.
Pydantic-only, zero-dep core.
Install & invoke¶
pip install "agent-airlock>=0.8.42"
airlock-scan-tools ./tools/ --policy strict
python -m agent_airlock.cli.scan_tools ./mcp.json --output json
The shipped console script is
airlock-scan-tools(hyphenated), matchingairlock-explain/airlock-conformance. The unifiedairlock scan-toolsspace-form lands with the deferred CLI-dispatcher PR (seepyproject.toml).
Inputs¶
scan-tools accepts any of:
- a single
.jsontool definition ({"name": …, "inputSchema": …}), - a server card / tool-list export (
{"tools": [ … ]}), - a bare list of tool defs (
[ … ]), - an MCP client config with inlined tool schemas
(
{"mcpServers": {"srv": {"command": …, "tools": [ … ]}}}), - a directory of any of the above (known config names —
mcp.json,claude_desktop_config.json,.mcp.json— are loaded first, then any*.json).
A config that only registers server commands with no inlined tool schemas yields zero tools to check — there is nothing to statically type-check, and the loader says so rather than inventing schemas.
Grades and checks¶
Each tool is graded pass / warn / fail. The run's exit code is the worst grade.
| Code | Grade | What it means |
|---|---|---|
SCAN001 |
fail | The tool is denied / not allow-listed by the least-privilege policy (SecurityPolicy.check_tool_allowed). |
SCAN002 |
fail | The tool description carries injected instructions (Server-Card trust boundary). Reuses the shipped mcp_spec_2026_07 guard. |
SCAN003 |
fail | A destructive/mutating tool declares an open argument surface (additionalProperties not false) — a ghost/hallucinated-argument vector into a high-blast-radius op. |
SCAN004 |
warn | A non-destructive tool declares an open argument surface. |
SCAN005 |
warn | A property has no type constraint, or a sensitive string arg (path/url/command/…) has no enum/pattern/format/maxLength. |
SCAN006 |
fail | The tool's inferred capability is denied or not granted by the policy's CapabilityPolicy. |
SCAN007 |
warn | The tool needs a sandbox-gated capability; the policy allows it only under sandbox execution. |
SCAN008 |
warn | A declared OAuth issuer is malformed (SEP-2468 / RFC 9207 companion; the live iss check is a runtime concern). |
Capability inference (most authoritative first): an explicit capabilities
list of Capability member names → MCP annotations
(destructiveHint / readOnlyHint / openWorldHint, real MCP-spec fields) → a
coarse tool-name heuristic (documented as a heuristic).
- An explicit
capabilitieslist is used as declared. It is airlock's own field, not an MCP one, so it is read as your declaration: keep it in tool definitions you wrote. - Otherwise each hint the annotations leave out takes its MCP spec default: not
read-only, destructive, open-world. A tool with no annotations is read as
destructive (
FILESYSTEM_DELETE) and open-world (NETWORK_HTTPS), so under the defaultstrictpolicy it failsSCAN006. Until 0.10.29 it fell through to the name heuristic. - The name heuristic only adds to that: a
delete_*name addsFILESYSTEM_DELETEto a read-only tool, and a benign name never takes a capability away. annotationsmay be a mapping or a pydantic model such asmcp.types.ToolAnnotations(read throughmodel_dump(by_alias=True, exclude_none=True), since mcp 2.x names the fieldsread_only_hintand so on). Anything else reads as no annotations.
Annotations from servers you trust¶
The MCP spec says to treat annotations as untrusted unless they come from a trusted
server. So a hint that lowers the risk (readOnlyHint: true, destructiveHint: false,
openWorldHint: false) is used only for tools listed under an mcpServers entry you
name with --trust-server (repeatable). A hint that raises it always counts.
A tool's server is the mcpServers key it is listed under in the file you scan. Nothing
a tool or a server card says about itself names its server, so a tool from a bare tool
list or a {"tools": [...]} export is never trusted. The text report adds a note:
line for each tool whose lowering hints were not used, and the JSON report gains
trusted_servers at the top and server and untrusted_hints on each result. A
--trust-server name no scanned entry has is reported as a loader note.
Programmatically, pass the same through scan_tools(..., servers=loaded.servers,
trusted_servers=[...]), or scan_tool(..., server=..., trusted_servers=[...]) for one
tool. Without them no tool's lowering hints are used.
Policies¶
--policy maps 1:1 to a shipped SecurityPolicy constant — no invented policy:
--policy |
Constant | Behaviour |
|---|---|---|
permissive |
PERMISSIVE_POLICY |
Allow all; only contract / type / trust checks bite. |
read-only |
READ_ONLY_POLICY |
read_*/get_*/list_* allowed; write_*/delete_* denied (SCAN001). |
strict (default) |
STRICT_POLICY |
STRICT capability caps: PROCESS_SHELL/FILESYSTEM_DELETE denied, write not granted, DANGEROUS requires sandbox (SCAN006/SCAN007). |
deny-by-default |
CAMOUFLAGE_RESISTANT_POLICY |
Empty allow-list + default_deny: every tool must be opted in by name (maximal posture). |
Exit codes (CI)¶
0— all tools pass.1— warnings only (under-specified contracts).2— at least one failure.
CI example¶
# .github/workflows/mcp-contract.yml
name: mcp-tool-contract
on: [pull_request]
jobs:
scan-tools:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with: { python-version: "3.12" }
- run: pip install "agent-airlock>=0.8.42"
# Fail the build on any contract FAIL (exit 2). Drop `|| [ $? -eq 1 ]`
# to also fail on warnings.
- run: airlock-scan-tools --policy strict ./tools/
Coverage — measured, reported as-is¶
python -m benchmarks.scantools_mcptox runs scan-tools over fixtures
reconstructed from the tool-poisoning technique in
MCPTox (arXiv:2508.14925) and reports:
- 69.2% static contract-checking coverage of the injection-shaped descriptions,
- 100% precision (no false positives on clean descriptions).
This is a static coverage number on 23 hand-written fixtures — not MCPTox's
model-in-the-loop Attack Success Rate (MCPTox: 45 live servers, 353 tools; 1,348 cases
in its released data, 1,312 in the paper's version 1; top reported attack success rate
72.8%, o1-mini), which is a property of agents, not of a static checker. The
declarative_side_effect fixtures (a malicious side effect stated with no
imperative marker) are honest misses, which is why coverage is 69.2%, not 100%.
On MCPTox's real poisoned descriptions the same check flags far fewer: 157 of
1,338 unique descriptions (11.7%), with 1 false positive in 362 clean tool blocks
(measured 2026-10-12 with python -m benchmarks.scantools_mcptox --mcptox-data --fetch).
Quote the fixture rate only beside that one. See
benchmarks/scantools_mcptox/RESULTS.md.
Programmatic use¶
from agent_airlock.scan import scan_tools, resolve_policy, load_tool_defs
loaded = load_tool_defs("./tools/")
report = scan_tools(
loaded.tools,
resolve_policy("strict"),
policy_name="strict",
servers=loaded.servers,
trusted_servers=["filesystem"],
)
print(report.exit_code) # 0 / 1 / 2
for result in report.failed:
print(result.tool_name, [v.code for v in result.violations])