Reading tools and contents
Agentic AI Systems

Chapter 3 of 8

Tools are capability contracts, not prompt decorations

Chapter 3

About 5 minutes · includes examples, an exercise, and references

Chapter at a glance

  • Expose narrow task-level capabilities with closed schemas and independently enforced domain authorization.
  • Separate capability discovery from permission, and build the allowed tool set from current authenticated state.
  • Give tools stable error and observation schemas, idempotency keys for writes, and bounded untrusted outputs.

A tool turns a probabilistic proposal into a possible real-world effect. That boundary deserves the same discipline as a public API. The model sees a name, description, and argument schema. Trusted code resolves the caller, validates the payload, authorizes the capability, executes with deadlines and isolation, and converts the result into a bounded observation. Every step can fail independently, so “the model called the tool” is only the beginning of the execution story.

Start with task-oriented tool design. A tool named run_sql with an arbitrary string makes the action space enormous and pushes authorization, validation, and intent into generated text. Tools such as get_order, list_refundable_items, quote_refund, and create_refund expose narrower semantics. Narrow tools are easier for a model to select, easier for policy code to authorize, easier to test, and easier for an operator to interpret. They also let the implementation change without changing the model-facing contract.

The schema is an executable boundary. Define required and optional fields, closed enums, numeric limits, string patterns, and whether unknown properties are rejected. Validate twice: once when the model output is decoded and again inside the service that owns the effect. The first validation protects the agent runtime; the second protects the domain system from every caller, not only agents. Structured output reduces syntactic ambiguity but does not prove semantic validity. A perfectly shaped request can still reference the wrong tenant, exceed policy, or encode an unsafe path.

Descriptions influence policy selection and therefore belong under change control. State what the tool does, what it does not do, whether it has side effects, which preconditions apply, and what evidence it returns. Do not put secrets, internal authorization rules, or instructions that a remote provider can rewrite into descriptions. The MCP specification explicitly treats tool behavior descriptions as untrusted unless they come from a trusted server. A host should display meaningful consent information and enforce authorization independently of annotations.

Separate discovery from authority. A model may discover that a capability exists, but discovery must not grant permission to invoke it. Build the allowed tool set per run from the authenticated principal, tenant, environment, current phase, and approval state. Filter both the catalogue shown to the model and the dispatcher that receives the call. Dispatcher enforcement is mandatory because generated names and arguments are untrusted and because a stale context may mention a capability that has since been revoked.

Every tool needs an error algebra. At minimum distinguish invalid_argument, unauthorized, forbidden, not_found, conflict, rate_limited, transient_dependency, permanent_dependency, deadline_exceeded, canceled, and internal. Add retryable as explicit metadata rather than asking the model to infer it from prose. Include a stable tool-call identifier and enough bounded detail to repair arguments. Do not return stack traces, credentials, full database rows, or unlimited command output to the model.

Write tools require effect identity. The runtime assigns an idempotency key derived from the run, logical action, and relevant state version. A retried call reuses that key. The service records the key atomically with the mutation and returns a semantically equivalent receipt on duplicates. The observation includes the receipt and verification handle. This is more reliable than prompting the model not to repeat itself because network timeouts can hide a successful write from both model and caller.

Tool results are untrusted data. A web page can contain prompt injection; a repository file can include instructions to exfiltrate secrets; a remote tool can be compromised. Keep instructions and observations in distinct channels or typed fields. Apply content-size limits, MIME checks, canonicalization, secret scanning, and provenance. For high-risk data, summarize in a lower-privilege process and attach the raw artifact by digest rather than interpolating it directly into the control prompt.

MCP standardizes integration among hosts, clients, and servers, but protocol compliance is not product safety. Hosts still decide which servers are trusted, how users consent, which credentials are delegated, how egress is constrained, and whether a tool requires approval. Treat each remote server as a supply-chain dependency. Pin versions or capability digests, inventory ownership, define availability behavior, and test malformed or adversarial responses.

Tool selection also has an economics layer. Sending hundreds of overlapping tools increases prompt size and selection confusion. Use task-aware discovery or a small router that exposes only relevant capabilities. Measure wrong-tool rate, invalid-argument rate, calls per successful task, repeated calls with unchanged arguments, latency, and marginal value of each tool. Remove tools that add surface area without improving verified outcomes.

A reliable tool contract has four views. The model view contains concise semantics and a strict argument schema. The policy view contains principals, scopes, phases, budgets, and approval requirements. The executor view contains timeouts, idempotency, isolation, and dependency clients. The observation view contains a stable result schema, provenance, and retry classification. Keeping these views separate prevents a prompt edit from becoming an authorization change and makes interface review possible without debating model personality.

At application scale, a tool registry should be versioned like an API. Contract tests ensure examples satisfy schemas, removed fields follow a migration window, error codes remain stable, and write receipts can be verified. A shadow environment can replay recorded proposals against mocks before enabling a new tool in production. The release unit is not “a prompt and some functions”; it is a versioned policy, catalogue, schemas, implementations, fixtures, and evaluation set.

Key points

  • Expose narrow task-level capabilities with closed schemas and independently enforced domain authorization.
  • Separate capability discovery from permission, and build the allowed tool set from current authenticated state.
  • Give tools stable error and observation schemas, idempotency keys for writes, and bounded untrusted outputs.
  • Version descriptions, schemas, policy, executors, and contract tests as one release surface.

Schema-aware capability dispatcher

Read the expected behavior in the surrounding walkthrough, then copy and run this reference implementation.

Schema-aware capability dispatcherpython
from __future__ import annotations

from dataclasses import dataclass
from math import isfinite
from typing import Any, Callable

@dataclass(frozen=True)
class Principal:
    tenant: str
    scopes: frozenset[str]

@dataclass(frozen=True)
class Tool:
    required: frozenset[str]
    allowed_fields: frozenset[str]
    validate: Callable[[dict[str, Any]], bool]
    handler: Callable[[Principal, dict[str, Any]], dict[str, Any]]

def finite_operands(args: dict[str, Any]) -> bool:
    values = (args["left"], args["right"])
    if any(type(value) not in {int, float} for value in values):
        return False
    try:
        return all(isfinite(value) for value in values)
    except OverflowError:
        return False

def add(_: Principal, args: dict[str, Any]) -> dict[str, Any]:
    value = float(args["left"]) + float(args["right"])
    if not isfinite(value):
        raise ValueError("result is not finite")
    return {"status": "ok", "value": value}

REGISTRY = {
    "add": Tool(
        frozenset({"calculator:use"}),
        frozenset({"left", "right"}),
        finite_operands,
        add,
    ),
}

def dispatch(principal: Principal, name: str, args: dict[str, Any]) -> dict[str, Any]:
    tool = REGISTRY.get(name)
    if tool is None:
        return {"status": "error", "code": "not_found", "retryable": False}
    if not tool.required.issubset(principal.scopes):
        return {"status": "error", "code": "forbidden", "retryable": False}
    if set(args) != tool.allowed_fields:
        return {"status": "error", "code": "invalid_argument", "retryable": False}
    if not tool.validate(args):
        return {"status": "error", "code": "invalid_argument", "retryable": False}
    try:
        return tool.handler(principal, args)
    except (TypeError, ValueError):
        return {"status": "error", "code": "invalid_argument", "retryable": False}

user = Principal("tenant-a", frozenset({"calculator:use"}))
assert dispatch(user, "add", {"left": 2, "right": 3})["value"] == 5
assert dispatch(user, "add", {"left": 2, "right": 3, "code": "evil"})["code"] == "invalid_argument"
assert dispatch(user, "add", {"left": float("nan"), "right": 3})["code"] == "invalid_argument"
print(dispatch(user, "add", {"left": 2, "right": 3}))

Worked examples

Toy

A safe calculator

A model chooses add, subtract, multiply, or divide through a closed schema instead of executing arbitrary Python.

The dispatcher rejects unknown operations, non-finite values, and division by zero. The result observation contains value or a stable error code. The example shows why a tiny capability beats a general code executor for a tiny job.

  • Unknown fields are rejected.
  • Errors are data rather than exceptions in the prompt.
  • No arbitrary code path exists.

Application

Read versus write customer tools

A service agent can inspect accounts broadly but needs a fresh approval to change a subscription.

Read tools receive tenant-scoped credentials. The write catalogue appears only after the runtime records an approval bound to customer, operation, parameters, and expiry. The dispatcher checks the binding even if an old transcript contains the tool name.

  • Catalogue filtering and dispatcher checks agree.
  • Approval is bound to exact arguments.
  • The write returns a verification receipt.

System

Multi-provider capability gateway

An enterprise host connects internal functions and several MCP servers while enforcing one policy plane.

The gateway normalizes schemas and observations but preserves provider provenance. It authenticates each server, maps user identity to least-privilege credentials, imposes egress and payload limits, and records tool version plus policy decision in every span.

  • Remote descriptions never override host policy.
  • Credential delegation is audience-restricted.
  • Provider outages have explicit fallback behavior.

Exercise

Threat-model a tool catalogue

Take a broad agent tool and redesign it into the smallest capability set that supports one real workflow.

  1. Split read, propose, and write behavior into separate named tools with strict schemas.
  2. Define authorization inputs, approval binding, timeout, idempotency, and result schema for each write.
  3. Enumerate malicious or malformed observations and specify normalization limits.
  4. Create contract cases for success, invalid arguments, forbidden access, duplicate delivery, timeout, and dependency failure.

Success criteria

  • The model cannot express arbitrary code or unrestricted queries through the redesigned interface.
  • A discovered tool remains unusable without independent dispatcher authorization.
  • Duplicate write calls produce one intended effect and a stable receipt.
  • Failures are machine-classifiable and do not leak sensitive diagnostics.

Reflect: Which tool looked convenient only because it silently transferred product policy into generated text?

References and further reading