Guard agent tools
An agent tool is dangerous in two ways: the tool can change after you approved it (an MCP server silently rewrites a description or schema), and the agent can call a tool nobody delegated to it. The SDK guard checks both before your handler runs. If either check fails, or DID.is cannot answer, the handler does not run.
What is checked
Section titled “What is checked”- Definition pin (local). The tool definition the agent actually received hashes to the SHA-256 you approved. The hash is RFC 8785 (JCS) + SHA-256, the same
definitionSha256DID.is reports for MCP tools. No network call. - Delegation (DID.is).
verify-toolconfirms the configured agent DID has a registered,VALIDdelegation chain whose capabilities grant the tool, and, if you setroot, that the chain starts at that root.
What is not checked
Section titled “What is not checked”- Who is calling. The guard trusts the agent DID from your configuration. Never take it from model output or request input.
- Arguments. The guard decides whether a tool may run, not what it is called with. Validate arguments in your handler.
- Unpinned tools. A tool without a pin skips the definition check. Set
requirePins/require_pinsto refuse them.
Capabilities and tool names
Section titled “Capabilities and tool names”A capability is what a delegation grants, written with : namespaces: weather:* grants weather:forecast and weather:alerts:list, but not forecast, payments:refund or weatherx:forecast. payments:refund grants only payments:refund. See Agent trust APIs.
A tool name is what the model and the MCP server see. MCP, OpenAI and most agent frameworks allow only letters, digits, _, - and . in tool names, so a tool cannot be called weather:forecast. Give the guard both names:
| You pass | Used for | Example |
|---|---|---|
tool |
The pin (tools/list name) and error messages |
weather_forecast |
capability |
The question sent to DID.is verify-tool |
weather:forecast |
If you leave out capability, the tool name is used as the capability (useful when your tools are not exposed through MCP). A capability must be exact: no *, no empty segments, no spaces. Wildcards belong in delegations, not in tool calls. Requires SDK 0.2.1 or later.
Keep the name-to-capability table in your code or configuration, next to the pins. A tool missing from the table should be refused, as in the examples below.
- Review the MCP server’s
tools/listand store pins:pinToolDefinitions(tools)/pin_tool_definitions(tools)returns{ toolName: sha256 }. - Register the agent’s delegation chain once:
verifyDelegation(chain, { trustedRoots: [root], register: true }). - Wrap each tool handler with the guard.
import { DidisClient, createDidisGuard, DidisGuardError } from "@didis/client";
const guard = createDidisGuard({ client: new DidisClient(), agent: process.env.AGENT_DID!, root: process.env.ROOT_DID, pins, // from step 1 requirePins: true,});
const forecast = guard.wrap( { tool: "weather_forecast", // name in tools/list (pin key) capability: "weather:forecast", // what the delegation must grant definition: () => currentTools.get("weather_forecast"), }, async (city: string) => weatherApi.forecast(city),);
try { await forecast("Reykjavik");} catch (e) { if (e instanceof DidisGuardError) console.warn(e.code, e.tool, e.detail?.capability); // the tool did not run else throw e;}import osfrom didis import DidisClient, DidisGuard, DidisGuardError
guard = DidisGuard( DidisClient(), agent=os.environ["AGENT_DID"], root=os.environ["ROOT_DID"], pins=pins, # from step 1 require_pins=True,)
@guard.tool( "weather_forecast", # name in tools/list (pin key) definition=lambda: current_tools["weather_forecast"], capability="weather:forecast", # what the delegation must grant)def forecast(city: str) -> str: return weather_api.forecast(city)
try: forecast("Reykjavik")except DidisGuardError as e: print(e.code, e.tool, e.detail["capability"]) # the tool did not runUse AsyncDidisGuard with AsyncDidisClient for async def tools.
For a single tool there is a one-line form: withDidisGuard(handler, { client, agent, root, tool, capability, pin, definition }) in TypeScript and @didis_guard(client, agent, tool, root, capability=..., pin=..., definition=...) in Python. For several tools, create one guard so they share its cache.
Use with MCP and agent frameworks
Section titled “Use with MCP and agent frameworks”The guard does not depend on any framework. Put guard.check(...) in the one place where your code runs a tool, before the tool runs. Each pattern below has a complete, runnable example in the SDK repositories. The examples need no account, no LLM and no API key; they call the public API with throwaway demo keys.
| Framework | Where the check goes | TypeScript | Python |
|---|---|---|---|
| MCP client (official SDKs) | Your callTool / call_tool function, before client.callTool |
mcp-client.mjs |
mcp_client.py |
| LangChain | Inside the tool function, before the real work | langchain-tool.mjs |
langchain_tool.py |
| OpenAI Agents SDK | Inside execute / a decorator under @function_tool |
openai-agents-tool.mjs |
openai_agents_tool.py |
MCP client
Section titled “MCP client”Your agent calls a third-party MCP server. Pin its tools/list once after review, then route every call through one function. The definition passed to the guard is the one the server serves now, so a server that changes a description after approval is caught (TOOL_DRIFT) before the call is sent.
// MCP tool name -> DID.is capability. Tools not listed are refused.const CAPABILITIES = { weather_forecast: "weather:forecast", payments_refund: "payments:refund" };
async function callTool(name: string, args: Record<string, unknown>) { const capability = CAPABILITIES[name]; if (!capability) throw new DidisGuardError("TOOL_NOT_AUTHORIZED", name, `no capability mapping for '${name}'`); await guard.check({ tool: name, capability, definition: async () => (await mcp.listTools()).tools.find((t) => t.name === name), }); return mcp.callTool({ name, arguments: args });}# MCP tool name -> DID.is capability. Tools not listed are refused.CAPABILITIES = {"weather_forecast": "weather:forecast", "payments_refund": "payments:refund"}
async def call_tool(name: str, args: dict): capability = CAPABILITIES.get(name) if capability is None: raise DidisGuardError("TOOL_NOT_AUTHORIZED", name, f"no capability mapping for '{name}'")
async def current_definition(): tools = (await mcp.list_tools(cache_mode="refresh")).tools return next((t.model_dump(by_alias=True, exclude_none=True, mode="json") for t in tools if t.name == name), None)
await guard.check(name, current_definition, capability=capability) # AsyncDidisGuard return await mcp.call_tool(name, args)The Python example uses mcp 2.x (from mcp import Client). Pins must be computed from the same plain-JSON form (model_dump(by_alias=True, exclude_none=True, mode="json")).
Fetching tools/list before every call costs one round trip to the MCP server. If that is too slow, cache the list and refresh it when the server sends notifications/tools/list_changed; the pin check is local and costs nothing.
LangChain and OpenAI Agents SDK
Section titled “LangChain and OpenAI Agents SDK”These tools run your own code, so there is usually no remote definition to pin. Check the capability inside the tool. When the guard blocks, return a short refusal to the model instead of raising, so it tells the user rather than retrying.
import { tool } from "@langchain/core/tools"; // or: import { tool } from "@openai/agents";import { z } from "zod";
const forecast = tool(async ({ city }) => { try { await guard.check({ tool: "weather_forecast", capability: "weather:forecast" }); } catch (e) { if (e instanceof DidisGuardError) return `Blocked by policy (${e.code}). Do not retry this tool.`; throw e; } return weatherApi.forecast(city);}, { name: "weather_forecast", description: "Forecast for a city.", schema: z.object({ city: z.string() }) });// OpenAI Agents SDK: tool({ name, description, parameters, execute }) with the same body.from agents import function_tool # OpenAI Agents SDK
def guarded(guard, *, capability): def decorate(fn): @functools.wraps(fn) # keeps the signature, so the model sees the real parameters async def run(*args, **kwargs): try: await guard.check(fn.__name__, capability=capability) except DidisGuardError as e: return f"Blocked by policy ({e.code}). Do not retry this tool." return await fn(*args, **kwargs) return run return decorate
@function_tool@guarded(guard, capability="weather:forecast")async def weather_forecast(city: str) -> str: """Forecast for a city.""" return await weather_api.forecast(city)For LangChain, see langchain_tool.py: the same check inside a StructuredTool.
Returning a refusal is a choice for the model’s benefit; the tool body still does not run. If you prefer the run to stop, raise instead (LangChain) or let the error reach the SDK’s error handling (OpenAI Agents SDK).
Errors
Section titled “Errors”| Code | Meaning |
|---|---|
TOOL_DRIFT |
The definition no longer matches its pin, or its name differs. |
TOOL_DEFINITION_MISSING |
The tool is pinned but no definition was supplied, or requirePins is on and the tool has no pin. |
TOOL_NOT_AUTHORIZED |
DID.is answered DENY, or the chain’s root is not the configured root. |
GUARD_UNAVAILABLE |
DID.is could not be reached, returned an error (including 429), or gave an unexpected answer. |
GUARD_MISCONFIGURED |
Invalid agent or root DID, pin, cache setting, tool name or capability (for example one containing *). |
Caching and limits
Section titled “Caching and limits”- An
ALLOWis reused for 30 seconds by default (allowCacheMs/allow_cache_seconds,0disables), never past the delegation’s expiry.DENYand errors are never cached. CallclearCache()/clear_cache()after revoking a delegation. verify-tooluses the standard rate-limit budget. A429fails closed, so size the cache to your call rate. See Errors and rate limits.- The
ALLOWcache is keyed by capability, so two tool names mapped to the same capability share one answer. onDecision/on_decisionreceives every decision for audit logs, with bothtoolandcapability. An exception in the hook does not change the decision.
Optional: compare with DID.is
Section titled “Optional: compare with DID.is”attestMcpPins(client, endpoint, pins) / attest_mcp_pins(...) asks DID.is to inspect a public MCP server and compares the observed definitionSha256 values with your pins. It returns ok: false when any pin differs or the server could not be inspected (for example AUTH_REQUIRED).
Run it at startup or deploy time, not before every call. It is an outbound, rate-limited request. DID.is cannot reach servers that need a login or run on a private network. The local pin check is the one that protects each call.
Next steps
Section titled “Next steps”- Agent trust APIs covers delegation verification and capabilities.
- Delegation receipts describes the receipt format.