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
55 changes: 55 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -235,6 +235,61 @@ 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. Per the [MCP specification](https://modelcontextprotocol.io/specification/2025-06-18/basic/index#general-fields), `_meta` is a protocol-level field for attaching additional metadata to interactions. This is useful for:

- Passing request-specific context to servers
- Custom routing or multi-tenant scenarios
- Session tracking and correlation IDs

### Example: adding `_meta` via interceptor

Metadata is passed through interceptors, keeping it separate from tool arguments:

```python
from langchain_mcp_adapters.tools import load_mcp_tools
from langchain_mcp_adapters.interceptors import MCPToolCallRequest

async def add_context_interceptor(request: MCPToolCallRequest, handler):
"""Add request context via metadata."""
modified = request.override(
metaParams={"user_id": "current-user-id", "correlation_id": "trace-123"}
)
return await handler(modified)

tools = await load_mcp_tools(
session,
tool_interceptors=[add_context_interceptor],
)

# Tool arguments remain clean - no metadata mixed in
result = await tool.ainvoke({"query": "hello"})
```

### Example: composing multiple metadata interceptors

```python
async def add_correlation_interceptor(request: MCPToolCallRequest, handler):
"""Add correlation ID for tracing."""
meta = request.metaParams or {}
meta["correlation_id"] = "trace-xyz"
return await handler(request.override(metaParams=meta))

async def add_user_interceptor(request: MCPToolCallRequest, handler):
"""Add user context."""
meta = request.metaParams or {}
meta["user_id"] = "user-123"
return await handler(request.override(metaParams=meta))

tools = await load_mcp_tools(
session,
tool_interceptors=[add_correlation_interceptor, add_user_interceptor],
)
```

> Per the [MCP specification](https://modelcontextprotocol.io/specification/2025-06-18/basic/index#general-fields), `_meta` is a separate protocol-level parameter, not part of tool arguments. It enables servers to access metadata without exposing it to language models.

## 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]
metaParams: 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).
metaParams: Optional metadata to pass to the MCP server.

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

def override(
self, **overrides: Unpack[_MCPToolCallRequestOverrides]
Expand Down
28 changes: 19 additions & 9 deletions langchain_mcp_adapters/tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -67,18 +67,21 @@
MAX_ITERATIONS = 1000


class MCPToolArtifact(TypedDict):
class MCPToolArtifact(TypedDict, total=False):
"""Artifact returned from MCP tool calls.

This TypedDict wraps the structured content from MCP tool calls,
This TypedDict wraps additional fields returned by MCP tool calls,
allowing for future extension if MCP adds more fields to tool results.

Attributes:
structured_content: The structured content returned by the MCP tool,
corresponding to the structuredContent field in CallToolResult.
_meta: The response metadata returned by the MCP tool,
corresponding to the _meta field in CallToolResult.
"""

structured_content: dict[str, Any]
_meta: dict[str, Any]


def _convert_mcp_content_to_lc_block( # noqa: PLR0911
Expand Down Expand Up @@ -155,8 +158,8 @@ def _convert_call_tool_result(
A tuple containing:
- The content: either a string (single text), list of content blocks,
ToolMessage, or Command
- The artifact: MCPToolArtifact with structured_content if present,
otherwise None
- The artifact: MCPToolArtifact with structured_content and/or _meta
when present, otherwise None

Raises:
ToolException: If the tool call resulted in an error.
Expand Down Expand Up @@ -188,12 +191,17 @@ def _convert_call_tool_result(
error_msg = "\n".join(error_parts) if error_parts else str(tool_content)
raise ToolException(error_msg)

# Extract structured content and wrap in MCPToolArtifact
# Extract artifact fields from MCP result.
artifact: MCPToolArtifact | None = None
if call_tool_result.structuredContent is not None:
artifact = MCPToolArtifact(
structured_content=call_tool_result.structuredContent
)
if (
call_tool_result.structuredContent is not None
or call_tool_result.meta is not None
):
artifact = MCPToolArtifact()
if call_tool_result.structuredContent is not None:
artifact["structured_content"] = call_tool_result.structuredContent
if call_tool_result.meta is not None:
artifact["_meta"] = call_tool_result.meta

return tool_content, artifact

Expand Down Expand Up @@ -378,6 +386,7 @@ async def execute_tool(request: MCPToolCallRequest) -> MCPToolCallResult:
tool_name,
tool_args,
progress_callback=mcp_callbacks.progress_callback,
meta=request.metaParams,
)
except Exception as e: # noqa: BLE001
# Capture exception to re-raise outside context manager
Expand All @@ -396,6 +405,7 @@ async def execute_tool(request: MCPToolCallRequest) -> MCPToolCallResult:
tool_name,
tool_args,
progress_callback=mcp_callbacks.progress_callback,
meta=request.metaParams,
)

return call_tool_result
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ readme = "README.md"
requires-python = ">=3.10"
dependencies = [
"langchain-core>=1.0.0,<2.0.0",
"mcp>=1.9.2",
"mcp>=1.22.0",
"typing-extensions>=4.14.0",
]

Expand Down
167 changes: 167 additions & 0 deletions tests/test_interceptors.py
Original file line number Diff line number Diff line change
Expand Up @@ -321,3 +321,170 @@ async def tool_message_interceptor(
assert result.content == "Custom ToolMessage response"
assert result.name == "add"
assert result.tool_call_id == "test-call-id"


class TestMetadataPassthrough:
"""Tests for metadata passthrough to MCP servers via interceptors."""

async def test_interceptor_adds_metadata(self, socket_enabled):
"""Test that interceptors can add metadata to tool calls."""
captured_meta = []

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

async def add_meta_interceptor(
request: MCPToolCallRequest,
handler,
) -> CallToolResult:
# Add metaParams through the interceptor
modified_request = request.override(
metaParams={"session_id": "abc123", "user_id": "test-user"}
)
return await handler(modified_request)

with run_streamable_http(_create_math_server, 8210):
tools = await load_mcp_tools(
None,
connection={
"url": "http://localhost:8210/mcp",
"transport": "streamable_http",
},
tool_interceptors=[add_meta_interceptor, capture_meta_interceptor],
)

add_tool = next(tool for tool in tools if tool.name == "add")
result = await add_tool.ainvoke({"a": 2, "b": 3})

# Tool executes successfully
assert result == [{"type": "text", "text": "5", "id": IsLangChainID}]

# Verify that the metadata was passed through the interceptor
assert len(captured_meta) == 1
assert captured_meta[0] == {
"session_id": "abc123",
"user_id": "test-user",
}

async def test_interceptor_modifies_metadata(self, socket_enabled):
"""Test that interceptors can modify metadata for different requests."""
captured_requests = []

async def capture_request_interceptor(
request: MCPToolCallRequest,
handler,
) -> CallToolResult:
# Capture the request state to verify metadata
captured_requests.append(
{
"name": request.name,
"args": request.args.copy(),
"metaParams": request.metaParams,
}
)
return await handler(request)

async def contextual_meta_interceptor(
request: MCPToolCallRequest,
handler,
) -> CallToolResult:
# Add context-specific metadata based on tool name
metadata = {
"tool": request.name,
"timestamp": "2025-02-26T00:00:00Z",
}

modified_request = request.override(metaParams=metadata)
return await handler(modified_request)

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

add_tool = next(tool for tool in tools if tool.name == "add")
result = await add_tool.ainvoke({"a": 5, "b": 7})

# Tool executes successfully
assert result == [{"type": "text", "text": "12", "id": IsLangChainID}]

# Verify the captured request has the metadata
assert len(captured_requests) == 1
captured = captured_requests[0]
assert captured["name"] == "add"
assert captured["args"] == {"a": 5, "b": 7}
assert captured["metaParams"] == {
"tool": "add",
"timestamp": "2025-02-26T00:00:00Z",
}

async def test_multiple_interceptors_modify_metadata(self, socket_enabled):
"""Test that multiple interceptors can compose to build metadata."""
captured_meta = []

async def add_correlation_interceptor(
request: MCPToolCallRequest,
handler,
) -> CallToolResult:
# First interceptor adds correlation ID
meta = request.metaParams or {}
meta["correlation_id"] = "corr-123"
modified = request.override(metaParams=meta)
return await handler(modified)

async def add_user_interceptor(
request: MCPToolCallRequest,
handler,
) -> CallToolResult:
# Second interceptor adds user context
meta = request.metaParams or {}
meta["user_id"] = "user-456"
modified = request.override(metaParams=meta)
return await handler(modified)

async def capture_meta_interceptor(
request: MCPToolCallRequest,
handler,
) -> CallToolResult:
captured_meta.append(request.metaParams)
return await handler(request)

with run_streamable_http(_create_math_server, 8212):
tools = await load_mcp_tools(
None,
connection={
"url": "http://localhost:8212/mcp",
"transport": "streamable_http",
},
tool_interceptors=[
add_correlation_interceptor,
add_user_interceptor,
capture_meta_interceptor,
],
)

add_tool = next(tool for tool in tools if tool.name == "add")
result = await add_tool.ainvoke({"a": 10, "b": 20})

# Tool executes successfully
assert result == [{"type": "text", "text": "30", "id": IsLangChainID}]

# Verify both interceptors contributed to metadata
assert len(captured_meta) == 1
assert captured_meta[0] == {
"correlation_id": "corr-123",
"user_id": "user-456",
}
Loading