Tool Dispatch and Action Validation
From tool-call request to function execution: dispatch pipelines, MCP discovery, and permission boundaries
Learning Objectives
- Trace the complete tool dispatch pipeline from parsing through execution to result formatting
- Distinguish between static tool registries and MCP-based dynamic capability discovery
- Explain how parameter validation and permission boundaries protect execution safety
- Compare parallel and sequential tool execution strategies and their tradeoffs
- Analyze how error capture and result formatting enable model recovery from failures
Prerequisites
- Module 3 (Tool Use & Function Calling) — tool calling semantics from the model's perspective
- Module 5, Lesson 2 (Agent Loops) — turn lifecycle and control flow
Overview
In Module 3 (Tool Use & Function Calling), we examined tool use from the model's perspective: how the model generates structured tool calls, how tools are described through schemas, and how results flow back as observations. But that module treated tool execution as a black box — the model requests a tool, and somehow, a result appears.
This lesson opens that black box. We're now inside the agent harness, where tool dispatch is the critical bridge between what the model requests and what the environment provides. The harness is responsible for:
- Parsing tool calls from model output
- Validating parameters against schemas
- Enforcing permission boundaries
- Executing tools safely and capturing results
- Formatting results for the model's next turn
Tool dispatch is not a single function call. It's a pipeline with distinct stages, each of which can succeed or fail, and each of which contributes to the safety, reliability, and observability of agent execution.
We'll also explore two fundamentally different approaches to tool availability: static registries (where tools are hardcoded into the harness) and MCP-based dynamic discovery (where tools are discovered at runtime from external systems).
The Dispatch Pipeline
When the model generates a tool call, the harness processes it through a multi-stage pipeline:
The dispatch pipeline: parse → validate → permissions → execute → capture → format → append to context.
Let's walk through each stage:
1. Parse Tool Call
The model's output includes a structured representation of the tool call. The harness parses this to extract:
- Tool name (e.g.,
web_search,calculator,read_file) - Parameters (e.g.,
{"query": "MCP protocol", "max_results": 5}) - Tool call ID (for tracking multiple concurrent calls)
Modern APIs (like Claude's tool use API) return tool calls as JSON objects, so parsing is straightforward. Older systems that relied on text parsing (e.g., "Action: search\nAction Input: MCP protocol") required fragile regex-based extraction.
2. Validate Parameters
Before executing a tool, the harness validates the parameters against the tool's JSON Schema:
- Are all required parameters present?
- Do parameter types match (string, integer, array, etc.)?
- Do values satisfy constraints (min/max, enum values, regex patterns)?
If validation fails, the harness does not execute the tool. Instead, it returns a structured error to the model, which can retry with corrected parameters.
3. Check Permissions
Not every tool should be available in every context. The harness enforces permission boundaries:
- Is this tool allowed in the current session?
- Does the user or policy permit this tool?
- Are there runtime constraints (e.g., rate limits, quota)?
Permission checks can be:
- Static (e.g., "this agent can only read files, not write them")
- Dynamic (e.g., "web search is allowed until the daily quota is exhausted")
- Policy-driven (e.g., "file writes require user confirmation")
4. Execute Tool
If validation and permissions pass, the harness executes the tool. Execution can involve:
- Calling a local function (e.g., a Python function in the harness)
- Invoking an external API (e.g., a web search service)
- Running a subprocess (e.g., executing a shell command)
- Dispatching to an MCP server (more on this below)
The harness sets timeouts to prevent tools from hanging indefinitely.
5. Capture Result/Error
Tools can succeed, fail, or timeout. The harness captures:
- Success: The tool's return value (structured data, text, or binary)
- Failure: Exception details, error messages, stack traces
- Timeout: A special error indicating the tool exceeded its time budget
The harness does not crash when a tool fails. Instead, it treats failures as observations — data the model can reason about.
6. Format for Model
The raw result or error is formatted into a tool result message:
{
"type": "tool_result",
"tool_use_id": "toolu_abc123",
"content": "Search results: ..."
}
Or, for errors:
{
"type": "tool_result",
"tool_use_id": "toolu_abc123",
"is_error": true,
"content": "Error: InvalidParameterException - 'max_results' must be between 1 and 100"
}
This structured format allows the model to distinguish success from failure and to correlate results with specific tool calls.
7. Append to Context
The formatted result is appended to the conversation context as a tool_result message, which becomes part of the input to the next model turn.
The Dispatch Table
How does the harness know which code to execute when the model calls a tool? The simplest approach is a dispatch table — a mapping from tool names to implementations.
Here's a minimal example in Python:
import jsonschema
from typing import Callable, Dict, Any
class ToolRegistry:
def __init__(self):
self.tools: Dict[str, Dict[str, Any]] = {}
def register(self, name: str, fn: Callable, schema: Dict[str, Any]):
"""Register a tool with its implementation and parameter schema."""
self.tools[name] = {
"fn": fn,
"schema": schema
}
def dispatch(self, tool_name: str, params: Dict[str, Any]) -> Any:
"""Validate and execute a tool."""
if tool_name not in self.tools:
raise ValueError(f"Unknown tool: {tool_name}")
tool = self.tools[tool_name]
# Validate parameters against schema
try:
jsonschema.validate(params, tool["schema"])
except jsonschema.ValidationError as e:
raise ValueError(f"Parameter validation failed: {e.message}")
# Execute the tool
return tool["fn"](**params)
# Example usage
registry = ToolRegistry()
# Register a calculator tool
def calculator(expression: str) -> float:
# Safely evaluate math expressions
return eval(expression, {"__builtins__": {}}, {})
calculator_schema = {
"type": "object",
"properties": {
"expression": {"type": "string"}
},
"required": ["expression"]
}
registry.register("calculator", calculator, calculator_schema)
# Dispatch a tool call
result = registry.dispatch("calculator", {"expression": "2 + 2"})
# result: 4
This pattern scales well for small, stable tool sets. However, it has limitations:
- Tools must be known at harness compile time
- Adding tools requires code changes
- Different users or sessions can't have different tool sets without complex branching logic
Parameter Validation
Parameter validation is the harness's first line of defense against malformed tool calls. JSON Schema provides a declarative way to specify:
- Types:
string,number,integer,boolean,array,object - Constraints:
minimum,maximum,minLength,maxLength,pattern,enum - Composition:
anyOf,oneOf,allOf,not
Example: A web_search tool might require:
web_search_schema = {
"type": "object",
"properties": {
"query": {
"type": "string",
"minLength": 1,
"maxLength": 500
},
"max_results": {
"type": "integer",
"minimum": 1,
"maximum": 100,
"default": 10
},
"language": {
"type": "string",
"enum": ["en", "es", "fr", "de", "ja"],
"default": "en"
}
},
"required": ["query"]
}
When the model calls web_search with {"query": "MCP", "max_results": 500}, validation fails because max_results exceeds 100. The harness returns:
{
"type": "tool_result",
"tool_use_id": "toolu_abc123",
"is_error": true,
"content": "ValidationError: 500 is greater than the maximum of 100 (path: max_results)"
}
The model sees this error in the next turn and can retry with {"query": "MCP", "max_results": 10}.
Why validate when the model knows the schema? Models are stochastic and can produce malformed outputs, especially under token pressure or with complex schemas. Validation ensures that only well-formed tool calls reach execution.
Permission Boundaries
Permission boundaries determine which tools an agent can use and under what conditions. This is distinct from parameter validation — a tool call can be well-formed but still forbidden.
graph TD
TC["Tool Call Received"] --> AL{"On allow-list?"}
AL -->|no| DENY1["Reject: tool not permitted"]
AL -->|yes| DL{"On deny-list?"}
DL -->|yes| DENY2["Reject: tool blocked"]
DL -->|no| QUOTA{"Quota remaining?"}
QUOTA -->|no| DENY3["Reject: quota exhausted"]
QUOTA -->|yes| RISK{"High-risk tool?"}
RISK -->|yes| CONFIRM{"User confirms?"}
CONFIRM -->|no| DENY4["Reject: user denied"]
CONFIRM -->|yes| EXEC["Execute tool"]
RISK -->|no| EXEC
style EXEC fill:#e8f5e9,stroke:#388e3c
style DENY1 fill:#ffebee,stroke:#c62828
style DENY2 fill:#ffebee,stroke:#c62828
style DENY3 fill:#ffebee,stroke:#c62828
style DENY4 fill:#ffebee,stroke:#c62828
Static Permissions
A static permission list hardcodes allowed tools:
class PermissionedDispatcher:
def __init__(self, registry: ToolRegistry, allowed_tools: set[str]):
self.registry = registry
self.allowed_tools = allowed_tools
def dispatch(self, tool_name: str, params: Dict[str, Any]) -> Any:
if tool_name not in self.allowed_tools:
raise PermissionError(f"Tool '{tool_name}' is not permitted")
return self.registry.dispatch(tool_name, params)
# Example: a read-only agent
readonly_dispatcher = PermissionedDispatcher(
registry=registry,
allowed_tools={"read_file", "list_directory", "web_search"}
)
Dynamic Policy Evaluation
More sophisticated harnesses evaluate permissions based on:
- User identity: Different users have different tool sets
- Session context: Tools may be enabled only during specific workflows
- Resource quotas: Tools may be rate-limited or have daily caps
- User confirmation: High-risk tools require explicit user approval before execution
Example: A tool that deletes files might require user confirmation:
def dispatch_with_confirmation(tool_name: str, params: Dict[str, Any]) -> Any:
if tool_name in HIGH_RISK_TOOLS:
confirmation = request_user_confirmation(
f"Allow {tool_name} with params {params}?"
)
if not confirmation:
raise PermissionError("User denied tool execution")
return registry.dispatch(tool_name, params)
Warning
Security principle: Permission checks must happen before execution, not after. A tool that checks permissions inside its implementation can be bypassed by direct invocation.
Parallel vs Sequential Tool Execution
When the model generates multiple tool calls in a single turn, the harness must decide: execute them in parallel or sequentially?
Sequential Execution
Sequential execution processes tool calls one at a time, in the order they appear:
def execute_sequential(tool_calls: list[ToolCall]) -> list[ToolResult]:
results = []
for call in tool_calls:
result = dispatch(call.tool_name, call.params)
results.append(result)
return results
Advantages:
- Simple to implement and reason about
- Guarantees ordering (tool 2 sees the effects of tool 1)
- Easier to debug (clear cause-and-effect)
Disadvantages:
- Slower when tools have high latency (e.g., API calls)
- Underutilizes resources when tools are I/O-bound
Parallel Execution
Parallel execution runs independent tool calls concurrently:
import concurrent.futures
def execute_parallel(tool_calls: list[ToolCall]) -> list[ToolResult]:
with concurrent.futures.ThreadPoolExecutor() as executor:
futures = [
executor.submit(dispatch, call.tool_name, call.params)
for call in tool_calls
]
results = [f.result() for f in futures]
return results
Advantages:
- Faster when tools are I/O-bound or have high latency
- Better resource utilization
Disadvantages:
- Tools must be independent (no shared state, no ordering dependencies)
- Harder to debug (non-deterministic execution order)
- Requires careful handling of shared resources (e.g., file writes)
Hybrid Approach
Production harnesses often use a hybrid strategy:
- Tools marked as independent (e.g.,
web_searchandcalculator) run in parallel - Tools with dependencies (e.g.,
read_filefollowed bywrite_file) run sequentially - The harness analyzes tool call patterns to detect dependencies (e.g., if tool 2's parameters reference tool 1's result)
Design principle: Default to sequential execution for correctness, and only parallelize when you can prove independence.
Error Capture and Result Formatting
When a tool fails, the harness must decide: should the agent loop terminate, or should the error become an observation?
Production harnesses treat errors as data, not crashes. This enables model-driven error recovery:
def safe_dispatch(tool_name: str, params: Dict[str, Any]) -> ToolResult:
try:
result = dispatch(tool_name, params)
return ToolResult(
tool_use_id=params["tool_use_id"],
is_error=False,
content=result
)
except ValidationError as e:
return ToolResult(
tool_use_id=params["tool_use_id"],
is_error=True,
content=f"ValidationError: {e.message}"
)
except PermissionError as e:
return ToolResult(
tool_use_id=params["tool_use_id"],
is_error=True,
content=f"PermissionError: {e}"
)
except TimeoutError:
return ToolResult(
tool_use_id=params["tool_use_id"],
is_error=True,
content="Tool execution timed out"
)
except Exception as e:
return ToolResult(
tool_use_id=params["tool_use_id"],
is_error=True,
content=f"UnexpectedError: {type(e).__name__}: {e}"
)
The model receives the error in its next turn:
System: Tool result for toolu_abc123 (calculator)
Error: ValidationError: 'expression' is required
The model can then reason about the error and retry:
I need to include the 'expression' parameter. Let me try again with the correct schema.
This error-as-observation pattern is critical for robustness — the agent can recover from transient failures, malformed inputs, and resource exhaustion without human intervention.
MCP: Dynamic Capability Discovery
So far, we've assumed tools are statically registered in the harness at compile time. But what if:
- Tools are provided by external systems with evolving APIs?
- Different users need different tool sets?
- Tools are too numerous to hardcode (e.g., thousands of enterprise APIs)?
Model Context Protocol (MCP) solves this by treating tools as dynamically discoverable capabilities.
What is MCP?
MCP is a protocol for connecting AI applications (like agent harnesses) to external systems (like databases, APIs, or internal tools). It defines how to:
- List tools: Query an MCP server for available tools and their schemas
- Invoke tools: Call a tool with parameters and receive a result
- Negotiate capabilities: Discover what the server supports (tools, resources, prompts)
- Handle errors: Return structured errors for invalid calls or server failures
- Enforce security: Authenticate clients and enforce access control
MCP is not just a tool dispatcher — it's a standard interface for capability discovery and invocation. Think of it as "USB for AI" — a universal connector that lets harnesses plug into arbitrary systems without custom integration code.
Static Registry vs MCP Discovery
Static registry: tools hardcoded in the harness. MCP discovery: tools provided by external servers, discovered at runtime.
How MCP Dispatch Works
When the model calls a tool via MCP:
-
Tool discovery (happens once per session):
- Harness connects to MCP server
- Server returns list of available tools with schemas
- Harness registers these tools in its runtime registry
-
Tool invocation (happens per tool call):
- Harness validates parameters against the MCP-provided schema
- Harness sends tool call to MCP server via JSON-RPC
- Server executes the tool and returns result or error
- Harness formats result for the model
Here's a simplified example:
import mcpclient
class MCPDispatcher:
def __init__(self, server_url: str):
self.client = mcpclient.Client(server_url)
self.tools = {}
self._discover_tools()
def _discover_tools(self):
"""Query the MCP server for available tools."""
response = self.client.list_tools()
for tool in response["tools"]:
self.tools[tool["name"]] = tool["schema"]
def dispatch(self, tool_name: str, params: Dict[str, Any]) -> Any:
"""Validate and invoke a tool via MCP."""
if tool_name not in self.tools:
raise ValueError(f"Unknown tool: {tool_name}")
schema = self.tools[tool_name]
jsonschema.validate(params, schema)
# Invoke tool via MCP JSON-RPC
response = self.client.call_tool(tool_name, params)
if response.get("error"):
raise RuntimeError(response["error"]["message"])
return response["result"]
# Example: Connect to a database MCP server
db_dispatcher = MCPDispatcher("http://localhost:8080/mcp")
result = db_dispatcher.dispatch("query_db", {"sql": "SELECT * FROM users LIMIT 10"})
When to Use MCP
Use MCP when:
- External systems: Tools are provided by external services you don't control
- Dynamic tool sets: Tools vary per user, session, or deployment
- Security boundaries: Tools require authentication or fine-grained access control
- Evolving APIs: Tool schemas change frequently, and you don't want to redeploy the harness
Use static registries when:
- Simple, stable tools: Tools are local functions that rarely change
- Low latency: You need minimal overhead per tool call
- Full control: You own the tool implementations and schemas
Structured Tool Calls vs Text Parsing
Early agent systems (like ReAct) relied on text parsing to extract tool calls from free-form model output:
Thought: I need to search for information about MCP.
Action: search
Action Input: Model Context Protocol
The harness would parse this text to extract tool_name="search" and params={"query": "Model Context Protocol"}.
This approach is fragile:
- Parsing failures: Models might format the output incorrectly (e.g., missing newlines, extra whitespace)
- Ambiguity: Is
Action Input: 2 + 2a math expression or a search query? - No validation: The harness can't validate parameters until after parsing
Modern systems use structured tool calls with typed schemas:
{
"type": "tool_use",
"id": "toolu_abc123",
"name": "web_search",
"input": {
"query": "Model Context Protocol",
"max_results": 5
}
}
This is:
- Unambiguous: Clear separation between tool name and parameters
- Validatable: Parameters can be checked against JSON Schema before parsing
- Robust: No risk of parsing failures due to formatting issues
Best practice: Always use structured tool calls in production harnesses. Text parsing is a historical artifact, not a design pattern.
Case Study: Dispatching Search and Calculator
Let's trace two tool calls through the full dispatch pipeline: web_search (which succeeds) and calculator (which fails validation, then succeeds on retry).
Turn 1: Web Search
Model output:
{
"type": "tool_use",
"id": "toolu_001",
"name": "web_search",
"input": {
"query": "agent harness architecture",
"max_results": 5
}
}
Dispatch pipeline:
- Parse: Extract
tool_name="web_search",params={"query": "...", "max_results": 5} - Validate: Check params against schema → ✅ Pass (all required fields present, types correct)
- Permissions: Check if
web_searchis allowed → ✅ Pass - Execute: Call
web_search("agent harness architecture", max_results=5)→ Returns 5 search results - Capture: Wrap result in
ToolResult(is_error=False, content="...") - Format: Create
tool_resultmessage with results - Append: Add to context for next turn
Result to model:
{
"type": "tool_result",
"tool_use_id": "toolu_001",
"content": "Found 5 results:\n1. Agent Harness Design Patterns\n2. Building Production Agent Systems\n..."
}
Turn 2: Calculator (First Attempt)
Model output:
{
"type": "tool_use",
"id": "toolu_002",
"name": "calculator",
"input": {}
}
Dispatch pipeline:
- Parse: Extract
tool_name="calculator",params={} - Validate: Check params against schema → ❌ Fail (
expressionis required but missing) - Capture: Wrap validation error in
ToolResult(is_error=True, content="...") - Format: Create
tool_resultmessage with error - Append: Add error to context
Result to model:
{
"type": "tool_result",
"tool_use_id": "toolu_002",
"is_error": true,
"content": "ValidationError: 'expression' is a required property"
}
Turn 3: Calculator (Retry)
Model output:
{
"type": "tool_use",
"id": "toolu_003",
"name": "calculator",
"input": {
"expression": "2 + 2"
}
}
Dispatch pipeline:
- Parse: Extract
tool_name="calculator",params={"expression": "2 + 2"} - Validate: Check params against schema → ✅ Pass
- Permissions: Check if
calculatoris allowed → ✅ Pass - Execute: Call
calculator("2 + 2")→ Returns4 - Capture: Wrap result in
ToolResult(is_error=False, content="4") - Format: Create
tool_resultmessage - Append: Add to context
Result to model:
{
"type": "tool_result",
"tool_use_id": "toolu_003",
"content": "4"
}
This illustrates the power of error-as-observation: the model sees the validation failure, understands what went wrong, and corrects its input — all without human intervention or harness-level retry logic.
Summary
Tool dispatch is the execution engine of the agent loop. It bridges the model's abstract requests (tool calls) with concrete execution (API calls, file operations, computations). The dispatch pipeline — parse, validate, permissions, execute, capture, format — ensures that tool execution is safe, observable, and recoverable.
We've seen two architectural approaches:
- Static registries: Tools are hardcoded at compile time. Simple, fast, but inflexible.
- MCP dynamic discovery: Tools are discovered at runtime from external servers. More complex, but enables per-user tool sets, external system integration, and evolving APIs.
We've also explored the tradeoffs in parallel vs sequential execution and the critical role of error capture in enabling model-driven recovery.
But dispatch is only half the story. Tools produce results, but where do those results go? How does the harness decide which tools to show the model, which results to prioritize, and when to stop calling tools?
In Lesson 4 (Context, Goals, and State), we'll examine how the harness manages the context window (what the model can see), goal tracking (what the agent is trying to accomplish), and state (what has happened so far). These are the control mechanisms that turn tool dispatch from a reactive pipeline into a goal-directed system.
Key Terms
- Dispatch pipeline
- The multi-stage process of parsing, validating, permissioning, executing, and formatting tool calls.
- Dispatch table
- A mapping from tool names to implementations, used for static tool registration.
- Parameter validation
- Checking tool call parameters against JSON Schema before execution to ensure type safety and constraint satisfaction.
- Permission boundaries
- Rules that determine which tools an agent can invoke, based on static lists, user identity, or dynamic policies.
- Parallel execution
- Running multiple independent tool calls concurrently to reduce latency.
- Sequential execution
- Running tool calls one at a time, in order, to guarantee consistent execution and simplify debugging.
- Error capture
- Treating tool failures as observations rather than crashes, enabling model-driven error recovery.
- MCP (Model Context Protocol)
- A protocol for connecting AI applications to external systems through dynamic capability discovery.
- Structured tool calls
- Tool invocations represented as typed JSON objects, as opposed to free-form text parsed by regex.
- Error-as-observation
- The design pattern where tool failures are returned to the model as data, allowing the model to reason about and recover from errors.