AI & Agent Dev Bug Sandbox logo
AI & Agent Dev Bug Sandbox
Back to Radar

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`.

mediumConfidence 72%Langchain

Origin Analysis

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.

Ecosystem Topology