Spaces:
Running on Zero
A newer version of the Gradio SDK is available: 6.19.0
Tool Contract
Tools are the fourth stable contract. An agent never holds a tool directly; it
asks the ToolRegistry, which enforces the agent's capability grant before
dispatching. "The Artist gets image-gen, the Critic does not" is enforced by the
runtime, not by convention.
Shape
A tool is a (name, description, run) triple. run(**params) returns a
JSON-serialisable dict that the calling agent folds into its emitted event.
registry = ToolRegistry()
registry.register("oracle", "Draw a cryptic omen. Params: {seed: str}.", oracle_fn)
Capability enforcement
registry.call(agent_name, manifest, tool, params)
# raises CapabilityViolation if tool not in manifest.tools
# raises KeyError if tool is granted but not registered
# else -> tool's dict result
ManifestAgent wraps this as self.call_tool(name, **params) and injects the
descriptions of granted tools into the prompt (AVAILABLE TOOLS block).
Worked example: the oracle path
oracle-grove pairs a tool-using agent with a tool-less one:
fortune-teller handler: fortune-teller tools: [oracle] emits: oracle.spoke
scene-whisperer (generic) tools: [] emits: world.observed
FortuneTeller (src/agents/handlers.py) calls the deterministic oracle tool,
weaves the omen into its prompt, and records the omen on the event payload β so
the tool output is first-class ledger data. scene-whisperer has no grant, so a
call would raise CapabilityViolation. tests/test_tools.py proves both.
Transports: in-process and MCP (ADR-0017, realized)
The (name, description, run) interface fronts an in-process callable and a
tool running out-of-process over the Model Context Protocol (MCP) β same contract,
swappable transport, invisible to agents. The capability check is the security
boundary; MCP is only transport.
flowchart TD
A["agent.call_tool(name, params)"] --> B["ToolRegistry.call()"]
B --> C{"name in manifest.tools?"}
C -->|no| X["raise CapabilityViolation"]
C -->|yes| D{"registered in-process?"}
D -->|yes| E["run() β dict (default)"]
D -->|no| F{"resolver has it?"}
F -->|yes| G["MCP client β stdio server β dict"]
F -->|no| Y["raise KeyError"]
The capability check (node C) runs first β before any transport is touched β so swapping in-process for MCP never weakens the security boundary.
ToolRegistry.call(...) enforces tool in manifest.tools and raises
CapabilityViolation on a denied call before any transport is touched β then
dispatches in-process if the tool is registered locally, otherwise to an attached
ToolResolver (the MCP client). describe() prefers in-process descriptions and
falls back to the resolver's, so prompt assembly is identical across transports.
Config gate
default_tool_registry() is in-process by default. Set one of these to resolve
granted tools over MCP instead (the grant check is unchanged either way):
MCP_SERVERSβ::-separated stdio command lines, e.g.MCP_SERVERS="python -m src.tools.mcp_server"orMCP_SERVERS="python -m src.tools.mcp_server :: node other-server.js".MCP_ORACLE=1β shorthand for the built-in oracle server (python -m src.tools.mcp_server); ignored whenMCP_SERVERSis set.
With neither set the registry stays fully in-process β the offline default the
test-suite exercises. mcp is an optional extra (pip install -e '.[mcp]') and
is imported lazily, so import src.* and import app work with it not installed.
The server exposes the same oracle implementation, so for a given seed the
omen drawn over MCP is byte-identical to the in-process one: oracle-grove
produces the same ledger in both modes.
Code
src/tools/registry.pyβToolRegistry,ToolSpec,CapabilityViolation,ToolResolversrc/tools/builtins.pyβoracle,default_tool_registry()(MCP config gate)src/tools/mcp_server.pyβ FastMCP stdio server exposing the built-in toolssrc/tools/mcp_client.pyβMCPToolClient,MCPResolver, env gate (mcp_resolver_from_env)src/agents/handlers.pyβFortuneTeller(handler that calls a tool)config/agents/fortune-teller.yaml,config/scenarios/oracle-grove.yamltests/test_tools.py(in-process),tests/test_mcp.py(transport + capability)