Skip to content
8 changes: 8 additions & 0 deletions src/fastmcp/client/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -880,6 +880,7 @@ async def call_tool_mcp(
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.

Expand All @@ -891,6 +892,7 @@ async def call_tool_mcp(
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,
Expand All @@ -904,11 +906,13 @@ async def call_tool_mcp(
# 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

Expand All @@ -919,6 +923,7 @@ async def call_tool(
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.

Expand All @@ -929,6 +934,8 @@ async def call_tool(
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:
Expand All @@ -948,6 +955,7 @@ async def call_tool(
arguments=arguments or {},
timeout=timeout,
progress_handler=progress_handler,
meta=meta,
)
data = None
if result.isError and raise_on_error:
Expand Down
42 changes: 41 additions & 1 deletion tests/client/test_client.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import asyncio
import sys
from typing import cast
from typing import Any, cast
from unittest.mock import AsyncMock

import mcp
Expand Down Expand Up @@ -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))
Expand Down
56 changes: 56 additions & 0 deletions tests/server/test_context.py
Original file line number Diff line number Diff line change
Expand Up @@ -178,3 +178,59 @@ async def test_context_state_inheritance(self):

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)
Loading