LangGraph Integration
Per-invocation mocking via RunnableConfig—no environment variables, no global state, fully concurrent.
StuntDouble uses LangGraph’s native ToolNode with an awrap_tool_call wrapper for per-invocation mocking.
Overview
┌─────────────────────────────────────────────────────────────────────────────┐
│ LangGraph Per-Invocation Flow │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ graph.invoke( │
│ state, ┌─────────────────────────┐ │
│ config={ │ Check scenario_metadata │ │
│ "configurable": { └───────────┬─────────────┘ │
│ "scenario_metadata": {...} │ │
│ } ┌────────┴────────┐ │
│ } ▼ ▼ │
│ ) Has mocks? No mocks? │
│ │ │ │
│ ▼ ▼ │
│ Return MOCK Call REAL tool │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
Benefit |
Description |
|---|---|
✅ Concurrent-safe |
Each invocation has its own mocks |
✅ No global state |
No environment variables or singletons |
✅ Production-ready |
Same graph handles mock and real traffic |
✅ Flexible |
Different mocks per request |
ToolNode Wrapper
Uses LangGraph’s native ToolNode with StuntDouble’s awrap_tool_call wrapper.
Prerequisites
The ToolNode Wrapper requires the awrap_tool_call parameter on ToolNode, which was introduced in LangGraph 1.0. Make sure your dependencies meet these minimum versions:
Package |
Minimum Version |
Why |
|---|---|---|
|
>=1.0.0 |
Provides |
|
>=1.2.5 |
Required by StuntDouble for |
# Verify your versions
pip show langgraph langchain-core
# Upgrade if needed
pip install --upgrade "langgraph>=1.0.0" "langchain-core>=1.2.5"
# Or with uv
uv add "langgraph>=1.0.0" "langchain-core>=1.2.5"
# Or with Poetry
poetry add "langgraph>=1.0.0" "langchain-core>=1.2.5"
See Dependencies Reference for full compatibility details.
Option A: Default Registry (Simplest) ⭐
Use the pre-configured mockable_tool_wrapper and default_registry for zero-setup mocking:
from langgraph.graph import StateGraph, MessagesState, START
from langgraph.prebuilt import ToolNode, tools_condition
from langchain_core.messages import HumanMessage
from stuntdouble import (
mockable_tool_wrapper, # Pre-configured wrapper
default_registry, # Default mock registry
inject_scenario_metadata, # Config helper
)
# Your real tools (production code unchanged)
tools = [get_customer_tool, list_bills_tool, create_invoice_tool]
# Step 1: Register mocks on the default registry
default_registry.mock("get_customer").returns({
"id": "CUST-001",
"name": "Test Corp",
"balance": 1500,
})
default_registry.mock("list_bills").returns({
"bills": [{"id": "B001", "amount": 500}]
})
# Step 2: Build graph with native ToolNode + mockable wrapper
builder = StateGraph(MessagesState)
builder.add_node("agent", agent_node)
builder.add_node("tools", ToolNode(tools, awrap_tool_call=mockable_tool_wrapper)) # ← Native ToolNode!
builder.add_edge(START, "agent")
builder.add_conditional_edges("agent", tools_condition)
builder.add_edge("tools", "agent")
graph = builder.compile()
# Step 3: Invoke WITH mocks
config = inject_scenario_metadata({}, {
"scenario_id": "langgraph-default-registry-demo"
})
result = await graph.ainvoke({"messages": [HumanMessage("List my bills")]}, config=config)
# → Uses mocked get_customer / list_bills
# Step 4: Invoke WITHOUT mocks (no scenario_metadata = real tools)
result = await graph.ainvoke({"messages": [HumanMessage("List my bills")]})
# → Uses real list_bills tool
Using the fluent builder on default_registry (even simpler):
from stuntdouble import default_registry, mockable_tool_wrapper
mock = default_registry.mock # Convenience: mock("tool").returns(...)
# No registry parameter needed — uses default_registry automatically
mock("get_customer").returns({"id": "123", "name": "Test Corp"})
mock("list_bills").returns({"bills": [{"id": "B001", "amount": 500}]})
mock("get_invoice").when(status={"$in": ["paid", "pending"]}).returns({"priority": "low"})
# Verify registration
print(default_registry.list_registered()) # ['get_customer', 'list_bills', 'get_invoice']
# Use the pre-configured wrapper (reads from default_registry)
tool_node = ToolNode(tools, awrap_tool_call=mockable_tool_wrapper)
Option B: Custom Registry (Full Control)
For advanced scenarios where you need multiple registries, custom wrappers, call recording, or signature validation:
from typing import Any, Callable
from langgraph.graph import StateGraph, MessagesState, START
from langgraph.prebuilt import ToolNode, tools_condition
from langchain_core.messages import HumanMessage
from stuntdouble import (
MockToolsRegistry,
create_mockable_tool_wrapper,
inject_scenario_metadata,
)
# Your real tools
tools = [get_customer_tool, list_bills_tool]
# Step 1: Define mock functions
# Each mock_fn takes scenario_metadata and returns a callable that matches
# the real tool's signature (accepts the same arguments).
def get_customer_mock(scenario_metadata: dict[str, Any]) -> Callable[..., Any]:
"""
Mock for get_customer tool.
Real tool signature: get_customer(user_id: str) -> dict
"""
mocks = scenario_metadata.get("mocks", {})
mock_data = mocks.get("get_customer", [])
if isinstance(mock_data, list) and mock_data:
data = mock_data[0].get("output", {})
else:
data = mock_data if isinstance(mock_data, dict) else {}
# Mock callable matches real tool signature
def mock_fn(user_id: str) -> dict:
# Can use input values in response, or return static mock data
return data or {"id": user_id, "name": "Test Corp", "status": "active"}
return mock_fn
def list_bills_mock(scenario_metadata: dict[str, Any]) -> Callable[..., Any]:
"""
Mock for list_bills tool.
Real tool signature: list_bills(start_date: str, end_date: str) -> dict
"""
mocks = scenario_metadata.get("mocks", {})
mock_data = mocks.get("list_bills", [])
if isinstance(mock_data, list) and mock_data:
data = mock_data[0].get("output", {})
else:
data = {}
bills = data.get("bills", [])
# Mock callable matches real tool signature
def mock_fn(start_date: str, end_date: str) -> dict:
# Can filter based on input args if needed
return {"bills": bills, "start_date": start_date, "end_date": end_date}
return mock_fn
# Step 2: Create registry and register mock functions
registry = MockToolsRegistry()
registry.register("get_customer", mock_fn=get_customer_mock)
registry.register("list_bills", mock_fn=list_bills_mock)
# Step 3: Create wrapper and build graph with native ToolNode
wrapper = create_mockable_tool_wrapper(registry)
builder = StateGraph(MessagesState)
builder.add_node("agent", agent_node)
builder.add_node("tools", ToolNode(tools, awrap_tool_call=wrapper)) # ← Native ToolNode!
builder.add_edge(START, "agent")
builder.add_conditional_edges("agent", tools_condition)
builder.add_edge("tools", "agent")
graph = builder.compile()
# Step 4: Invoke WITH mocks
config = inject_scenario_metadata({}, {
"mocks": {
"list_bills": [{"output": {"bills": [{"id": "B001", "amount": 500}]}}]
}
})
result = await graph.ainvoke(
{"messages": [HumanMessage("Get customer CUST-001")]},
config=config
)
# Step 5: Invoke WITHOUT mocks (production mode - no scenario_metadata)
result = await graph.ainvoke(
{"messages": [HumanMessage("Get customer CUST-001")]}
)
API Reference
# LangGraph package exports
from stuntdouble import (
# Pre-configured wrapper and registry
mockable_tool_wrapper, # Ready-to-use awrap_tool_call wrapper
default_registry, # Default MockToolsRegistry instance
# Factory functions
create_mockable_tool_wrapper, # Create wrapper with custom registry
# Mock registration
MockToolsRegistry, # Factory-based mock registration
MockBuilder, # Chainable mock builder (also: from stuntdouble import MockBuilder)
# Fluent builder: mock = default_registry.mock (no standalone mock function)
# Call recording
CallRecorder, # Records tool calls for verification
CallRecord, # Individual call record
# Config utilities
inject_scenario_metadata, # Add scenario_metadata to config
get_scenario_metadata, # Extract from ToolCallRequest
get_configurable_context, # Extract configurable dict from RunnableConfig
# Validation
validate_mock_parameters, # Validate mock inputs match tool schema
validate_mock_signature, # Validate mock function signature matches tool
validate_registry_mocks, # Validate scenario_metadata mock cases
# Exceptions
MissingMockError, # Raised when mock not found in strict mode
SignatureMismatchError, # Raised when mock signature doesn't match tool
MockAssertionError, # Raised by CallRecorder assertions
)
Function |
Description |
|---|---|
|
Pre-configured wrapper for |
|
Default |
|
Create wrapper with custom registry, optional recorder, validation, and error-handling controls |
|
Convenience returning |
|
Chainable builder: |
|
Create a registry for mock functions |
|
Register a mock function. Pass |
|
Records tool calls for test assertions |
|
Individual call record with |
|
Create config with scenario_metadata |
|
Extract the |
|
Validate mock function signature matches tool |
|
Validate |
Troubleshooting
Mocks Not Working
Check you’re using the wrapper:
# Default registry approach from stuntdouble import mockable_tool_wrapper ToolNode(tools, awrap_tool_call=mockable_tool_wrapper) # ← Required! # Or custom registry approach wrapper = create_mockable_tool_wrapper(registry) ToolNode(tools, awrap_tool_call=wrapper) # ← Required!
Check scenario_metadata is passed:
config = inject_scenario_metadata({}, {"mocks": {...}}) result = await graph.ainvoke(state, config=config)
Check mock is registered:
# Default registry from stuntdouble import default_registry print(default_registry.list_registered()) # Should include your tool name # Custom registry print(registry.list_registered()) # Should include your tool name
Check
whenpredicate returns True:# If using `when=`, ensure it returns True for your scenario default_registry.register("tool", mock_fn=..., when=lambda md: md.get("mode") == "test")
CallRecorder: Tool Call Verification
The CallRecorder captures all tool calls during test execution, enabling verification of what tools were called, with what arguments, and how many times.
Quick Start
from stuntdouble import (
MockToolsRegistry,
CallRecorder,
create_mockable_tool_wrapper,
)
from langgraph.prebuilt import ToolNode
# Create registry and recorder
registry = MockToolsRegistry()
recorder = CallRecorder()
# Register mocks
mock = registry.mock
mock("get_customer").returns({"id": "123", "name": "Test Corp"})
# Create wrapper with recorder
wrapper = create_mockable_tool_wrapper(registry, recorder=recorder)
# Build your graph
tools = [get_customer, list_bills, create_invoice]
tool_node = ToolNode(tools, awrap_tool_call=wrapper)
# ... run your agent ...
# Verify calls were made
recorder.assert_called("get_customer")
recorder.assert_not_called("delete_account")
recorder.assert_called_once("list_bills")
recorder.assert_called_times("create_invoice", 2)
# Verify arguments
recorder.assert_called_with("get_customer", customer_id="123")
recorder.assert_last_called_with("list_bills", status="active")
# Verify call order
recorder.assert_call_order("get_customer", "list_bills", "create_invoice")
# Inspect recorded calls
print(recorder.summary())
API Reference
from stuntdouble import CallRecorder, CallRecord, MockAssertionError
recorder = CallRecorder()
Query Methods
Method |
Description |
Example |
|---|---|---|
|
Check if tool was called (optionally with specific args) |
|
|
Get number of calls to a tool |
|
|
Get list of |
|
|
Get the most recent call |
|
|
Get the first call |
|
|
Get arguments from a specific call |
|
|
Get result from a specific call |
|
|
Human-readable summary of all calls |
|
|
Reset recorder for next test |
|
Assertion Methods
All assertion methods raise MockAssertionError on failure.
Method |
Description |
Example |
|---|---|---|
|
Assert tool was called at least once |
|
|
Assert tool was never called |
|
|
Assert tool was called exactly once |
|
|
Assert tool was called exactly n times |
|
|
Assert any call matches the arguments |
|
|
Assert last call matches the arguments |
|
|
Assert tools were called in order |
|
CallRecord Properties
Each recorded call is a CallRecord with these properties:
Property |
Description |
Example |
|---|---|---|
|
Name of the tool |
|
|
Arguments passed to the tool |
|
|
Return value (mock or real) |
|
|
Exception if call failed |
|
|
Whether a mock was used |
|
|
Call duration in milliseconds |
|
|
Unix timestamp when call was made |
|
|
Scenario ID from metadata |
|
Examples
Basic Verification
recorder = CallRecorder()
wrapper = create_mockable_tool_wrapper(registry, recorder=recorder)
# After running agent
recorder.assert_called("get_customer")
recorder.assert_not_called("delete_account")
assert recorder.call_count("list_bills") == 1
Argument Verification
# Verify any call matches
recorder.assert_called_with("get_customer", customer_id="123")
# Verify last call matches
recorder.assert_last_called_with("list_bills", status="active", limit=10)
# Check if called with specific args
if recorder.was_called("create_invoice", amount=100):
print("Invoice created for $100")
Call Order Verification
# Verify tools were called in specific order
recorder.assert_call_order("get_customer", "list_bills", "create_invoice")
Inspecting Calls
# Get all calls for a tool
calls = recorder.get_calls("create_invoice")
for call in calls:
print(f"Amount: {call.args['amount']}, Result: {call.result}")
# Get specific call arguments
first_args = recorder.get_args("get_customer", index=0)
last_result = recorder.get_result("list_bills")
# Get full summary
print(recorder.summary())
# Output:
# Recorded 4 call(s):
# 1. get_customer [MOCKED] args={'customer_id': '123'}
# 2. list_bills [MOCKED] args={'status': 'active'}
# 3. create_invoice [MOCKED] args={'amount': 500}
# 4. create_invoice [MOCKED] args={'amount': 1200}
Testing with pytest
import pytest
from stuntdouble import (
MockToolsRegistry,
CallRecorder,
create_mockable_tool_wrapper,
)
@pytest.fixture
def recorder():
return CallRecorder()
@pytest.fixture
def mock_wrapper(recorder):
registry = MockToolsRegistry()
registry.mock("get_customer").returns({"id": "123", "name": "Test Corp"})
return create_mockable_tool_wrapper(registry, recorder=recorder)
def test_customer_workflow(mock_wrapper, recorder):
# Build graph with wrapper
tool_node = ToolNode(tools, awrap_tool_call=mock_wrapper)
# ... run agent ...
# Verify behavior
recorder.assert_called("get_customer")
recorder.assert_called_with("get_customer", customer_id="123")
assert recorder.get_result("get_customer")["name"] == "Test Corp"
Thread Safety
CallRecorder is thread-safe and suitable for concurrent test execution. All methods use internal locking to protect the call list during concurrent access.
Using Mirrored Tools
MCP Tool Mirroring auto-generates mock tools from MCP server schemas. These mirrored tools integrate seamlessly with LangGraph’s per-invocation mocking.
Quick Start with Mirroring
from stuntdouble.mirroring import ToolMirror
from stuntdouble import (
MockToolsRegistry,
create_mockable_tool_wrapper,
inject_scenario_metadata,
)
from langgraph.prebuilt import ToolNode
# 1. Mirror tools from MCP server
mirror = ToolMirror()
mirror.mirror(["python", "-m", "my_mcp_server"])
tools = mirror.to_langchain_tools()
# 2. Create registry and wrapper
registry = MockToolsRegistry()
wrapper = create_mockable_tool_wrapper(registry)
# 3. Build graph with mirrored tools
builder = StateGraph(MessagesState)
builder.add_node("agent", agent_node)
builder.add_node("tools", ToolNode(tools, awrap_tool_call=wrapper))
builder.add_edge(START, "agent")
builder.add_conditional_edges("agent", tools_condition)
builder.add_edge("tools", "agent")
graph = builder.compile()
# 4. Invoke - mirrored tools return generated mock data
result = await graph.ainvoke({"messages": [HumanMessage("Create an invoice")]})
LangGraph-Optimized Mirroring
Use ToolMirror.for_langgraph() for a mirror pre-configured for LangGraph integration:
from stuntdouble.mirroring import ToolMirror
# Optimized for LangGraph: generates mock functions compatible with registry
mirror = ToolMirror.for_langgraph()
mirror.mirror(["python", "-m", "my_mcp_server"])
tools = mirror.to_langchain_tools()
Mirroring with HTTP Authentication
Mirror from remote MCP servers behind authentication:
from stuntdouble.mirroring import ToolMirror
mirror = ToolMirror()
# Bearer token authentication
result = mirror.mirror(
http_url="https://api.example.com/mcp",
headers={"Authorization": "Bearer your-token-here"}
)
# API key authentication
result = mirror.mirror(
http_url="http://localhost:8080",
headers={"X-API-Key": "abc123", "X-Client-ID": "my-app"}
)
tools = mirror.to_langchain_tools()
Mirroring with LangGraph Registry Integration
For advanced scenarios, mirror tools directly into the LangGraph mock registry:
from stuntdouble.mirroring import ToolMirror
from stuntdouble import MockToolsRegistry
# Create registry first
registry = MockToolsRegistry()
# Mirror tools and register them in the LangGraph registry
mirror = ToolMirror.for_langgraph(registry=registry)
mirror.mirror(["python", "-m", "my_server"])
# Registry now contains mock functions for all mirrored tools
print(registry.list_registered())
# ['create_invoice', 'get_customer', 'list_bills', ...]
Combining with Custom Mocks
Override specific mirrored tools with custom behavior:
from stuntdouble.mirroring import ToolMirror
from stuntdouble import MockToolsRegistry
registry = MockToolsRegistry()
# Mirror all tools
mirror = ToolMirror.for_langgraph(registry=registry)
mirror.mirror(["python", "-m", "my_server"])
# Override specific tool with custom mock
registry.mock("get_customer").returns({
"id": "CUST-001",
"name": "Test Corp",
"tier": "platinum"
})
# get_customer uses custom mock, others use generated mocks
→ See the MCP Tool Mirroring Guide for complete documentation.