September 20, 2026

Building Tools for Agents: What Makes a Good Tool Interface?

Agent tools are interfaces for probabilistic callers. Good ones constrain interpretation, enforce authority and report outcomes clearly enough for agents and operators to act safely.

A simple request, briefly

A customer tells a support agent, “I entered my old address for the headphones I ordered yesterday. Please send them to my new apartment instead.”

At first glance, this is clerical work. Find the order, replace the address, save. The parcel complicates matters by continuing to move while everyone discusses it.

The first tool for handling this request might look like this:

1@mcp.tool
2async def update_order(order_id: str, address: str) -> bool:
3 """Update an order."""
4 ...
The examples use FastMCP 3.4.4, although the interface principles are independent of the framework.

This function would not look out of place in an internal service. Its callers are expected to learn the surrounding conventions elsewhere. They know what an order ID looks like, which address format the service accepts, and whether the function is available after dispatch. If it returns true, an engineer can inspect the implementation or logs to work out what succeeded.

An agent usually has the tool's name, description, schema, and returned value. It must decide whether the tool applies from that public surface. The address argument could mean a formatted string or a complete postal address, while the tool name says nothing about which part of the order will change. The Boolean could refer to an updated database row, a warehouse acknowledgement, or a carrier redirect for a parcel already in transit. Turning the function into an agent tool makes all of that missing context part of its interface. The model interprets the contract at runtime, and some interpretations will be wrong. I want the tool to carry more of the meaning itself, especially when a reasonable misunderstanding can send someone’s parcel to the wrong address.

Name the operation before describing it

The first redesign should narrow the operation. I am happy to accept a longer name here. It tells the agent which field will change and places a visible boundary around the order state.

1@mcp.tool
2async def change_unshipped_order_address(
3 request: AddressChangeRequest,
4) -> AddressChangeResult:
5 """Change the delivery address for an order before carrier handoff.
6
7 Use this after the customer has supplied the complete replacement
8 address. The existing delivery address is replaced in full.
9
10 The order must belong to the authenticated customer and must not be
11 cancelled, shipped, or locked for warehouse processing. If the parcel
12 has been handed to the carrier, use request_shipment_redirect instead.
13 """
14 ...

The docstring gives the model information that would otherwise be buried in service documentation. It explains that the tool replaces the address rather than patching individual fields. It also identifies the order states in which the operation is available and points to the appropriate capability when the retailer no longer controls the parcel. The extra detail belongs here because it affects tool selection.

The shipped case needs its own tool.

1@mcp.tool
2async def request_shipment_redirect(
3 request: ShipmentRedirectRequest,
4) -> ShipmentRedirectResult:
5 """Ask the carrier to redirect an eligible shipment to a new address.
6
7 This submits a redirect request. It does not confirm that the carrier
8 has accepted or completed the redirect. Report the returned status to
9 the customer without presenting the request as a completed change.
10 """
11 ...

Once the parcel has been dispatched, there may be nothing left to update in the retailer’s fulfilment system. The carrier may accept a redirect, decline it, or offer another delivery option. UPS, for example, allows shippers to request a redirect before delivery, subject to its own restrictions. Putting both behaviors behind a broad order update tool would conceal that change in authority. Before handoff, the retailer owns the state transition. After handoff, it can only request an action from the carrier. I would rather expose two plain tools than one versatile tool whose meaning changes halfway through an order’s life.

Descriptions still need enforcement behind them. If the model calls the address change tool after dispatch, the order service must reject the request. A detailed docstring can improve tool selection, but it cannot protect the warehouse from a stale or incorrect call.

Make incomplete requests difficult to express

The address string is the next obvious weakness. It leaves the model to decide how several fields should be flattened into text, and it gives the gateway little to validate before calling the order service.

1from typing import Annotated
2
3from pydantic import BaseModel, ConfigDict, Field, StringConstraints
4
5
6NonEmpty = Annotated[str, StringConstraints(strip_whitespace=True, min_length=1)]
7OrderId = Annotated[
8 str,
9 Field(strict=True, pattern=r"^ord_[0-9a-f]{32}$"),
10]
11
12
13class PostalAddress(BaseModel):
14 model_config = ConfigDict(extra="forbid")
15
16 recipient_name: NonEmpty = Field(
17 description="Person or organization receiving the parcel"
18 )
19 address_line_1: NonEmpty
20 address_line_2: str | None = None
21 locality: NonEmpty = Field(description="City, town, or postal locality")
22 administrative_area: str | None = Field(
23 default=None,
24 description="State, province, or region when required",
25 )
26 postal_code: NonEmpty
27 country_code: str = Field(
28 strict=True,
29 pattern=r"^[A-Z]{2}$",
30 description="Two-letter ISO 3166-1 country code",
31 )
32
33
34class AddressChangeRequest(BaseModel):
35 model_config = ConfigDict(extra="forbid")
36
37 order_id: OrderId = Field(
38 description="Order ID with an ord_ prefix and 32 lowercase hex characters"
39 )
40 new_address: PostalAddress
41 expected_order_version: int = Field(
42 ge=1,
43 description="Order version most recently read by the agent",
44 )

I like request models at this boundary to be boring. Required fields are required, unknown fields are rejected, and identifiers follow the format used by the application. For this example, an order ID begins with “ord_” and ends with 32 lowercase hexadecimal characters. Encoding that rule in the schema stops the agent from passing an arbitrary string when it invents or miscopies an identifier. It does not prove that a well-formed ID exists or belongs to the caller; the order service must still establish both. The schema does not attempt to decide whether a postal address is real either. Address rules vary by country, and well-formed input can still be undeliverable. That requires an address service and, in some cases, another exchange with the customer. Suppose validation expands a street name or changes the postal code. The tool can safely apply a cosmetic normalization, but a material correction should come back as a suggested address that the customer can confirm.

The same boundary applies elsewhere. Pydantic can check that the expected order version is a positive integer, but it cannot say whether that version is still current. It can verify that the country code contains two capital letters, while the order service must decide whether the retailer ships there and whether moving the order across regions changes its delivery options. The remaining questions depend on current order state and the retailer’s delivery rules.

Current state belongs in the decision

An agent might read the order while it is waiting for fulfilment, spend a few seconds collecting the new address, and then submit the change. The warehouse does not pause for the conversation. It could create a label or lock the order for packing during those few seconds, while the transcript continues to describe an order that was safe to edit. Including the expected order version makes the earlier observation explicit and lets the service compare it with the version it is about to change.

The version and fulfilment checks need to happen close to the write. Checking fulfilment state in the gateway and changing the order later would leave a race between the two operations. The domain service should compare the version, verify the allowed state, and commit the change as one protected transition. There will be other preconditions. The order cannot be cancelled, payment may need to remain valid for the destination, and some goods cannot be shipped to every region. Keeping those rules in the order domain gives every caller the same behavior, including customer applications and internal support tools.

I would not let the model tell the gateway who it is. The request should identify the order to change, while the authenticated connection supplies the identity under which the change is authorized.

1from fastmcp.exceptions import ToolError
2from fastmcp.server.auth import AccessToken
3from fastmcp.server.dependencies import CurrentAccessToken
4
5
6@mcp.tool(timeout=10.0)
7async def change_unshipped_order_address(
8 request: AddressChangeRequest,
9 token: AccessToken = CurrentAccessToken(),
10) -> AddressChangeResult:
11 principal = Principal.from_access_token(token)
12
13 if not await policy.can_change_delivery_address(
14 principal=principal,
15 order_id=request.order_id,
16 ):
17 raise ToolError("The caller cannot change this order's address.")
18
19 return await order_service.change_delivery_address(
20 principal=principal,
21 request=request,
22 )

FastMCP injects the access token from the current authenticated request and leaves that parameter out of the tool schema shown to the model. The application then maps the token’s subject, tenant, and scopes into its own principal type. Allowing the request to carry a customer ID or an administrator flag would only let the agent state who it wants to be. The permission should match the capability. A customer may change an eligible order address, while a support employee may have permission to request a carrier redirect. Neither permission implies access to a general order update operation that can change prices, ownership, or fulfilment state.

Say what happened

The Boolean returned by the initial prototype could only indicate success or failure, which gives the agent too little information to choose its next step. The result model used in the redesigned interface must distinguish a completed address change, a suggested correction, a rejected request, and an uncertain downstream outcome.

1from typing import Literal
2
3
4class AddressChangeResult(BaseModel):
5 status: Literal[
6 "completed",
7 "requires_confirmation",
8 "rejected",
9 "outcome_unknown",
10 ]
11 operation_id: str | None = None
12 order_id: OrderId
13 code: str | None = None
14 message: str
15 confirmed_address: PostalAddress | None = None
16 suggested_address: PostalAddress | None = None
17 retryable: bool = False

For a completed change, the result records the address committed to the order. If validation needs customer confirmation, it includes the suggested address instead. A rejected result carries a stable reason code. I treat an unknown outcome as a real result rather than an awkward gap to hide behind a generic error. The redirect tool needs its own model because a carrier request may be submitted, later accepted, or rejected as ineligible. If the carrier has only received the request, the agent should say so. “Your redirect is complete” would claim more than the external system has confirmed.

Error handling works better when it preserves these meanings. A malformed country code can fail schema validation, while a missing authentication context or denied permission can raise a controlled ToolError. Version conflicts, warehouse handoff, and address confirmation are expected domain outcomes that the agent needs to distinguish. Raising ToolError for them would mark the invocation itself as failed and may send the surrounding agent loop into generic error handling. The domain guards therefore return structured rejections with the code and data needed for the next step.

1order = await orders.get(request.order_id)
2
3if order.version != request.expected_order_version:
4 return AddressChangeResult(
5 status="rejected",
6 order_id=order.id,
7 code="ORDER_STATE_CHANGED",
8 message="The order changed after it was read. Read it again before acting.",
9 )
10
11if order.fulfilment_state not in {"pending", "address_change_allowed"}:
12 return AddressChangeResult(
13 status="rejected",
14 order_id=order.id,
15 code="WAREHOUSE_HANDOFF_STARTED",
16 message="The retailer can no longer change this address.",
17 )

With retryability defaulting to false, the version-conflict result tells the agent to read the order again rather than repeat the same call. An address-confirmation result calls for a customer response, while an authorization failure ends the attempt. Stable codes are useful because neither the agent nor application code should have to infer recovery behaviour from the wording of an exception.

Internal failures need a different treatment. Stack traces, database names, and policy internals do not help the model recover and may disclose information it should never receive. The gateway can log those details under the operation ID and return a bounded public error.

The retry needs to refer to the same change

Imagine that the order service commits the new address, then sends the change to the warehouse. The warehouse accepts it, but its response is lost. Eventually the tool call times out. The agent has an error and no reliable account of the warehouse state.

Calling the tool again may be reasonable, provided the second call is another attempt at the same logical operation. The gateway receives the retained idempotency key from the invocation context and binds it to a fingerprint of the order ID, expected version, and normalized address. If the model generated a fresh key after a timeout, the service would see a new operation and could repeat the side effect.

1request_fingerprint = fingerprint(request)
2reservation = await operations.reserve(
3 key=invocation.logical_operation_id,
4 fingerprint=request_fingerprint,
5)
6
7if reservation.fingerprint != request_fingerprint:
8 raise ToolError("The idempotency key was reused for a different change.")
9
10if not reservation.created:
11 return await operations.await_result(reservation.operation_id)
12
13return await execute_address_change(reservation.operation_id, request)

The reservation must be atomic. A lookup followed by an insert leaves enough time for two simultaneous deliveries to find nothing and both begin. In practice, the operation table can enforce uniqueness on the key while a transactional insert decides which caller created the record. The other caller joins the recorded operation and waits briefly for its result instead of starting another address change.

If the same key arrives with a different fingerprint, the call is rejected. Google’s HTTP guidance describes idempotence in terms of server-side effects, which is the property needed here. A timeout only limits how long the caller waits and provides no evidence about whether the warehouse received the request. Once the result is uncertain, the service can query by operation ID, inspect the current order and warehouse state, and update the operation record. Until that reconciliation finishes, an unknown outcome is more accurate than either success or failure.

Retry policy follows from the error semantics. A temporary failure while reading order state may be retried with backoff. Invalid input should be returned to the agent for correction. A mutation with an unknown outcome should be reconciled before anything creates a new operation.

The tool gateway

The resulting path separates what the agent is asked to understand from what the system must enforce.

The agent chooses between the address change and redirect tools based on the customer’s request and the order state it has read. At the gateway, the call is validated and associated with an authenticated principal. The domain service checks the order version and business rules, records the operation, and coordinates the warehouse or carrier request. Responses return through the same boundary in the public result format.

Mandatory rules remain in the services that own them, and the MCP server does not accumulate order logic. Its gateway adapts the agent-facing contract to the same domain services used by the rest of the application.

Test the contract, then follow the effect

I would start the evaluation by checking whether the agent chose the address change tool for an unshipped order and the redirect tool after dispatch. That tells us whether the names, descriptions, and surrounding context were clear enough.

It does not cover the consequences of the call. The test suite should verify that the authenticated customer owned the order, that a stale version produced no write, and that repeated delivery of one idempotency key created one logical operation. For redirects, the final customer response should agree with the status confirmed by the carrier. Several cases are worth injecting deliberately. Let the warehouse lock the order after the agent reads it. Return an address correction that requires confirmation. Drop the warehouse response after applying the change. Repeat a request with the same key, then reuse that key with different arguments. These tests reveal whether the interface gives the caller enough information to recover and whether the backend holds its boundary when the caller behaves poorly.

These tests need evidence from the model call through to the final external state. Correlation IDs can connect those events without putting a customer’s full address into traces. That record can expose failures that a tool-call transcript cannot. The model may have chosen the wrong capability or misunderstood its result, while the services may have accepted stale state, duplicated an operation, or reported a downstream outcome inaccurately.

Let the tool be boring

Agent tool reviews often stop once a model can work out what the function means. I think that standard is too forgiving. What matters is how much interpretation remains after the model has read the name, schema, and possible results. Every plausible interpretation becomes another behaviour to test and another explanation an operator may eventually owe a customer.

Any tool whose safety case depends on the model understanding what we meant is unfinished. It should remain safe when selected at the wrong time or given a plausible but fictional identifier. A lost response should not turn a retry into another side effect. Meeting that standard usually produces narrower tools and more explicit refusals. There is no engineering prize for hiding several dangerous operations behind one elegant verb.