MCP Interceptors
MCP interceptors are async middleware for tool calls — wrapping every MCP invocation with auth, retry, logging, and access control. This article covers the real API (MCPToolCallRequest, handler, override), when to use interceptors vs alternatives, core patterns with correct imports, multi-server routing via server_name, failure modes when interceptors break, and testing strategies.
Quick Reference
- →from langchain_mcp_adapters.interceptors import MCPToolCallRequest — the typed request object
- →Interceptor signature: async def my_interceptor(request: MCPToolCallRequest, handler) — NOT call_next
- →request.name for tool name, request.args for arguments, request.server_name for routing — NOT request.tool_name
- →request.override(headers={...}, args={...}) for immutable updates — never mutate request directly
- →tool_interceptors=[...] on MultiServerMCPClient — NOT interceptors=
- →First interceptor in list = outermost layer (runs first on request, last on response)
- →request.runtime can be None outside LangGraph — always guard access with if request.runtime:
- →Return ToolMessage to short-circuit without calling the tool; return Command to update agent state
Should I Use Interceptors?
Interceptors add indirection. Before reaching for one, ask whether the concern applies to all or most MCP tool calls uniformly. If it does — auth injection, logging, retry, rate limiting — an interceptor is the right abstraction. If the logic is specific to one tool, put it in the tool itself or in a graph node. The distinction matters because interceptors run on every tool call from every server, and a bug in any interceptor takes down all your MCP tools simultaneously.
Interceptors are for cross-cutting concerns — if the logic targets one tool, keep it in the tool
| Concern | Use Interceptor? | Alternative | Why |
|---|---|---|---|
| Inject user auth token into all MCP calls | Yes | — | Cross-cutting, applies uniformly to all servers |
| Log every tool call with timing | Yes | — | Cross-cutting observability — one interceptor covers all tools |
| Retry transient failures (ConnectionError, TimeoutError) | Yes | — | Cross-cutting resilience; retry logic doesn't belong per-tool |
| Block premium tools for free users | Yes | Graph conditional edge | Interceptor is simpler when the gate applies to all servers |
| Transform output format of one specific tool | No | @tool wrapper or graph node | Tool-specific — an interceptor checking request.name == '...' for all logic is wrong |
| Route to different graph nodes based on result | Maybe | Graph conditional edge | Use Command return only if routing needs tool call context |
| Rate-limit calls to one specific server | Yes, with server_name guard | Per-server config | Interceptor can dispatch by request.server_name |
If your interceptor starts with `if request.name == 'specific_tool':` for most of its logic, it belongs in the tool or a graph node. Interceptors shine when the logic is uniform across all tool calls.