Define a tool with Pydantic schemas, an error contract and a registry entry¶
Draft outline: not validated
Drafted with AI from official sources on 16 Oct 2026; updated on 22 Oct 2026 for task AGT-02-T01. Do not use on a live system until it is written, dry-run by someone other than the author and signed off (planned 2026-12-08). Author: Sanjay Sarvesh · dry-run: Yasar · sign-off: James. Work items: AGT-02-T01, AGT-02-T02, AGT-02-T03, AGT-02-T04.
1. Summary¶
This how-to shows how to build an enterprise-ready agent tool using Pydantic V2 schemas, a structured error contract that maps to MCP tool execution errors, and an internal tool registry. It covers strict argument typing, input and output schema validation, machine-readable error codes with retryable indicators, and direct binding to LangGraph agents via bind_tools and ToolNode. Standardizing tool definitions eliminates schema drift, halts runaway retry loops, and enforces authorization boundaries before tools are invoked.
Risk: Medium (weak schemas let the model send bad or dangerous arguments). Typical duration: 2-4 hours per tool.
2. When to use and when not to¶
Use it when:
- Adding a new capability (database query, external API integration, internal microservice call) to the enterprise agent catalog.
- Refactoring ad hoc or unstructured Python functions into standardized, versioned tools.
- Exposing internal services to Model Context Protocol (MCP) clients or LangGraph agent runtimes.
- Setting up granular authorization scopes and machine-readable error contracts for agent tools.
Do not use it when:
- The agent uses native LLM reasoning without external system access (such as pure text summarisation or classification).
- The capability is already provided as an out-of-the-box cloud platform connector without custom business logic.
- Performing zero-downtime credential rotations for existing tool connectors: follow PB-AGT-02-02.
- Shipping a breaking schema change to an existing tool using canary versions: follow PB-AGT-02-03.
- Conducting an operational or security permission audit of registered tools: follow PB-AGT-02-04.
- Triaging an active outage or rate-limiting incident on a downstream tool dependency: follow PB-AGT-02-05.
3. What you must know first¶
- Pydantic V2 generates JSON Schema:
Model.model_json_schema()emits standard JSON Schema Draft 2020-12 representations from typed models; explicitField(description=...)annotations directly shape model tool-calling accuracy (Pydantic: JSON Schema). - MCP naming conventions: Tool names should be 1-128 characters from ASCII letters, digits, underscore, hyphen, and dot (
^[A-Za-z0-9_.-]{1,128}$); namespace names as${DOMAIN}.${ACTION}to prevent collisions across multiple MCP servers (MCP specification 2026-07-28: Tools). - Protocol vs. execution errors (
isError): Protocol errors (unknown tool, malformed request, server error) are returned as JSON-RPC errors. Tool execution errors (API failures, input validation errors, business-logic errors such as a missing record) are returned as a normal tool result withisError: trueand the message in a content text block, so the model can self-correct (MCP specification 2026-07-28: Tools). - Output schema: Declaring an outputSchema lets clients validate structured results; if one is declared, the server must return structuredContent that conforms to it (MCP specification 2026-07-28: Tools).
- Machine-readable error contracts: Always supply an error code, human-readable guidance, and an explicit
retryable: boolflag to prevent the LLM from entering infinite retry loops on non-recoverable errors. - LangGraph tool binding: Convert registered tool specifications into LangChain
StructuredToolinstances usingStructuredTool.from_function(); bind them to chat models viamodel.bind_tools()and execute them within LangGraph usingToolNode(LangGraph: ToolNode and tool binding).
4. Prerequisites and access¶
- Python 3.10 or higher with
pydantic>=2.0.0,langgraph>=0.2.0, andlangchain-core>=0.3.0. - Access to the target downstream service API and sandbox credentials (service principal or API token).
- Agreed tool naming convention and authorization scope taxonomy (e.g.
orders:read,orders:write). - Registry package installed, and the tool owner and scopes agreed.
5. Steps¶
Follow these steps to define, register, and bind a tool to LangGraph:
-
Establish tool name and semantic version. Name the tool using the pattern
${DOMAIN}.${ACTION}and set an initial semantic version (e.g.orders.lookup@1.0.0). Check: Verify the name matches the regex pattern^[A-Za-z0-9_.-]{1,128}$. Also confirm the model-facing name, with dots replaced by underscores, is 64 characters or fewer and does not collide with another registered tool. -
Define the Pydantic input schema with strict boundaries. Create a Pydantic
BaseModelfor all tool inputs. Annotate every field with types, bounds (ge,le,min_length,max_length), and clear descriptions instructing the LLM on required formatting. Check: RunInputModel.model_json_schema()and verify all constraints appear in the generated schema dictionary. A second engineer reviews the generated schema and every field description before registration. -
Define the output schema and structured content. Create a corresponding output
BaseModelrepresenting the successful return payload. Check: Confirm the output model serialises to clean JSON dictionaries without raw objects. -
Implement the error contract. Define standard error payloads with
code,message,detail, and aretryableboolean flag. Ensure business logic catches downstream exceptions and maps them to structured error dictionaries withisError: true. When the tool is exposed over MCP, return the error as a tool result:isError: true, with the message, code and retryable serialised into a content text block.retryableis our registry convention, not an MCP field. If you also returnstructuredContent, it must conform to the tool'soutputSchema. Check: Unit test downstream failure cases and confirm they return{"isError": True, "error": {"code": "...", "retryable": False}}instead of throwing unhandled exceptions. -
Register the tool in the central registry. Instantiate a
ToolSpeccontaining the tool metadata, input model, output model, required scopes, side-effect classification, and execution handler. Register it in the central registry table. Check: Query the registry by key and confirm the tool metadata and handler are retrievable. -
Bind the registered tool to a LangGraph agent. Convert the
ToolSpecinto a LangChainStructuredToolviaStructuredTool.from_function(). Bind the tool to the chat model usingmodel.bind_tools()and attach aToolNodeto the graph. Check: Inspectmodel_with_tools.kwargs["tools"]and verify the generated function schema matches the Pydantic model. -
Execute unit tests for input validation, execution, and error states. Write automated test cases verifying valid parameter execution, schema rejection of invalid parameters, recoverable retryable errors, and non-recoverable errors. Check: Run pytest and verify 100% pass rate across the contract tests.
Draft snippet: Pydantic tool spec, registry and LangGraph binding¶
from typing import Callable, Literal, TypedDict
from langchain_core.messages import AIMessage, HumanMessage
from langchain_core.tools import StructuredTool
from langgraph.graph import END, START, MessagesState, StateGraph
from langgraph.prebuilt import ToolNode, tools_condition
from pydantic import BaseModel, Field, ValidationError
# 1. Pydantic input schema with strict validation
class LookupOrderInput(BaseModel):
order_id: str = Field(
min_length=6,
max_length=20,
pattern=r"^ORD-[0-9]{4,8}$",
description="Unique order reference in format 'ORD-XXXX'",
)
# 2. Output schema
class OrderOutput(BaseModel):
order_id: str
status: str
items_count: int
total_amount: float
# 3. Tool registry and specification
class ToolSpec(BaseModel):
name: str = Field(pattern=r"^[A-Za-z0-9_.-]{1,128}$")
version: str
description: str
owner: str
scopes: list[str]
is_side_effect: bool
input_model: type[BaseModel]
handler: Callable[[dict], dict]
REGISTRY: dict[str, ToolSpec] = {}
def register_tool(spec: ToolSpec) -> None:
key = f"{spec.name}@{spec.version}"
if key in REGISTRY:
raise ValueError(f"Tool '{key}' already registered. Bump the version.")
REGISTRY[key] = spec
# 4. Tool implementation with error contract
def lookup_order_handler(params: dict) -> dict:
order_id = params.get("order_id")
# Simulated downstream business logic
if order_id == "ORD-9999":
# Non-retryable business failure
return {
"isError": True,
"error": {
"code": "order_not_found",
"message": f"Order {order_id} does not exist. Do not retry.",
"retryable": False,
},
}
# Successful response
return {
"isError": False,
"result": {
"order_id": order_id,
"status": "shipped",
"items_count": 2,
"total_amount": 149.50,
},
}
# Register tool
order_tool_spec = ToolSpec(
name="orders.lookup",
version="1.0.0",
description="Look up order status and shipping details by order reference",
owner="OrderPlatformTeam",
scopes=["orders:read"],
is_side_effect=False,
input_model=LookupOrderInput,
handler=lookup_order_handler,
)
register_tool(order_tool_spec)
# 5. Catalog binding: Convert registered tool into LangChain StructuredTool
def create_langchain_tool(spec: ToolSpec) -> StructuredTool:
return StructuredTool.from_function(
func=spec.handler,
name=spec.name.replace(".", "_"), # Ensure model-compatible naming
description=spec.description,
args_schema=spec.input_model,
return_direct=False,
)
langchain_order_tool = create_langchain_tool(order_tool_spec)
tools = [langchain_order_tool]
# 6. Bind to LangGraph agent with ToolNode
workflow = StateGraph(MessagesState)
def mock_model_node(state: MessagesState) -> dict:
# Simulates an LLM proposing a tool call
return {
"messages": [
AIMessage(
content="",
tool_calls=[
{
"name": "orders_lookup",
"args": {"order_id": "ORD-1234"},
"id": "call_tx_001",
}
],
)
]
}
workflow.add_node("agent", mock_model_node)
workflow.add_node("tools", ToolNode(tools))
workflow.add_edge(START, "agent")
workflow.add_conditional_edges("agent", tools_condition)
workflow.add_edge("tools", END)
app = workflow.compile()
output = app.invoke({"messages": [HumanMessage(content="Check order ORD-1234")]})
print("Tool execution result:", output["messages"][-1].content)
Snippets to add:
snippets/registry/core.py: Registry, ToolSpec, and error contract engine (python)snippets/registry/schemas/orders.lookup.json: Exported JSON Schema for tool integration (json)snippets/tests/test_tool_contract.py: Contract test suite exercising input validation, execution, and error formats (python)
6. Verify it works¶
To verify the tool implementation and LangGraph integration:
- Schema Generation Check:
Call
InputModel.model_json_schema(). Confirm all fields, types, regex patterns, and descriptions match the specification. - Valid Input Execution Check:
Execute the tool with valid arguments
{"order_id": "ORD-1234"}. Confirm it returnsisError: Falsewith populated result fields. - Invalid Input Validation Check:
Pass invalid arguments
{"order_id": "bad-id"}. Confirm Pydantic raises aValidationErroror the tool handler catches it and returns a structured validation error. - Non-Retryable Error Contract Check:
Pass an unknown order
{"order_id": "ORD-9999"}. Confirm the tool returnsisError: Truewithretryable: Falseand error codeorder_not_found. - LangGraph Agent Binding Check:
Execute the compiled LangGraph workflow. Verify that the agent routes to
ToolNode, executes the registered tool, and returns the structured result in the message history.
7. Known issues and fixes¶
| Symptom | Likely cause | Fix |
|---|---|---|
| Model sends amounts as strings, wrong units or empty arguments | Schema too loose or field descriptions missing | Use precise types and bounds, put units in field names (for example amount_minor), and add formatting examples in Field(description=...) |
| Agent loops continuously retrying a failed tool call | Tool returned error as unstructured text without retryable: False |
Standardize error response to include isError: True and "retryable": False |
| Name collision when registering tools from multiple sources | Multiple MCP servers expose identical unqualified tool names | Prefix tool names with domain namespace (${DOMAIN}.${ACTION}) |
| Tool call fails during LangGraph execution with naming error | Tool name contains dots or characters unsupported by model APIs | Sanitise dots to underscores when creating the LangChain StructuredTool, keep the result to 64 characters or fewer, and reject registrations whose sanitised name collides with an existing tool. |
| PII or API tokens leaked in error detail | Raw system traceback or upstream payload returned in error message | Sanitize error output and return generic code with scrubbed error message |
8. Monitoring and alerts¶
| Signal | Alert when | Action |
|---|---|---|
| Tool input validation failure rate | Greater than 5% of calls over 1 hour | Review LLM prompt instructions and refine field descriptions in Pydantic schema |
Tool execution error rate (isError: true) |
Above the per-tool baseline (threshold TO VERIFY from sandbox and early production data) | Check downstream dependency availability and network latency |
| Unhandled tool runtime exceptions (HTTP 500) | Greater than 0 in any window | Investigate missing exception handlers in tool implementation |
| Model retries on non-retryable errors | Greater than 0 occurrences | Verify retryable: False is properly formatted in the error payload |
9. AWS and open-source equivalents¶
- AWS: AgentCore Gateway can turn OpenAPI, Smithy and Lambda definitions into MCP tools with managed auth: OAuth inbound, and IAM, API key or OAuth outbound depending on target type.
- Open source: FastMCP or the official MCP SDKs consume the same JSON Schemas.
- LangChain / Semantic Kernel: LangChain provides
StructuredTool.from_function()for schema wrapping; Microsoft Semantic Kernel exposes@kernel_functiondecorated methods with typed parameter annotations.
10. Validation record¶
Pending. Planned dry-run scenario: A second engineer adds a new read-only tool using only the playbook, and passes when the schema is generated, the tool appears in the agent's tool list, and the three contract tests pass.