Skip to content

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 SecurityPolicy envelope?

It is deliberately not two things it is often confused with:

  • Not the runtime @Airlock decorator. @Airlock validates and enforces at call time (ghost-arg stripping, strict Pydantic validation, policy checks, sandboxing). scan-tools runs 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-tools checks the type contract against a policy. The two overlap only on the description-poisoning subset (which scan-tools covers 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), matching airlock-explain / airlock-conformance. The unified airlock scan-tools space-form lands with the deferred CLI-dispatcher PR (see pyproject.toml).

Inputs

scan-tools accepts any of:

  • a single .json tool 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 capabilities list 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 default strict policy it fails SCAN006. Until 0.10.29 it fell through to the name heuristic.
  • The name heuristic only adds to that: a delete_* name adds FILESYSTEM_DELETE to a read-only tool, and a benign name never takes a capability away.
  • annotations may be a mapping or a pydantic model such as mcp.types.ToolAnnotations (read through model_dump(by_alias=True, exclude_none=True), since mcp 2.x names the fields read_only_hint and 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.

airlock scan-tools ./mcp.json --trust-server filesystem --trust-server memory

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])