Agent Guild Preflight
Deny-only preflight for operator-selected remote MCP tools. Checks the configured public endpoint through Agent Guild before invocation. Default policy blocks do_not_delegate, unproven required checks, caution and unavailable evidence; existing tool permissions remain in force. Inactive until configured. Retains observation evidence and rechecks the endpoint binding before allowing native execution.
README
agent_guild_preflight — Agent Zero plugin
A deny-only gate for remote MCP tools in Agent Zero.
Immediately before a tool from an operator-protected remote MCP server executes, the plugin asks
Agent Guild's free, unauthenticated GET /preflight what
that endpoint proves right now (reachability, a real protocol handshake, card and payment claims,
independent evidence) and blocks the call when the verdict is do_not_delegate, when a required
check is not proven, or — by default — when the evidence is unavailable or inconsistent.
It never grants anything. Agent Zero's own tool-access policy still runs first (and again inside
MCPTool.execute), a denial from this plugin is a normal RepairableException the agent can
react to, and installing the plugin without configuring it produces zero network traffic.
Built and tested against Agent Zero v2.11 (6a6cecff8527b164668c7a6ab2f76b6b1ed7cfa1).
What it hooks
extensions/python/tool_execute_before/_45_agent_guild_preflight.py — the native
tool_execute_before extension point that agent.py awaits right before tool.execute in both
the native function-call path (_execute_tool_request) and the legacy text path
(process_tools). The unique _45_ filename keeps the built-in _10_enforce_tool_policy.py
untouched and ahead of it. The hook reads agent.loop_data.current_tool and acts only when it is a
real helpers.mcp_handler.MCPTool whose server name is listed in protected_servers.
Install
In your Agent Zero environment, clone the versioned plugin into its plugin directory:
git clone --branch v0.1.0 https://github.com/AgentTanuki/a0-agent-guild-preflight.git /a0/usr/plugins/agent_guild_preflightThe directory root must contain
plugin.yaml. The Plugin Hub route becomes available if maintainers accept the index submission.Agent Zero discovers the extension automatically. Nothing happens yet:
protected_serversis empty.Create
/a0/usr/plugins/agent_guild_preflight/config.json, for example:
{
"protected_servers": { "public_mcp": "https://mcp.example.com/mcp" },
"policy": { "required_proven": ["endpoint_reachable", "protocol_handshake"],
"on_caution": "deny", "on_unavailable": "deny" }
}
public_mcp must be the name of a remote MCP server in Agent Zero's MCP settings (matched
after Agent Zero's own name normalization) and the URL must be identical to the one that
server is configured with. Only that URL is ever sent to Guild.
Restart Agent Zero after installing or updating the plugin so its helper modules are reloaded.
Configuration uses Agent Zero's native file lookup. The first existing config.json wins in
this order: project plus agent profile, project, agent profile, global; otherwise the plugin's
default_config.yaml supplies the defaults. Files at different scopes are not merged. A project
file belongs at /a0/usr/projects/<project>/.a0proj/plugins/agent_guild_preflight/config.json;
a profile file belongs at /a0/usr/agents/<profile>/plugins/agent_guild_preflight/config.json;
a combined file belongs under the project's .a0proj/agents/<profile>/plugins/ directory.
This plugin ships no editable settings form; the Plugins dialog can list and remove existing
scoped files. See the pinned configuration loader.
To remove: delete the folder and restart. No other state is written (evidence is kept on the agent object and in the chat log only).
Binding rules (why a name is not enough)
Model-supplied tool names or descriptions do not prove which server a tool belongs to. For every
protected call the plugin resolves the bound MCPTool through the same project-aware
MCPConfig.get_for_agent(agent) that native execution uses, and requires that:
- exactly one configured server carries that normalized name (a collision such as
Pub-Servervspub serveris ambiguous and denies); - it is a remote server (a local/stdio server under a protected name denies);
- its
urlequals the operator-approved URL byte-for-byte (a changed binding denies); - it currently exposes the tool;
- the approved URL passes the syntactic screen (http/https, public host, no userinfo, query, fragment, whitespace or control characters). Ineligible URLs are refused before any disclosure and are never echoed into diagnostics.
After the awaited preflight the configuration is read again and must still match. Remaining
race: an administrative change to the MCP configuration that lands between that re-check and
tool.execute cannot be excluded by this hook; the framework offers no atomic identity guarantee
across the two. Initialization and list_tools happened earlier and are not covered — this
protects tools/call only.
Policy
| key | default | meaning |
|---|---|---|
policy.required_proven |
[endpoint_reachable, protocol_handshake] |
each must be proven; unknown is not proven |
policy.tolerate_unknown_required |
false |
accept unknown (never failed) on required checks — must be a real boolean; "false" (a string) is a configuration error |
policy.on_caution |
deny |
delegate_with_caution (a non-blocking check failed) |
policy.on_unavailable |
deny |
Guild unreachable, deadline, HTTP error, malformed / inconsistent / unrecognized body |
do_not_delegate always blocks, whatever the policy. Unknown stays unknown: it is reported in the
evidence and never counted as either failure or trust. Boolean settings (tolerate_unknown_required,
allow_private_hosts) accept real booleans. Omitted or null settings use the defaults; other
types make the configuration invalid,
which denies every protected call before any preflight. Every binding or policy denial — including a binding
that changed while the preflight was in flight — is recorded exactly once with its actual reason and
the observation obtained, replacing any earlier record. Configuration errors deny MCP calls before
a preflight but do not create an observation record.
Evidence
For binding and policy decisions, the plugin stores a decision record and, when a preflight
completed, its full observation (verdict, failed, unknowns, scored, checks, method,
limits, guild_origin, observed_at, cached, age_seconds, error) under
agent.get_data("agent_guild_preflight.last_observation") (last 20 under
…decisions) and logs a fixed-field summary to the chat log. That stored evidence is
untrusted operator data: it is never placed in the prompt. The model-visible denial message is a
projection of controlled values only — validated status/verdict enums, the operator-approved
endpoint (redacted form), our own timestamp, and check identifiers only if they are ones the Guild
contract defines (endpoint_reachable, protocol_handshake, agent_card_resolves,
agent_card_signed, payment_claim_holds, independent_evidence); any other identifier the
response carries is reduced to a count (+N unlisted), and headline/detail/method/limits text is
never included. This separation reduces the model's exposure to remote text; it is not a general
prompt-injection guarantee, and tool results that the framework itself returns from an allowed
call are outside this plugin's control.
A preflight is an unsigned observation of one moment: it is not identity, signature verification, protocol conformance, permission, or a guarantee about what the endpoint does with your data.
Network behaviour
- Only
GET {guild_origin}/preflight?url=<approved URL>; headers sent:User-Agent,Accept. No tool arguments, MCP headers, prompts, operator identity, cookies or credentials. guild_originis fixed to the production origin. The only accepted override (fixture_guild_origin) is a literalhttp://127.0.0.1:<port>/http://[::1]:<port>for offline tests; anything else makes the configuration invalid, which denies protected tools.- One wall-clock deadline (
timeout_seconds, default 5) over name resolution, connect, TLS, headers and the streamed body (1 MiB cap) viahttpxunderasyncio.timeout; at the deadline the request is cancelled and its connection closed, so no further bytes are read or sent. A name lookup already running inside the resolver's worker thread cannot be interrupted: it completes in the background, its result is discarded, and no connect follows. No redirects, retries or environment proxies (trust_env=False). - Observations are cached per exact URL for
cache_ttl_seconds(default 300) and labelledcachedwith their originalobserved_at; they are never presented as current.
Not covered: A2A (a2a_chat) calls, local/stdio MCP servers, tools that are not MCPTool
instances, and anything a caller does after disabling the plugin.
Tests
The release-support/native-tests/tests/ directory contains 49 tests through the real v2.11 framework runtime:
real Agent dispatch, loopback MCP servers and a local Guild fixture, with deterministic tool
requests and no model provider. Only the embedding dependency is stubbed at import.
See TESTING.md for the pinned runtime, reproducible commands and test limits.
Attribution
The preflight client (helpers/guild_preflight.py) is adapted from the reviewed
agentscope-agentguild 0.1.2 client (AgentTanuki, Apache-2.0). AgentScope is not a dependency.
Licensed under Apache-2.0 (see LICENSE). Not affiliated with the Agent Zero project.