Skip to content

Commit 9f2e4dd

Browse files
committed
feat: add support for passing metadata to MCP tool calls
Add metaParams support to MCP tool calls and expose MCP response _meta in MCPToolArtifact. This allows callers to pass metadata parameters to MCP tools and access response metadata returned by MCP tool calls alongside structured_content. Per the MCP specification (https://modelcontextprotocol.io/specification/2025-06-18/basic/index#general-fields), _meta is defined as a separate field at the request level. This implementation correctly treats metaParams as a distinct parameter passed to session.call_tool(meta=...), following the spec's CallToolRequest structure where _meta is a sibling to 'name' and 'arguments', not nested within arguments. Metadata is passed exclusively through interceptors, keeping tool arguments clean and protocol-level metadata separate from LLM-facing parameters. Changes: - Bump mcp dependency from 1.9.2 to 1.22.0 - Add metaParams field to MCPToolCallRequest for passing metadata to MCP servers - Add metaParams parameter to tool calls passed as 'meta' to session.call_tool() - Add _meta field to MCPToolArtifact (with total=False to make both fields optional) - Update tools and interceptors to support metadata parameters - Add comprehensive tests for metadata passing and interceptor composition - Update README documentation with interceptor-based metadata examples - Ensure clean separation: tool arguments remain pure, metadata handled via interceptors
1 parent 141be26 commit 9f2e4dd

File tree

7 files changed

+1282
-717
lines changed

7 files changed

+1282
-717
lines changed

README.md

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -235,6 +235,61 @@ response = await agent.ainvoke({"messages": "what is the weather in nyc?"})
235235
236236
> Only `sse` and `http` transports support runtime headers. These headers are passed with every HTTP request to the MCP server.
237237
238+
## Passing request metadata (`_meta`)
239+
240+
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:
241+
242+
- Passing request-specific context to servers
243+
- Custom routing or multi-tenant scenarios
244+
- Session tracking and correlation IDs
245+
246+
### Example: adding `_meta` via interceptor
247+
248+
Metadata is passed through interceptors, keeping it separate from tool arguments:
249+
250+
```python
251+
from langchain_mcp_adapters.tools import load_mcp_tools
252+
from langchain_mcp_adapters.interceptors import MCPToolCallRequest
253+
254+
async def add_context_interceptor(request: MCPToolCallRequest, handler):
255+
"""Add request context via metadata."""
256+
modified = request.override(
257+
metaParams={"user_id": "current-user-id", "correlation_id": "trace-123"}
258+
)
259+
return await handler(modified)
260+
261+
tools = await load_mcp_tools(
262+
session,
263+
tool_interceptors=[add_context_interceptor],
264+
)
265+
266+
# Tool arguments remain clean - no metadata mixed in
267+
result = await tool.ainvoke({"query": "hello"})
268+
```
269+
270+
### Example: composing multiple metadata interceptors
271+
272+
```python
273+
async def add_correlation_interceptor(request: MCPToolCallRequest, handler):
274+
"""Add correlation ID for tracing."""
275+
meta = request.metaParams or {}
276+
meta["correlation_id"] = "trace-xyz"
277+
return await handler(request.override(metaParams=meta))
278+
279+
async def add_user_interceptor(request: MCPToolCallRequest, handler):
280+
"""Add user context."""
281+
meta = request.metaParams or {}
282+
meta["user_id"] = "user-123"
283+
return await handler(request.override(metaParams=meta))
284+
285+
tools = await load_mcp_tools(
286+
session,
287+
tool_interceptors=[add_correlation_interceptor, add_user_interceptor],
288+
)
289+
```
290+
291+
> 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.
292+
238293
## Using with LangGraph StateGraph
239294
240295
```python

langchain_mcp_adapters/interceptors.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ class _MCPToolCallRequestOverrides(TypedDict, total=False):
4646
name: NotRequired[str]
4747
args: NotRequired[dict[str, Any]]
4848
headers: NotRequired[dict[str, Any] | None]
49+
metaParams: NotRequired[dict[str, Any] | None]
4950

5051

5152
@dataclass
@@ -60,6 +61,7 @@ class MCPToolCallRequest:
6061
name: Tool name to invoke.
6162
args: Tool arguments as key-value pairs.
6263
headers: HTTP headers for applicable transports (SSE, HTTP).
64+
metaParams: Optional metadata to pass to the MCP server.
6365
6466
Context fields (read-only, use for routing/logging):
6567
server_name: Name of the MCP server handling the tool.
@@ -71,6 +73,7 @@ class MCPToolCallRequest:
7173
server_name: str # Context: MCP server name
7274
headers: dict[str, Any] | None = None # Modifiable: HTTP headers
7375
runtime: object | None = None # Context: LangGraph runtime (if any)
76+
metaParams: dict[str, Any] | None = None # Modifiable: MCP metadata
7477

7578
def override(
7679
self, **overrides: Unpack[_MCPToolCallRequestOverrides]

langchain_mcp_adapters/tools.py

Lines changed: 19 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -67,18 +67,21 @@
6767
MAX_ITERATIONS = 1000
6868

6969

70-
class MCPToolArtifact(TypedDict):
70+
class MCPToolArtifact(TypedDict, total=False):
7171
"""Artifact returned from MCP tool calls.
7272
73-
This TypedDict wraps the structured content from MCP tool calls,
73+
This TypedDict wraps additional fields returned by MCP tool calls,
7474
allowing for future extension if MCP adds more fields to tool results.
7575
7676
Attributes:
7777
structured_content: The structured content returned by the MCP tool,
7878
corresponding to the structuredContent field in CallToolResult.
79+
_meta: The response metadata returned by the MCP tool,
80+
corresponding to the _meta field in CallToolResult.
7981
"""
8082

8183
structured_content: dict[str, Any]
84+
_meta: dict[str, Any]
8285

8386

8487
def _convert_mcp_content_to_lc_block( # noqa: PLR0911
@@ -155,8 +158,8 @@ def _convert_call_tool_result(
155158
A tuple containing:
156159
- The content: either a string (single text), list of content blocks,
157160
ToolMessage, or Command
158-
- The artifact: MCPToolArtifact with structured_content if present,
159-
otherwise None
161+
- The artifact: MCPToolArtifact with structured_content and/or _meta
162+
when present, otherwise None
160163
161164
Raises:
162165
ToolException: If the tool call resulted in an error.
@@ -188,12 +191,17 @@ def _convert_call_tool_result(
188191
error_msg = "\n".join(error_parts) if error_parts else str(tool_content)
189192
raise ToolException(error_msg)
190193

191-
# Extract structured content and wrap in MCPToolArtifact
194+
# Extract artifact fields from MCP result.
192195
artifact: MCPToolArtifact | None = None
193-
if call_tool_result.structuredContent is not None:
194-
artifact = MCPToolArtifact(
195-
structured_content=call_tool_result.structuredContent
196-
)
196+
if (
197+
call_tool_result.structuredContent is not None
198+
or call_tool_result.meta is not None
199+
):
200+
artifact = MCPToolArtifact()
201+
if call_tool_result.structuredContent is not None:
202+
artifact["structured_content"] = call_tool_result.structuredContent
203+
if call_tool_result.meta is not None:
204+
artifact["_meta"] = call_tool_result.meta
197205

198206
return tool_content, artifact
199207

@@ -378,6 +386,7 @@ async def execute_tool(request: MCPToolCallRequest) -> MCPToolCallResult:
378386
tool_name,
379387
tool_args,
380388
progress_callback=mcp_callbacks.progress_callback,
389+
meta=request.metaParams,
381390
)
382391
except Exception as e: # noqa: BLE001
383392
# Capture exception to re-raise outside context manager
@@ -396,6 +405,7 @@ async def execute_tool(request: MCPToolCallRequest) -> MCPToolCallResult:
396405
tool_name,
397406
tool_args,
398407
progress_callback=mcp_callbacks.progress_callback,
408+
meta=request.metaParams,
399409
)
400410

401411
return call_tool_result

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ readme = "README.md"
1414
requires-python = ">=3.10"
1515
dependencies = [
1616
"langchain-core>=1.0.0,<2.0.0",
17-
"mcp>=1.9.2",
17+
"mcp>=1.22.0",
1818
"typing-extensions>=4.14.0",
1919
]
2020

tests/test_interceptors.py

Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -321,3 +321,170 @@ async def tool_message_interceptor(
321321
assert result.content == "Custom ToolMessage response"
322322
assert result.name == "add"
323323
assert result.tool_call_id == "test-call-id"
324+
325+
326+
class TestMetadataPassthrough:
327+
"""Tests for metadata passthrough to MCP servers via interceptors."""
328+
329+
async def test_interceptor_adds_metadata(self, socket_enabled):
330+
"""Test that interceptors can add metadata to tool calls."""
331+
captured_meta = []
332+
333+
async def capture_meta_interceptor(
334+
request: MCPToolCallRequest,
335+
handler,
336+
) -> CallToolResult:
337+
# Capture the metaParams to verify it was set
338+
captured_meta.append(request.metaParams)
339+
return await handler(request)
340+
341+
async def add_meta_interceptor(
342+
request: MCPToolCallRequest,
343+
handler,
344+
) -> CallToolResult:
345+
# Add metaParams through the interceptor
346+
modified_request = request.override(
347+
metaParams={"session_id": "abc123", "user_id": "test-user"}
348+
)
349+
return await handler(modified_request)
350+
351+
with run_streamable_http(_create_math_server, 8210):
352+
tools = await load_mcp_tools(
353+
None,
354+
connection={
355+
"url": "http://localhost:8210/mcp",
356+
"transport": "streamable_http",
357+
},
358+
tool_interceptors=[add_meta_interceptor, capture_meta_interceptor],
359+
)
360+
361+
add_tool = next(tool for tool in tools if tool.name == "add")
362+
result = await add_tool.ainvoke({"a": 2, "b": 3})
363+
364+
# Tool executes successfully
365+
assert result == [{"type": "text", "text": "5", "id": IsLangChainID}]
366+
367+
# Verify that the metadata was passed through the interceptor
368+
assert len(captured_meta) == 1
369+
assert captured_meta[0] == {
370+
"session_id": "abc123",
371+
"user_id": "test-user",
372+
}
373+
374+
async def test_interceptor_modifies_metadata(self, socket_enabled):
375+
"""Test that interceptors can modify metadata for different requests."""
376+
captured_requests = []
377+
378+
async def capture_request_interceptor(
379+
request: MCPToolCallRequest,
380+
handler,
381+
) -> CallToolResult:
382+
# Capture the request state to verify metadata
383+
captured_requests.append(
384+
{
385+
"name": request.name,
386+
"args": request.args.copy(),
387+
"metaParams": request.metaParams,
388+
}
389+
)
390+
return await handler(request)
391+
392+
async def contextual_meta_interceptor(
393+
request: MCPToolCallRequest,
394+
handler,
395+
) -> CallToolResult:
396+
# Add context-specific metadata based on tool name
397+
metadata = {
398+
"tool": request.name,
399+
"timestamp": "2025-02-26T00:00:00Z",
400+
}
401+
402+
modified_request = request.override(metaParams=metadata)
403+
return await handler(modified_request)
404+
405+
with run_streamable_http(_create_math_server, 8211):
406+
tools = await load_mcp_tools(
407+
None,
408+
connection={
409+
"url": "http://localhost:8211/mcp",
410+
"transport": "streamable_http",
411+
},
412+
tool_interceptors=[
413+
contextual_meta_interceptor,
414+
capture_request_interceptor,
415+
],
416+
)
417+
418+
add_tool = next(tool for tool in tools if tool.name == "add")
419+
result = await add_tool.ainvoke({"a": 5, "b": 7})
420+
421+
# Tool executes successfully
422+
assert result == [{"type": "text", "text": "12", "id": IsLangChainID}]
423+
424+
# Verify the captured request has the metadata
425+
assert len(captured_requests) == 1
426+
captured = captured_requests[0]
427+
assert captured["name"] == "add"
428+
assert captured["args"] == {"a": 5, "b": 7}
429+
assert captured["metaParams"] == {
430+
"tool": "add",
431+
"timestamp": "2025-02-26T00:00:00Z",
432+
}
433+
434+
async def test_multiple_interceptors_modify_metadata(self, socket_enabled):
435+
"""Test that multiple interceptors can compose to build metadata."""
436+
captured_meta = []
437+
438+
async def add_correlation_interceptor(
439+
request: MCPToolCallRequest,
440+
handler,
441+
) -> CallToolResult:
442+
# First interceptor adds correlation ID
443+
meta = request.metaParams or {}
444+
meta["correlation_id"] = "corr-123"
445+
modified = request.override(metaParams=meta)
446+
return await handler(modified)
447+
448+
async def add_user_interceptor(
449+
request: MCPToolCallRequest,
450+
handler,
451+
) -> CallToolResult:
452+
# Second interceptor adds user context
453+
meta = request.metaParams or {}
454+
meta["user_id"] = "user-456"
455+
modified = request.override(metaParams=meta)
456+
return await handler(modified)
457+
458+
async def capture_meta_interceptor(
459+
request: MCPToolCallRequest,
460+
handler,
461+
) -> CallToolResult:
462+
captured_meta.append(request.metaParams)
463+
return await handler(request)
464+
465+
with run_streamable_http(_create_math_server, 8212):
466+
tools = await load_mcp_tools(
467+
None,
468+
connection={
469+
"url": "http://localhost:8212/mcp",
470+
"transport": "streamable_http",
471+
},
472+
tool_interceptors=[
473+
add_correlation_interceptor,
474+
add_user_interceptor,
475+
capture_meta_interceptor,
476+
],
477+
)
478+
479+
add_tool = next(tool for tool in tools if tool.name == "add")
480+
result = await add_tool.ainvoke({"a": 10, "b": 20})
481+
482+
# Tool executes successfully
483+
assert result == [{"type": "text", "text": "30", "id": IsLangChainID}]
484+
485+
# Verify both interceptors contributed to metadata
486+
assert len(captured_meta) == 1
487+
assert captured_meta[0] == {
488+
"correlation_id": "corr-123",
489+
"user_id": "user-456",
490+
}

0 commit comments

Comments
 (0)