Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 37 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -235,6 +235,43 @@ response = await agent.ainvoke({"messages": "what is the weather in nyc?"})

> Only `sse` and `http` transports support runtime headers. These headers are passed with every HTTP request to the MCP server.

## Passing request metadata (`_meta`)

MCP supports passing metadata with each tool invocation via the `_meta` parameter. This is useful for:
- Passing request-specific context to servers
- Custom routing or multi-tenant scenarios

### Example: passing `_meta` with tool invocation

```python
# Pass _meta as part of tool arguments
result = await tool.ainvoke({
"message": "hello",
"_meta": {"session_id": "abc123"}
})
```

### Example: adding `_meta` via interceptor

```python
from langchain_mcp_adapters.tools import load_mcp_tools

async def add_context_interceptor(request, handler):
"""Add request context via _meta."""
modified = request.override(
meta={"user_id": "current-user-id"}
)
return await handler(modified)

tools = await load_mcp_tools(
None,
connection={"url": "http://localhost:8000/mcp", "transport": "http"},
tool_interceptors=[add_context_interceptor],
)
```

> Per the [MCP specification](https://modelcontextprotocol.io/specification/2025-06-18/basic/index#meta), `_meta` is reserved for protocol-level metadata.

## Using with LangGraph StateGraph

```python
Expand Down
3 changes: 3 additions & 0 deletions langchain_mcp_adapters/interceptors.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ class _MCPToolCallRequestOverrides(TypedDict, total=False):
name: NotRequired[str]
args: NotRequired[dict[str, Any]]
headers: NotRequired[dict[str, Any] | None]
meta: NotRequired[dict[str, Any] | None]


@dataclass
Expand All @@ -60,6 +61,7 @@ class MCPToolCallRequest:
name: Tool name to invoke.
args: Tool arguments as key-value pairs.
headers: HTTP headers for applicable transports (SSE, HTTP).
meta: MCP protocol metadata (_meta) to pass to the server.

Context fields (read-only, use for routing/logging):
server_name: Name of the MCP server handling the tool.
Expand All @@ -70,6 +72,7 @@ class MCPToolCallRequest:
args: dict[str, Any]
server_name: str # Context: MCP server name
headers: dict[str, Any] | None = None # Modifiable: HTTP headers
meta: dict[str, Any] | None = None # Modifiable: MCP _meta protocol metadata
runtime: object | None = None # Context: LangGraph runtime (if any)

def override(
Expand Down
10 changes: 10 additions & 0 deletions langchain_mcp_adapters/tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -341,6 +341,7 @@ async def execute_tool(request: MCPToolCallRequest) -> MCPToolCallResult:
"""
tool_name = request.name
tool_args = request.args
tool_meta = request.meta
effective_connection = connection

# If headers were modified, create a new connection with updated headers
Expand Down Expand Up @@ -378,6 +379,7 @@ async def execute_tool(request: MCPToolCallRequest) -> MCPToolCallResult:
tool_name,
tool_args,
progress_callback=mcp_callbacks.progress_callback,
meta=tool_meta,
)
except Exception as e: # noqa: BLE001
# Capture exception to re-raise outside context manager
Expand All @@ -396,17 +398,25 @@ async def execute_tool(request: MCPToolCallRequest) -> MCPToolCallResult:
tool_name,
tool_args,
progress_callback=mcp_callbacks.progress_callback,
meta=tool_meta,
)

return call_tool_result

# Build and execute the interceptor chain
handler = _build_interceptor_chain(execute_tool, tool_interceptors)

# Extract _meta from arguments - it's protocol metadata, not a tool argument.
# We must remove it before passing arguments to the MCP server, otherwise
# the server will reject it as an unexpected tool parameter.
tool_meta = arguments.pop("_meta", None)

request = MCPToolCallRequest(
name=tool.name,
args=arguments,
server_name=server_name or "unknown",
headers=None,
meta=tool_meta,
runtime=runtime,
)
call_tool_result = await handler(request)
Expand Down
143 changes: 143 additions & 0 deletions tests/test_interceptors.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,149 @@
from tests.utils import IsLangChainID, run_streamable_http


class TestMetaPassthrough:
"""Tests for _meta parameter passthrough to MCP servers."""

async def test_meta_passed_via_arguments(self, socket_enabled):
"""Test that _meta in tool arguments is passed through to MCP server."""

def _create_meta_echo_server(port: int = 8220):
"""Create a server that echoes the received _meta."""
server = FastMCP(port=port)

@server.tool()
def echo_meta(message: str) -> str:
"""Echo message and return any received meta."""
# The server receives _meta through the MCP protocol
# We'll verify it was passed by checking the request context
return f"Message: {message}"

return server

with run_streamable_http(_create_meta_echo_server, 8220):
tools = await load_mcp_tools(
None,
connection={
"url": "http://localhost:8220/mcp",
"transport": "streamable_http",
},
)

echo_tool = next(tool for tool in tools if tool.name == "echo_meta")
# Pass _meta as part of the arguments - it should be extracted
# and passed to MCP call_tool
result = await echo_tool.ainvoke(
{"message": "hello", "_meta": {"session_id": "abc123"}}
)
# Tool executes successfully (meta doesn't affect response in this test)
assert "Message: hello" in str(result)

async def test_interceptor_can_modify_meta(self, socket_enabled):
"""Test that interceptors can modify the meta field."""
captured_meta = []

async def add_meta_interceptor(
request: MCPToolCallRequest,
handler,
) -> CallToolResult:
# Add or modify meta through the interceptor
modified_request = request.override(
meta={"added_by_interceptor": True, "user_id": "test-user"}
)
return await handler(modified_request)

async def capture_meta_interceptor(
request: MCPToolCallRequest,
handler,
) -> CallToolResult:
# Capture the meta after modification to verify it was set
captured_meta.append(request.meta)
return await handler(request)

def _create_simple_server(port: int = 8221):
server = FastMCP(port=port)

@server.tool()
def simple_tool(value: int) -> int:
"""Return doubled value."""
return value * 2

return server

with run_streamable_http(_create_simple_server, 8221):
tools = await load_mcp_tools(
None,
connection={
"url": "http://localhost:8221/mcp",
"transport": "streamable_http",
},
# First interceptor modifies, second captures to verify
tool_interceptors=[add_meta_interceptor, capture_meta_interceptor],
)

simple_tool = next(tool for tool in tools if tool.name == "simple_tool")
result = await simple_tool.ainvoke({"value": 5})
assert "10" in str(result)

# Verify the meta was modified by the first interceptor
assert len(captured_meta) == 1
assert captured_meta[0] == {
"added_by_interceptor": True,
"user_id": "test-user",
}

async def test_meta_and_args_passed_separately(self, socket_enabled):
"""Test that _meta is extracted from args and passed as separate param."""
captured_requests = []

async def capture_request_interceptor(
request: MCPToolCallRequest,
handler,
) -> CallToolResult:
# Capture the request to verify _meta was extracted from args
captured_requests.append(
{
"args": dict(request.args),
"meta": request.meta,
}
)
return await handler(request)

def _create_capture_server(port: int = 8222):
server = FastMCP(port=port)

@server.tool()
def capture_tool(a: int, b: int) -> int:
"""Add two numbers."""
return a + b

return server

with run_streamable_http(_create_capture_server, 8222):
tools = await load_mcp_tools(
None,
connection={
"url": "http://localhost:8222/mcp",
"transport": "streamable_http",
},
tool_interceptors=[capture_request_interceptor],
)

capture_tool = next(tool for tool in tools if tool.name == "capture_tool")
result = await capture_tool.ainvoke(
{"a": 3, "b": 7, "_meta": {"session_id": "abc123"}}
)

# Verify the tool executed correctly
assert "10" in str(result)

# Verify _meta was extracted from args and placed in meta field
assert len(captured_requests) == 1
assert "_meta" not in captured_requests[0]["args"]
assert captured_requests[0]["args"] == {"a": 3, "b": 7}
assert captured_requests[0]["meta"] == {"session_id": "abc123"}


def _create_math_server(port: int = 8200):
"""Create a math server with add and multiply tools."""
server = FastMCP(port=port)
Expand Down
4 changes: 2 additions & 2 deletions tests/test_tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -441,7 +441,7 @@ async def test_convert_mcp_tool_to_langchain_tool():

# Verify session.call_tool was called with correct arguments
session.call_tool.assert_called_once_with(
"test_tool", {"param1": "test", "param2": 42}, progress_callback=None
"test_tool", {"param1": "test", "param2": 42}, progress_callback=None, meta=None
)

# Verify result
Expand Down Expand Up @@ -479,7 +479,7 @@ async def test_load_mcp_tools():
session.list_tools.return_value = MagicMock(tools=mcp_tools, nextCursor=None)

# Mock call_tool to return different results for different tools
async def mock_call_tool(tool_name, arguments, progress_callback=None):
async def mock_call_tool(tool_name, arguments, progress_callback=None, meta=None):
if tool_name == "tool1":
return CallToolResult(
content=[
Expand Down