Skip to content

Python: [Feature]: MCP _meta support for tool calls #5158

@Serjbory

Description

@Serjbory

Description


status: proposed
contact: "@Serjbory"
date: 2026-04-08

MCP _meta support for tool calls

What is the goal of this feature?

Enable users to pass arbitrary request metadata (_meta) to MCP servers when tools are invoked during agent execution. This supports distributed tracing correlation, user context propagation (locale, session info), and any server-specific request metadata the MCP protocol allows.

We know we're successful when users can attach per-request context (correlation IDs, locale, session info) to MCP tool calls without custom middleware or workarounds, and the metadata arrives in the MCP server's _meta field as defined by the protocol spec.

What is the problem being solved?

Today, MCPTool.call_tool() only injects OpenTelemetry trace context into the MCP _meta field. Users have no way to include additional metadata such as:

  • Request correlation IDs for debugging and distributed tracing across services
  • User context (locale, session info) for MCP servers that need request-specific behavior
  • Custom authorization context that MCP servers read from _meta rather than headers

The helper _inject_otel_into_mcp_meta() already accepts an initial meta dict parameter, but call_tool() always passes None. The plumbing exists — it's just not wired up.

Users who need this today must either fork the framework or implement fragile workarounds via function middleware that monkey-patch internal state. Both OpenAI Agents SDK and LangChain MCP Adapters have recently added _meta support, making this an expected capability.

API Changes

No new public types, parameters, or methods. The change is entirely internal to MCPTool.call_tool():

  1. Extract _meta from the incoming **kwargs (via kwargs.pop("_meta", None))
  2. Add "_meta" to the existing filtered-kwargs exclusion set (safety net so it never reaches tool arguments)
  3. Pass the extracted dict to _inject_otel_into_mcp_meta(user_meta) instead of _inject_otel_into_mcp_meta() — user keys take precedence, OTel keys fill in non-conflicting slots

The user-facing API is the existing function_invocation_kwargs parameter on agent.run(), which is the established mechanism for per-request context injection (used in 6+ existing samples for API keys, user IDs, session metadata, etc.).

E2E Code Samples

Basic: Pass correlation ID and locale

from agent_framework import Agent, MCPStreamableHTTPTool
from agent_framework.openai import OpenAIChatClient

agent = Agent(
    client=OpenAIChatClient(),
    name="assistant",
    tools=MCPStreamableHTTPTool(
        name="github",
        url="https://api.example.com/mcp",
    ),
)

# _meta is passed via the standard function_invocation_kwargs mechanism
response = await agent.run(
    "Search for Python repos",
    function_invocation_kwargs={
        "_meta": {
            "correlation_id": "req-456",
            "session_id": "abc-123",
            "locale": "fr-FR",
        },
    },
)

On the wire, the MCP call_tool request will include:

{
  "method": "tools/call",
  "params": {
    "name": "search",
    "arguments": {"query": "Python repos"},
    "_meta": {
      "correlation_id": "req-456",
      "session_id": "abc-123",
      "locale": "fr-FR",
      "traceparent": "00-abc123-def456-01"
    }
  }
}

With middleware: Inject _meta from request context

from agent_framework import Agent, FunctionMiddleware, FunctionInvocationContext

class InjectMetaMiddleware(FunctionMiddleware):
    """Middleware that adds _meta to every MCP tool call."""

    async def process(self, context: FunctionInvocationContext, call_next):
        context.kwargs["_meta"] = {
            "user_id": context.kwargs.get("user_id"),
            "tenant": "acme-corp",
        }
        await call_next()

agent = Agent(
    client=client,
    tools=mcp_tool,
    middleware=[InjectMetaMiddleware()],
)

response = await agent.run(
    "List files",
    function_invocation_kwargs={"user_id": "user-42"},
)

Combined with header_provider (MCPStreamableHTTPTool)

mcp_tool = MCPStreamableHTTPTool(
    name="secure-api",
    url="https://api.example.com/mcp",
    # header_provider injects HTTP headers (transport level)
    header_provider=lambda kwargs: {"Authorization": f"Bearer {kwargs['token']}"},
)

# _meta injects MCP protocol metadata (application level)
# Both work together — headers for auth, _meta for request context
response = await agent.run(
    "Fetch data",
    function_invocation_kwargs={
        "token": "sk-xxx",
        "_meta": {"request_id": "req-789", "locale": "en-US"},
    },
)

Behavioral details

Scenario Result
_meta provided, OTel active Both user keys and OTel keys in meta; user keys win on conflicts
_meta provided, OTel inactive Only user keys in meta
No _meta provided, OTel active OTel-only meta (existing behavior)
No _meta provided, OTel inactive meta=None (existing behavior)
_meta in kwargs Never sent as tool argument — extracted before filtering

Files to modify

File Change
python/packages/core/agent_framework/_mcp.py call_tool(): extract _meta from kwargs, add to filter set, pass to _inject_otel_into_mcp_meta()
python/packages/core/tests/core/test_mcp.py Tests: meta propagation, OTel merge, precedence, backward compat, exclusion from arguments

Test plan

  1. _meta propagated — pass _meta in kwargs, verify session.call_tool() receives it in meta=
  2. Merge with OTel — pass _meta with OTel active, verify both user and OTel keys present
  3. User wins conflicts — pass _meta with key matching OTel key, verify user value wins
  4. No _meta — call without _meta, verify existing OTel-only behavior unchanged
  5. _meta excluded from arguments — pass _meta, verify it's not in arguments dict sent to server
  6. MCPStreamableHTTPTool override — pass _meta through HTTP subclass, verify it reaches super().call_tool()

References

Code Sample

Language/SDK

Python

Metadata

Metadata

Assignees

No one assigned

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions