Skip to content

Commit be545d7

Browse files
authored
feature: roll out interceptor patterns (enables retries in addition to other features supported by hooks) (#351)
- [x] Replace hooks with more general interceptor pattern which will allow retry logic as well - [x] Add end-to-end tests for callbacks - [x] Add end-to-end tests for interceptor patterns - [x] Simplify the interceptor context for now (remove config) -- and we can see if we can plumb through toolruntime
1 parent 4db3ccb commit be545d7

File tree

7 files changed

+632
-602
lines changed

7 files changed

+632
-602
lines changed

langchain_mcp_adapters/client.py

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616
from mcp import ClientSession
1717

1818
from langchain_mcp_adapters.callbacks import CallbackContext, Callbacks
19-
from langchain_mcp_adapters.hooks import Hooks
19+
from langchain_mcp_adapters.interceptors import ToolCallInterceptor
2020
from langchain_mcp_adapters.prompts import load_mcp_prompt
2121
from langchain_mcp_adapters.resources import load_mcp_resources
2222
from langchain_mcp_adapters.sessions import (
@@ -53,15 +53,16 @@ def __init__(
5353
connections: dict[str, Connection] | None = None,
5454
*,
5555
callbacks: Callbacks | None = None,
56-
hooks: Hooks | None = None,
56+
tool_interceptors: list[ToolCallInterceptor] | None = None,
5757
) -> None:
5858
"""Initialize a MultiServerMCPClient with MCP servers connections.
5959
6060
Args:
6161
connections: A dictionary mapping server names to connection configurations.
6262
If None, no initial connections are established.
6363
callbacks: Optional callbacks for handling notifications and events.
64-
hooks: Optional hooks for before/after tool call processing.
64+
tool_interceptors: Optional list of tool call interceptors for modifying
65+
requests and responses.
6566
6667
Example: basic usage (starting a new session on each tool call)
6768
@@ -102,7 +103,7 @@ def __init__(
102103
connections if connections is not None else {}
103104
)
104105
self.callbacks = callbacks or Callbacks()
105-
self.hooks = hooks
106+
self.tool_interceptors = tool_interceptors or []
106107

107108
@asynccontextmanager
108109
async def session(
@@ -167,7 +168,7 @@ async def get_tools(self, *, server_name: str | None = None) -> list[BaseTool]:
167168
connection=self.connections[server_name],
168169
callbacks=self.callbacks,
169170
server_name=server_name,
170-
hooks=self.hooks,
171+
tool_interceptors=self.tool_interceptors,
171172
)
172173

173174
all_tools: list[BaseTool] = []
@@ -179,7 +180,7 @@ async def get_tools(self, *, server_name: str | None = None) -> list[BaseTool]:
179180
connection=connection,
180181
callbacks=self.callbacks,
181182
server_name=name,
182-
hooks=self.hooks,
183+
tool_interceptors=self.tool_interceptors,
183184
)
184185
)
185186
load_mcp_tool_tasks.append(load_mcp_tool_task)

langchain_mcp_adapters/hooks.py

Lines changed: 0 additions & 112 deletions
This file was deleted.
Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
"""Interceptor interfaces and types for MCP client tool call lifecycle management.
2+
3+
This module provides an interceptor interface for wrapping and controlling
4+
MCP tool call execution with a handler callback pattern.
5+
6+
In the future, we might add more interceptors for other parts of the
7+
request / result lifecycle, for example to support elicitation.
8+
"""
9+
10+
from __future__ import annotations
11+
12+
from dataclasses import dataclass, replace
13+
from typing import TYPE_CHECKING, Any, Protocol
14+
15+
from mcp.types import CallToolResult
16+
from typing_extensions import NotRequired, TypedDict, Unpack
17+
18+
if TYPE_CHECKING:
19+
from collections.abc import Awaitable, Callable
20+
21+
22+
MCPToolCallResult = CallToolResult
23+
24+
25+
class _MCPToolCallRequestOverrides(TypedDict, total=False):
26+
"""Possible overrides for MCPToolCallRequest.override() method.
27+
28+
Only includes modifiable request fields, not context fields like
29+
server_name and runtime which are read-only.
30+
"""
31+
32+
name: NotRequired[str]
33+
args: NotRequired[dict[str, Any]]
34+
headers: NotRequired[dict[str, Any] | None]
35+
36+
37+
@dataclass
38+
class MCPToolCallRequest:
39+
"""Tool execution request passed to MCP tool call interceptors.
40+
41+
Similar to LangChain's ToolCallRequest but adapted for MCP remote tools.
42+
MCP tools don't have local BaseTool instances, so this flattens the call
43+
data and context into a single object.
44+
45+
Modifiable fields (override these to change behavior):
46+
name: Tool name to invoke.
47+
args: Tool arguments as key-value pairs.
48+
headers: HTTP headers for applicable transports (SSE, HTTP).
49+
50+
Context fields (read-only, use for routing/logging):
51+
server_name: Name of the MCP server handling the tool.
52+
runtime: LangGraph runtime context (optional, None if outside graph).
53+
"""
54+
55+
name: str
56+
args: dict[str, Any]
57+
server_name: str # Context: MCP server name
58+
headers: dict[str, Any] | None = None # Modifiable: HTTP headers
59+
runtime: object | None = None # Context: LangGraph runtime (if any)
60+
61+
def override(
62+
self, **overrides: Unpack[_MCPToolCallRequestOverrides]
63+
) -> MCPToolCallRequest:
64+
"""Replace the request with a new request with the given overrides.
65+
66+
Returns a new `MCPToolCallRequest` instance with the specified
67+
attributes replaced. This follows an immutable pattern, leaving the
68+
original request unchanged.
69+
70+
Args:
71+
**overrides: Keyword arguments for attributes to override.
72+
Supported keys:
73+
- name: Tool name
74+
- args: Tool arguments
75+
- headers: HTTP headers
76+
77+
Returns:
78+
New MCPToolCallRequest instance with specified overrides
79+
applied.
80+
81+
Note:
82+
Context fields (server_name, runtime) cannot be overridden as
83+
they are read-only.
84+
85+
Examples:
86+
```python
87+
# Modify tool arguments
88+
new_request = request.override(args={"value": 10})
89+
90+
# Change tool name
91+
new_request = request.override(name="different_tool")
92+
```
93+
"""
94+
return replace(self, **overrides)
95+
96+
97+
class ToolCallInterceptor(Protocol):
98+
"""Protocol for tool call interceptors using handler callback pattern.
99+
100+
Interceptors wrap tool execution to enable request/response modification,
101+
retry logic, caching, rate limiting, and other cross-cutting concerns.
102+
Multiple interceptors compose in "onion" pattern (first is outermost).
103+
104+
The handler can be called multiple times (retry), skipped (caching/short-circuit),
105+
or wrapped with error handling. Each handler call is independent.
106+
107+
Similar to LangChain's middleware pattern but adapted for MCP remote tools.
108+
"""
109+
110+
async def __call__(
111+
self,
112+
request: MCPToolCallRequest,
113+
handler: Callable[[MCPToolCallRequest], Awaitable[MCPToolCallResult]],
114+
) -> MCPToolCallResult:
115+
"""Intercept tool execution with control over handler invocation.
116+
117+
Args:
118+
request: Tool call request containing name, args, headers, and context
119+
(server_name, runtime). Access context fields like request.server_name.
120+
handler: Async callable executing the tool. Can be called multiple
121+
times, skipped, or wrapped for error handling.
122+
123+
Returns:
124+
Final MCPToolCallResult from tool execution or interceptor logic.
125+
"""
126+
...

0 commit comments

Comments
 (0)