Skip to content
⚠️ Before You Start
Risk: Medium
Duration: 2-4 hours per tool
Last Validated: Not yet validated (draft)
Kind: How-to
Risk Note: Medium (weak schemas let the model send bad or dangerous arguments)

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; explicit Field(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 with isError: true and 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: bool flag to prevent the LLM from entering infinite retry loops on non-recoverable errors.
  • LangGraph tool binding: Convert registered tool specifications into LangChain StructuredTool instances using StructuredTool.from_function(); bind them to chat models via model.bind_tools() and execute them within LangGraph using ToolNode (LangGraph: ToolNode and tool binding).

4. Prerequisites and access

  • Python 3.10 or higher with pydantic>=2.0.0, langgraph>=0.2.0, and langchain-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:

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

  2. Define the Pydantic input schema with strict boundaries. Create a Pydantic BaseModel for 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: Run InputModel.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.

  3. Define the output schema and structured content. Create a corresponding output BaseModel representing the successful return payload. Check: Confirm the output model serialises to clean JSON dictionaries without raw objects.

  4. Implement the error contract. Define standard error payloads with code, message, detail, and a retryable boolean flag. Ensure business logic catches downstream exceptions and maps them to structured error dictionaries with isError: 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. retryable is our registry convention, not an MCP field. If you also return structuredContent, it must conform to the tool's outputSchema. Check: Unit test downstream failure cases and confirm they return {"isError": True, "error": {"code": "...", "retryable": False}} instead of throwing unhandled exceptions.

  5. Register the tool in the central registry. Instantiate a ToolSpec containing 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.

  6. Bind the registered tool to a LangGraph agent. Convert the ToolSpec into a LangChain StructuredTool via StructuredTool.from_function(). Bind the tool to the chat model using model.bind_tools() and attach a ToolNode to the graph. Check: Inspect model_with_tools.kwargs["tools"] and verify the generated function schema matches the Pydantic model.

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

  1. Schema Generation Check: Call InputModel.model_json_schema(). Confirm all fields, types, regex patterns, and descriptions match the specification.
  2. Valid Input Execution Check: Execute the tool with valid arguments {"order_id": "ORD-1234"}. Confirm it returns isError: False with populated result fields.
  3. Invalid Input Validation Check: Pass invalid arguments {"order_id": "bad-id"}. Confirm Pydantic raises a ValidationError or the tool handler catches it and returns a structured validation error.
  4. Non-Retryable Error Contract Check: Pass an unknown order {"order_id": "ORD-9999"}. Confirm the tool returns isError: True with retryable: False and error code order_not_found.
  5. 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_function decorated 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.

11. Sources