From 6c87758b8b82a3e48cad29652d21668eb9db8adf Mon Sep 17 00:00:00 2001 From: Antonio Iorga Date: Tue, 21 Oct 2025 13:54:38 +0200 Subject: [PATCH 1/4] feat: Add optional meta parameter to Client tool call methods --- src/fastmcp/client/client.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/fastmcp/client/client.py b/src/fastmcp/client/client.py index c1957c2776..cdf8a6cde3 100644 --- a/src/fastmcp/client/client.py +++ b/src/fastmcp/client/client.py @@ -831,6 +831,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. @@ -859,6 +860,7 @@ async def call_tool_mcp( arguments=arguments, read_timeout_seconds=timeout, progress_callback=progress_handler or self._progress_handler, + meta=meta, ) return result @@ -869,6 +871,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. @@ -898,6 +901,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: From abc02864a5591452fab3b16aa55fc09790403ecc Mon Sep 17 00:00:00 2001 From: Antonio Iorga Date: Tue, 28 Oct 2025 23:31:28 +0100 Subject: [PATCH 2/4] fix: Add support for mcp<1.19 --- src/fastmcp/client/client.py | 37 +++++++++++++++++++++++++++++------- 1 file changed, 30 insertions(+), 7 deletions(-) diff --git a/src/fastmcp/client/client.py b/src/fastmcp/client/client.py index f768bb6ad5..c77f00466f 100644 --- a/src/fastmcp/client/client.py +++ b/src/fastmcp/client/client.py @@ -3,6 +3,7 @@ import asyncio import copy import datetime +import inspect import secrets from contextlib import AsyncExitStack, asynccontextmanager from dataclasses import dataclass, field @@ -843,6 +844,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, @@ -855,13 +857,32 @@ async def call_tool_mcp( 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, - progress_callback=progress_handler or self._progress_handler, - meta=meta, - ) + + # Check if meta parameter is supported, remove this once MCP >= 1.19 is required + sig = inspect.signature(self.session.call_tool) + meta_supported = "meta" in sig.parameters + + # Include meta parameter if supported, warn user if they tried to use it but it's not supported + if meta_supported: + result = await self.session.call_tool( + name=name, + arguments=arguments, + read_timeout_seconds=timeout, + progress_callback=progress_handler or self._progress_handler, + meta=meta, + ) + else: + if meta is not None: + logger.warning( + "The 'meta' parameter is not supported by your installed version of MCP. " + "Please update to MCP >= 1.19 to use this feature. Proceeding without meta." + ) + result = await self.session.call_tool( + name=name, + arguments=arguments, + read_timeout_seconds=timeout, + progress_callback=progress_handler or self._progress_handler, + ) return result async def call_tool( @@ -882,6 +903,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: From 54e3a3bb80514be0b24eec3464b6e0348b674364 Mon Sep 17 00:00:00 2001 From: Antonio Iorga Date: Wed, 29 Oct 2025 00:02:29 +0100 Subject: [PATCH 3/4] chore: cleaner solution --- src/fastmcp/client/client.py | 47 ++++++++++++++++++------------------ 1 file changed, 23 insertions(+), 24 deletions(-) diff --git a/src/fastmcp/client/client.py b/src/fastmcp/client/client.py index c77f00466f..92e5c72fa0 100644 --- a/src/fastmcp/client/client.py +++ b/src/fastmcp/client/client.py @@ -279,6 +279,8 @@ def __init__( # Session context management - see class docstring for detailed explanation self._session_state = ClientSessionState() + # Cache for runtime feature detection + self._meta_supported: bool | None = None @property def session(self) -> ClientSession: @@ -858,31 +860,28 @@ async def call_tool_mcp( if isinstance(timeout, int | float): timeout = datetime.timedelta(seconds=float(timeout)) - # Check if meta parameter is supported, remove this once MCP >= 1.19 is required - sig = inspect.signature(self.session.call_tool) - meta_supported = "meta" in sig.parameters - - # Include meta parameter if supported, warn user if they tried to use it but it's not supported - if meta_supported: - result = await self.session.call_tool( - name=name, - arguments=arguments, - read_timeout_seconds=timeout, - progress_callback=progress_handler or self._progress_handler, - meta=meta, - ) - else: - if meta is not None: - logger.warning( - "The 'meta' parameter is not supported by your installed version of MCP. " - "Please update to MCP >= 1.19 to use this feature. Proceeding without meta." - ) - result = await self.session.call_tool( - name=name, - arguments=arguments, - read_timeout_seconds=timeout, - progress_callback=progress_handler or self._progress_handler, + # Check if meta parameter is supported (cached after first check) + if self._meta_supported is None: + sig = inspect.signature(self.session.call_tool) + self._meta_supported = "meta" in sig.parameters + + # Build call kwargs conditionally + call_kwargs: dict[str, Any] = { + "name": name, + "arguments": arguments, + "read_timeout_seconds": timeout, + "progress_callback": progress_handler or self._progress_handler, + } + + if self._meta_supported: + call_kwargs["meta"] = meta + elif meta is not None: + logger.warning( + "The 'meta' parameter is not supported by your installed version of MCP. " + "Please update to MCP >= 1.19 to use this feature. Proceeding without meta." ) + + result = await self.session.call_tool(**call_kwargs) return result async def call_tool( From dcec29094fdbc096dd560b2521796c1d2f36ff9f Mon Sep 17 00:00:00 2001 From: Antonio Iorga Date: Tue, 4 Nov 2025 12:05:37 +0100 Subject: [PATCH 4/4] Refactor call_tool to directly accept meta parameter and add tests for meta functionality --- src/fastmcp/client/client.py | 32 +++++---------------- tests/client/test_client.py | 42 ++++++++++++++++++++++++++- tests/server/test_context.py | 56 ++++++++++++++++++++++++++++++++++++ 3 files changed, 104 insertions(+), 26 deletions(-) diff --git a/src/fastmcp/client/client.py b/src/fastmcp/client/client.py index b7d42b0b5d..7059bd5c2f 100644 --- a/src/fastmcp/client/client.py +++ b/src/fastmcp/client/client.py @@ -3,7 +3,6 @@ import asyncio import copy import datetime -import inspect import secrets from contextlib import AsyncExitStack, asynccontextmanager from dataclasses import dataclass, field @@ -279,8 +278,6 @@ def __init__( # Session context management - see class docstring for detailed explanation self._session_state = ClientSessionState() - # Cache for runtime feature detection - self._meta_supported: bool | None = None @property def session(self) -> ClientSession: @@ -860,28 +857,13 @@ async def call_tool_mcp( if isinstance(timeout, int | float): timeout = datetime.timedelta(seconds=float(timeout)) - # Check if meta parameter is supported (cached after first check) - if self._meta_supported is None: - sig = inspect.signature(self.session.call_tool) - self._meta_supported = "meta" in sig.parameters - - # Build call kwargs conditionally - call_kwargs: dict[str, Any] = { - "name": name, - "arguments": arguments, - "read_timeout_seconds": timeout, - "progress_callback": progress_handler or self._progress_handler, - } - - if self._meta_supported: - call_kwargs["meta"] = meta - elif meta is not None: - logger.warning( - "The 'meta' parameter is not supported by your installed version of MCP. " - "Please update to MCP >= 1.19 to use this feature. Proceeding without meta." - ) - - result = await self.session.call_tool(**call_kwargs) + result = await self.session.call_tool( + name=name, + arguments=arguments, + read_timeout_seconds=timeout, + progress_callback=progress_handler or self._progress_handler, + meta=meta, + ) return result async def call_tool( diff --git a/tests/client/test_client.py b/tests/client/test_client.py index a32e920732..6d8f0f9ca6 100644 --- a/tests/client/test_client.py +++ b/tests/client/test_client.py @@ -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)) diff --git a/tests/server/test_context.py b/tests/server/test_context.py index 2a2e38e74b..d87a30666e 100644 --- a/tests/server/test_context.py +++ b/tests/server/test_context.py @@ -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)