Add meta to call tool (#2206)

* feat: Add optional meta parameter to Client tool call methods

* fix: Add support for mcp<1.19

* chore: cleaner solution

* Refactor call_tool to directly accept meta parameter and add tests for meta functionality

---------

Co-authored-by: Jeremiah Lowin <153965+jlowin@users.noreply.github.com>
This commit is contained in:
Antonio Iorga 2025-11-04 16:25:52 +01:00 committed by GitHub
commit 7e6610b7b6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 105 additions and 1 deletions

View file

@ -880,6 +880,7 @@ class Client(Generic[ClientTransportT]):
arguments: dict[str, Any],
progress_handler: ProgressHandler | None = None,
timeout: datetime.timedelta | float | int | None = None,
meta: dict[str, Any] | None = None,
) -> mcp.types.CallToolResult:
"""Send a tools/call request and return the complete MCP protocol result.
@ -891,6 +892,7 @@ class Client(Generic[ClientTransportT]):
arguments (dict[str, Any]): Arguments to pass to the tool.
timeout (datetime.timedelta | float | int | None, optional): The timeout for the tool call. Defaults to None.
progress_handler (ProgressHandler | None, optional): The progress handler to use for the tool call. Defaults to None.
meta (dict[str, Any] | None, optional): Additional metadata to send with the tool call.
Returns:
mcp.types.CallToolResult: The complete response object from the protocol,
@ -904,11 +906,13 @@ class Client(Generic[ClientTransportT]):
# Convert timeout to timedelta if needed
if isinstance(timeout, int | float):
timeout = datetime.timedelta(seconds=float(timeout))
result = await self.session.call_tool(
name=name,
arguments=arguments,
read_timeout_seconds=timeout, # ty: ignore[invalid-argument-type]
progress_callback=progress_handler or self._progress_handler,
meta=meta,
)
return result
@ -919,6 +923,7 @@ class Client(Generic[ClientTransportT]):
timeout: datetime.timedelta | float | int | None = None,
progress_handler: ProgressHandler | None = None,
raise_on_error: bool = True,
meta: dict[str, Any] | None = None,
) -> CallToolResult:
"""Call a tool on the server.
@ -929,6 +934,8 @@ class Client(Generic[ClientTransportT]):
arguments (dict[str, Any] | None, optional): Arguments to pass to the tool. Defaults to None.
timeout (datetime.timedelta | float | int | None, optional): The timeout for the tool call. Defaults to None.
progress_handler (ProgressHandler | None, optional): The progress handler to use for the tool call. Defaults to None.
raise_on_error (bool, optional): Whether to raise a ToolError if the tool call results in an error. Defaults to True.
meta (dict[str, Any] | None, optional): Additional metadata to send with the tool call.
Returns:
CallToolResult:
@ -948,6 +955,7 @@ class Client(Generic[ClientTransportT]):
arguments=arguments or {},
timeout=timeout,
progress_handler=progress_handler,
meta=meta,
)
data = None
if result.isError and raise_on_error:

View file

@ -1,6 +1,6 @@
import asyncio
import sys
from typing import cast
from typing import Any, cast
from unittest.mock import AsyncMock
import mcp
@ -148,6 +148,46 @@ async def test_call_tool_mcp(fastmcp_server):
assert "Hello, World!" in content_str
async def test_call_tool_with_meta():
"""Test that meta parameter is properly passed from client to server."""
server = FastMCP("MetaTestServer")
# Create a tool that accesses the meta from the request context
@server.tool
def check_meta() -> dict[str, Any]:
"""A tool that returns the meta from the request context."""
from fastmcp.server.dependencies import get_context
context = get_context()
meta = context.request_context.meta
# Return the meta data as a dict
if meta is not None:
return {
"has_meta": True,
"user_id": getattr(meta, "user_id", None),
"trace_id": getattr(meta, "trace_id", None),
}
return {"has_meta": False}
client = Client(transport=FastMCPTransport(server))
async with client:
# Test with meta parameter - verify the server receives it
test_meta = {"user_id": "test-123", "trace_id": "abc-def"}
result = await client.call_tool("check_meta", {}, meta=test_meta)
assert result.data["has_meta"] is True
assert result.data["user_id"] == "test-123"
assert result.data["trace_id"] == "abc-def"
# Test without meta parameter - verify fields are not present
result_no_meta = await client.call_tool("check_meta", {})
# When meta is not provided, custom fields should not be present
assert result_no_meta.data.get("user_id") is None
assert result_no_meta.data.get("trace_id") is None
async def test_list_resources(fastmcp_server):
"""Test listing resources with InMemoryClient."""
client = Client(transport=FastMCPTransport(fastmcp_server))

View file

@ -178,3 +178,59 @@ class TestContextState:
assert context1.get_state("key1") == "key1-context1"
assert context1.get_state("key-context3-only") is None
class TestContextMeta:
"""Test suite for Context meta functionality."""
def test_request_context_meta_access(self, context):
"""Test that meta can be accessed from request context."""
from mcp.server.lowlevel.server import request_ctx
from mcp.shared.context import RequestContext
# Create a mock meta object with attributes
class MockMeta:
def __init__(self):
self.user_id = "user-123"
self.trace_id = "trace-456"
self.custom_field = "custom-value"
mock_meta = MockMeta()
token = request_ctx.set(
RequestContext( # type: ignore[arg-type]
request_id=0,
meta=mock_meta, # type: ignore[arg-type]
session=MagicMock(wraps={}),
lifespan_context=MagicMock(),
)
)
# Access meta through context
retrieved_meta = context.request_context.meta
assert retrieved_meta is not None
assert retrieved_meta.user_id == "user-123"
assert retrieved_meta.trace_id == "trace-456"
assert retrieved_meta.custom_field == "custom-value"
request_ctx.reset(token)
def test_request_context_meta_none(self, context):
"""Test that context handles None meta gracefully."""
from mcp.server.lowlevel.server import request_ctx
from mcp.shared.context import RequestContext
token = request_ctx.set(
RequestContext( # type: ignore[arg-type]
request_id=0,
meta=None,
session=MagicMock(wraps={}),
lifespan_context=MagicMock(),
)
)
# Access meta through context
retrieved_meta = context.request_context.meta
assert retrieved_meta is None
request_ctx.reset(token)