Skip to content

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.

  • 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 definitionSha256 DID.is reports for MCP tools. No network call.
  • Delegation (DID.is). verify-tool confirms the configured agent DID has a registered, VALID delegation chain whose capabilities grant the tool, and, if you set root, that the chain starts at that root.
  • 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_pins to refuse them.

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.

  1. Review the MCP server’s tools/list and store pins: pinToolDefinitions(tools) / pin_tool_definitions(tools) returns { toolName: sha256 }.
  2. Register the agent’s delegation chain once: verifyDelegation(chain, { trustedRoots: [root], register: true }).
  3. 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;
}

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.

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

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 });
}

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.

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.

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

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 *).
  • An ALLOW is reused for 30 seconds by default (allowCacheMs / allow_cache_seconds, 0 disables), never past the delegation’s expiry. DENY and errors are never cached. Call clearCache() / clear_cache() after revoking a delegation.
  • verify-tool uses the standard rate-limit budget. A 429 fails closed, so size the cache to your call rate. See Errors and rate limits.
  • The ALLOW cache is keyed by capability, so two tool names mapped to the same capability share one answer.
  • onDecision / on_decision receives every decision for audit logs, with both tool and capability. An exception in the hook does not change the decision.

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.