-
Notifications
You must be signed in to change notification settings - Fork 722
Description
Checks
- I have updated to the lastest minor and patch version of Strands
- I have checked the documentation and this is not expected behavior
- I have searched ./issues and there are no duplicates of my issue
Strands Version
1.30.0
Python Version
3.11.5
Operating System
macOS Tahoe 26.1
Installation Method
pip
Steps to Reproduce
Summary
Two related issues prevent _meta (per MCP spec) from reaching MCP servers when using Strands:
MCPClientnever passesmetatoClientSession.call_tool()— there is no way for users to attach custom_metato tool calls.mcp_instrumentation.pycorrupts_meta— it usesmodel_dump()instead ofmodel_dump(by_alias=True), sending"meta"instead of"_meta"on the wire.
Steps to Reproduce
Part 1: _meta not forwarded
MCPClient._create_call_tool_coroutine calls:
# strands/tools/mcp/mcp_client.py
return await cast(ClientSession, self._background_thread_session).call_tool(
name, arguments, read_timeout_seconds
)But ClientSession.call_tool supports a meta kwarg:
# mcp/client/session.py
async def call_tool(
self,
name: str,
arguments: dict[str, Any] | None = None,
read_timeout_seconds: timedelta | None = None,
progress_callback: ProgressFnT | None = None,
*,
meta: dict[str, Any] | None = None, # <-- never passed by Strands
) -> types.CallToolResult:The meta kwarg is never forwarded, so custom _meta never reaches the server.
Part 2: Instrumentation corrupts _meta
In strands/tools/mcp/mcp_instrumentation.py, the patch_mcp_client function does:
params_dict = request.root.params.model_dump() # BUG: missing by_alias=True
meta = params_dict.setdefault("_meta", {}) # creates NEW key since "meta" != "_meta"The MCP Pydantic model defines:
class CallToolRequestParams:
meta: Meta | None = Field(alias="_meta") # Python name is "meta", wire name is "_meta"So model_dump() returns {"meta": {...}} (Python field name), not {"_meta": {...}} (wire format). Then setdefault("_meta", {}) finds no "_meta" key and creates a new empty one, resulting in:
{
"meta": {"progressToken": null, "com.example/request_id": "abc-123"},
"_meta": {}
}Proof
from mcp.types import CallToolRequestParams
params = CallToolRequestParams(
name="echo",
arguments={"message": "hello"},
_meta={"com.example/request_id": "abc-123"},
)
# What strands instrumentation does:
print(params.model_dump())
# {'meta': {'progressToken': None, 'com.example/request_id': 'abc-123'}, ...}
# ^ Python field name "meta", NOT "_meta"
# What it should do:
print(params.model_dump(by_alias=True))
# {'_meta': {'progressToken': None, 'com.example/request_id': 'abc-123'}, ...}
# ^ Correct wire format "_meta"Expected Behavior
Expected Behavior
MCPClientshould expose a way to pass custom_metathrough toClientSession.call_tool(meta=...).mcp_instrumentation.pyshould usemodel_dump(by_alias=True)so_metais serialized correctly.
Actual Behavior
-
_meta is always None on the MCP server — When using Strands MCPClient to call MCP tools, the server
receives _meta: None regardless of any custom metadata the user intends to send. There is no API
surface on MCPClient to pass _meta through to ClientSession.call_tool(). -
When OpenTelemetry instrumentation is active, _meta is corrupted on the wire — The outbound JSON-RPC
payload contains both "meta" (with the original data) and "_meta" (empty {}), instead of a single
"_meta" with the correct values:
What gets sent on the wire:
"params": {
"meta": {"progressToken": null, "com.example/request_id": "abc-123"},
"_meta": {}
}
What should be sent:
"params": {
"_meta": {"progressToken": null, "com.example/request_id": "abc-123"}
}
Additional Context
No response
Possible Solution
Suggested Fix
Part 1 — Add meta parameter to call_tool_sync/call_tool_async and forward it:
# In _create_call_tool_coroutine
async def _call_tool_direct() -> MCPCallToolResult:
return await cast(ClientSession, self._background_thread_session).call_tool(
name, arguments, read_timeout_seconds, meta=meta # forward meta
)Part 2 — One-line fix in mcp_instrumentation.py:
# Before (buggy)
params_dict = request.root.params.model_dump()
# After (fixed)
params_dict = request.root.params.model_dump(by_alias=True)Related Issues
No response