Preserve MCP Result-Level `_Meta` In LangChain Tool Message Conversion
The langchain.mcp adapter drops the optional `_meta` field from MCP `CallToolResult` when converting to a LangChain `ToolMessage`, causing loss of out-of-band response metadata. The hotfix stores this metadata under a dedicated `mcp_meta` key in `response_metadata`, without conflating it with the static tool definition `_meta`.
The conversion helper extracts text and possibly structured content from the MCP `CallToolResult` but ignores the optional top-level `_meta` attribute, which the MCP SDK has already parsed. This field is not merged into the resulting LangChain tool message, so downstream consumers cannot access result-level metadata.
1. Create an MCP tool that returns a result with a top-level `_meta` field, e.g. `{"content": [{"type": "text", "text": "ok"}], "_meta": {"trace_id": "abc"}}`.
2. Invoke the tool through LangChain's MCP adapter.
3. Observe the returned `ToolMessage` (or its `response_metadata`) - the `trace_id` from `_meta` is absent or not preserved under any accessible attribute.
4. Compare with the MCP specification, which allows result-level `_meta`.
Fixing Code Block
from typing import Any
from langchain_core.messages import ToolMessage
from mcp.types import CallToolResult, TextContent
def _mcp_call_tool_result_to_tool_message(
result: CallToolResult,
*,
tool_call_id: str,
tool_name: str,
) -> ToolMessage:
"""Convert an MCP CallToolResult into a LangChain ToolMessage.
Preserves result-level MCP `_meta` so downstream consumers can access
out-of-band response metadata without conflating it with tool definition
`_meta`.
"""
# Extract text from text content blocks; other content types remain
# accessible via the original result if needed.
text_parts: list[str] = []
for block in result.content:
if isinstance(block, TextContent):
text_parts.append(block.text)
content = "\n".join(text_parts)
response_metadata: dict[str, Any] = {}
if result.isError:
response_metadata["is_error"] = True
# Preserve result-level _meta if present. Use getattr for compatibility
# with MCP SDK versions where _meta may not exist.
result_meta = getattr(result, "_meta", None)
if result_meta:
# Use a dedicated key to avoid clobbering other metadata fields.
response_metadata["mcp_meta"] = result_meta
# If there is structured content and no text content, follow the usual
# adapter behavior of using structured content as the message content.
if result.structuredContent is not None and not content:
content = str(result.structuredContent)
return ToolMessage(
content=content,
tool_call_id=tool_call_id,
name=tool_name,
response_metadata=response_metadata or None,
# Keep structuredContent accessible as an artifact if present.
artifact=result.structuredContent,
)
Replace the existing MCP result conversion helper with the provided function. The update checks `CallToolResult._meta` (using `getattr` for SDK compatibility) and stores it under `response_metadata["mcp_meta"]`. This key is clearly separated from any static tool definition `_meta`, and the metadata is preserved without altering existing content extraction or structured content handling. The function includes the standard `ToolMessage` construction and maintains the previous artifact behavior.
Edge Case Audit
If `_meta` contains non-JSON-serializable objects, downstream consumers that assume `response_metadata` is fully JSON-compatible may encounter serialization errors. Use a dedicated nested key to avoid collisions, and validate serialization before relying on this metadata in production. This change does not alter the MCP tool definition's static `_meta`. Rollback is straightforward: revert to the previous conversion helper that omits `_meta`. If your deployment caches tool messages, ensure cache keys do not depend on `_meta` to avoid stale responses.