Tool-definition pins¶
ToolDefinitionPinGuard pins a tool's definition when an operator approves it and refuses
the tool once that definition moves. There is no trust on first use: an unseen tool is
refused, not learned.
Since 0.10.29 a pin also covers annotations and outputSchema, a whole tools/list
result can be checked at once, a FastMCP server can check its own tools, and a client can
check every listing it receives. Until then the pin left both fields out and nothing wired
the guard to FastMCP or to a listing.
What a pin covers¶
| Pin version | Fields |
|---|---|
| 1 (0.8.84 to 0.10.28) | name, description, inputSchema |
| 2 (the default since 0.10.29) | the above, plus annotations and outputSchema |
A server could flip readOnlyHint or change the result contract under a pinned name, and
version 1 read neither. In version 2 the two new fields are normalised: a None value is
dropped at any depth, and an absent field, null and {} read the same. So a server that
serialises an absent field differently is not drifting. Key order never matters. title,
_meta and other server bookkeeping are not pinned.
from agent_airlock.mcp_spec.tool_definition_pin_guard import ToolDefinitionPinGuard
guard = ToolDefinitionPinGuard()
guard.approve(reviewed_definition) # a version 2 pin
decision = guard.check(definition_from_the_server)
decision.verdict # e.g. deny_annotations_drift, with changed_fields=("annotations",)
Single-field drift has its own verdict: deny_description_drift, deny_schema_drift,
deny_annotations_drift or deny_output_schema_drift. Anything else is
deny_multi_field_drift.
Storing pins¶
guard.pins is {tool name: (digest, pin version)}. Store it, and load it back with
guard.pin(name, digest, pin_version=...).
A digest made before 0.10.29 is a version 1 digest. canonical_digest(definition,
pin_version=1) reproduces it, and pin(name, digest, pin_version=1) loads it so it
verifies as before, ignoring annotations and the output schema. A pin loaded from a digest
has no stored definition, so drift from it cannot say which field moved:
changed_fields is empty and the detail says unknown.
Whole listings¶
guard.check_listing(tools) judges a tools/list result: a mapping with tools, an
object with .tools (the MCP SDK's ListToolsResult), or the list itself, each tool a
mapping or a pydantic model of one.
- A tool that was never approved is refused, as is one that drifted, one listed twice and one that cannot be read, a tool with no name included.
- A pinned tool the listing leaves out is reported in
missingand refuses nothing: the ceiling only shrank. admittednames the tools a client may expose.validate_listingraisesToolListingDriftErrorinstead.
On the client¶
Call admit_tools_list(guard, result) on every listing: the first one, and every one
after, whether or not the server sent notifications/tools/list_changed. A new listing is
a ceiling to check against the pins, not a refresh to adopt.
from agent_airlock.mcp_spec.tool_definition_pin_guard import list_tools_pinned
tools = await list_tools_pinned(session, guard) # raises ToolListingDriftError on drift
tools = await list_tools_pinned(session, guard, on_drift="filter") # keeps the rest
list_tools_pinned calls session.list_tools() (an MCP ClientSession or a FastMCP
Client) and returns the result in its own shape. With on_drift="filter", the tools the
pins do not admit are dropped, and a listing that cannot be read raises anyway. With the
guard's enforce off, the listing comes back unchanged.
A paginated server lists one page per call, and list_tools_pinned checks each page on its
own. A tool listed on two pages reads as clean twice, and missing counts the tools on the
other pages. For a server that paginates, use list_all_tools_pinned:
from agent_airlock.mcp_spec.tool_definition_pin_guard import list_all_tools_pinned
tools = await list_all_tools_pinned(session, guard) # reads every page, judges them once
tools = await list_all_tools_pinned(session, guard, on_drift="filter", max_pages=20)
It follows each cursor, collects the tools and runs admit_tools_list once on all of them,
so a tool on two pages is a duplicate, and missing covers the whole listing. The result
has the first page's shape with no cursor left on it. With on_drift="filter", every copy
of a duplicate is dropped.
These raise ToolListingDriftError in every mode, with enforce off too, because there is
no whole listing to judge: a cursor seen twice, a cursor that is not a string, a page that
is not a listing, more than max_pages pages (100 by default, and the page past it is never
requested), and a session that cannot follow a cursor. An empty cursor ends the listing;
nothing past it is read, so nothing past it is admitted.
The SDKs differ, and it handles each of them:
| Session | How it pages | Comes back as |
|---|---|---|
ClientSession, mcp 2.x |
params=, result has next_cursor |
ListToolsResult |
ClientSession, mcp 1.x |
params=, result has nextCursor |
ListToolsResult |
mcp.Client, mcp 2.x |
cursor=, result has next_cursor |
ListToolsResult |
FastMCP Client 3 and 4 |
list_tools_mcp(cursor=...) |
a list, as list_tools() gives |
FastMCP's own list_tools() flattens the pages, so it is not used here: versions 3 and 4 stop
at a repeated cursor with only a warning, and version 2 returns page 1 and drops the cursor.
Version 2's page call takes no cursor either, so on a server that pages it raises. Pass
client.session instead.
On a FastMCP server¶
from agent_airlock.mcp import create_secure_mcp_server, tool_definitions
guard = ToolDefinitionPinGuard()
mcp, secure = create_secure_mcp_server("Reports", pin_guard=guard)
@secure
def send_report(recipient: str) -> str:
...
# At review time: approve what the server serves, and store guard.pins.
for definition in await tool_definitions(mcp):
guard.approve(definition)
With pin_guard set, the server:
- refuses to start while one of its tools is unpinned or drifted. A client connecting gets
the refusal (
tools/list refused: not admitted by the pins: ...); - lists only the tools the pins admit, on every
tools/list, so a tool added or replaced after startup is caught the next time a client lists; - refuses a
tools/callof any tool the pins do not admit, with anAIRLOCK_BLOCKerror result, so a hidden tool cannot be called by name.
tool_definitions(server) gives one definition per tool name: the version a call of that
name resolves to. check_server_tools(server, guard) runs the startup check on its own.
With the guard's enforce off, the server only logs what it would refuse.