diff --git a/docs/development/v3-notes/v3-features.mdx b/docs/development/v3-notes/v3-features.mdx index 04fa9e343e..4a8f11a1a0 100644 --- a/docs/development/v3-notes/v3-features.mdx +++ b/docs/development/v3-notes/v3-features.mdx @@ -23,6 +23,38 @@ result = await ctx.sample( ) ``` +### Google GenAI Sampling Handler + +FastMCP now includes a sampling handler for Google's Gemini models ([#2977](https://github.com/jlowin/fastmcp/pull/2977)). This enables MCP clients to use Google's GenAI models with the sampling protocol, including full tool calling support. + +```python +from fastmcp import Client +from fastmcp.client.sampling.handlers import GoogleGenaiSamplingHandler +from google.genai import Client as GoogleGenaiClient + +# Initialize the handler +handler = GoogleGenaiSamplingHandler( + default_model="gemini-2.0-flash-exp", + client=GoogleGenaiClient(), # Optional - creates one if not provided +) + +# Use with MCP sampling (handler is configured at Client construction) +async with Client("http://server/mcp", sampling_handler=handler) as client: + result = await client.sample( + messages=[...], + tools=[...], + ) +``` + +Key features: +- Converts MCP tool schemas to Google's function calling format +- Supports all Google GenAI models that implement function calling +- Handles nullable types, nested objects, and arrays in tool schemas +- Properly maps tool choices (`auto`, `required`, `none`) to Google's configuration +- Preserves model preferences from MCP sampling parameters + +The handler joins the existing Anthropic and OpenAI handlers, providing a consistent interface for model-agnostic sampling across providers. + ### Concurrent Tool Execution in Sampling When an LLM returns multiple tool calls in a single sampling response, they can now be executed concurrently ([#3022](https://github.com/PrefectHQ/fastmcp/pull/3022)). Default behavior remains sequential; opt in with `tool_concurrency`. Tools can declare `sequential=True` to force sequential execution even when concurrency is enabled. diff --git a/docs/python-sdk/fastmcp-client-sampling-handlers-google_genai.mdx b/docs/python-sdk/fastmcp-client-sampling-handlers-google_genai.mdx new file mode 100644 index 0000000000..69d71fe3a0 --- /dev/null +++ b/docs/python-sdk/fastmcp-client-sampling-handlers-google_genai.mdx @@ -0,0 +1,17 @@ +--- +title: google_genai +sidebarTitle: google_genai +--- + +# `fastmcp.client.sampling.handlers.google_genai` + + +Google GenAI sampling handler with tool support for FastMCP 3.0. + +## Classes + +### `GoogleGenaiSamplingHandler` + + +Sampling handler that uses the Google GenAI API with tool support. + diff --git a/pyproject.toml b/pyproject.toml index d88b5a51cd..3ada527fa8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -56,13 +56,14 @@ anthropic = ["anthropic>=0.40.0"] apps = ["prefab-ui>=0.6.0"] azure = ["azure-identity>=1.16.0"] code-mode = ["pydantic-monty>=0.0.7"] +gemini = ["google-genai>=1.18.0"] openai = ["openai>=1.102.0"] tasks = ["pydocket>=0.18.0"] [dependency-groups] dev = [ "dirty-equals>=0.9.0", - "fastmcp[anthropic,apps,azure,code-mode,openai,tasks]", + "fastmcp[anthropic,apps,azure,code-mode,gemini,openai,tasks]", # add optional dependencies for fastmcp dev "fastapi>=0.115.12", "opentelemetry-sdk>=1.20.0", diff --git a/src/fastmcp/client/sampling/handlers/google_genai.py b/src/fastmcp/client/sampling/handlers/google_genai.py new file mode 100644 index 0000000000..c072d51316 --- /dev/null +++ b/src/fastmcp/client/sampling/handlers/google_genai.py @@ -0,0 +1,368 @@ +"""Google GenAI sampling handler with tool support for FastMCP 3.0.""" + +from collections.abc import Sequence +from uuid import uuid4 + +try: + from google.genai import Client as GoogleGenaiClient + from google.genai.types import ( + Candidate, + Content, + FunctionCall, + FunctionCallingConfig, + FunctionCallingConfigMode, + FunctionDeclaration, + FunctionResponse, + GenerateContentConfig, + GenerateContentResponse, + ModelContent, + Part, + ThinkingConfig, + ToolConfig, + UserContent, + ) + from google.genai.types import Tool as GoogleTool +except ImportError as e: + raise ImportError( + "The `google-genai` package is not installed. " + "Install it with `pip install fastmcp[gemini]` or add `google-genai` " + "to your dependencies." + ) from e + +from mcp import ClientSession, ServerSession +from mcp.shared.context import LifespanContextT, RequestContext +from mcp.types import ( + AudioContent, + CreateMessageResult, + CreateMessageResultWithTools, + ImageContent, + ModelPreferences, + SamplingMessage, + SamplingMessageContentBlock, + StopReason, + TextContent, + ToolChoice, + ToolResultContent, + ToolUseContent, +) +from mcp.types import CreateMessageRequestParams as SamplingParams +from mcp.types import Tool as MCPTool + +__all__ = ["GoogleGenaiSamplingHandler"] + + +class GoogleGenaiSamplingHandler: + """Sampling handler that uses the Google GenAI API with tool support. + + Example: + ```python + from google.genai import Client + from fastmcp import FastMCP + from fastmcp.client.sampling.handlers.google_genai import ( + GoogleGenaiSamplingHandler, + ) + + handler = GoogleGenaiSamplingHandler( + default_model="gemini-2.0-flash", + client=Client(), + ) + + server = FastMCP(sampling_handler=handler) + ``` + """ + + def __init__( + self, + default_model: str, + client: GoogleGenaiClient | None = None, + thinking_budget: int | None = None, + ) -> None: + self.client: GoogleGenaiClient = client or GoogleGenaiClient() + self.default_model: str = default_model + self.thinking_budget: int | None = thinking_budget + + async def __call__( + self, + messages: list[SamplingMessage], + params: SamplingParams, + context: RequestContext[ServerSession, LifespanContextT] + | RequestContext[ClientSession, LifespanContextT], + ) -> CreateMessageResult | CreateMessageResultWithTools: + contents: list[Content] = _convert_messages_to_google_genai_content(messages) + + # Convert MCP tools to Google GenAI format + google_tools: list[GoogleTool] | None = None + tool_config: ToolConfig | None = None + + if params.tools: + google_tools = [ + _convert_tool_to_google_genai(tool) for tool in params.tools + ] + tool_config = _convert_tool_choice_to_google_genai(params.toolChoice) + + # Select the model based on preferences + selected_model = self._get_model(model_preferences=params.modelPreferences) + + # Configure thinking if a budget is specified + thinking_config = ( + ThinkingConfig(thinking_budget=self.thinking_budget) + if self.thinking_budget is not None + else None + ) + + response: GenerateContentResponse = ( + await self.client.aio.models.generate_content( + model=selected_model, + contents=contents, + config=GenerateContentConfig( + system_instruction=params.systemPrompt, + temperature=params.temperature, + max_output_tokens=params.maxTokens, + stop_sequences=params.stopSequences, + thinking_config=thinking_config, + tools=google_tools, # ty: ignore[invalid-argument-type] + tool_config=tool_config, + ), + ) + ) + + # Return appropriate result type based on whether tools were provided + if params.tools: + return _response_to_result_with_tools(response, selected_model) + return _response_to_create_message_result(response, selected_model) + + def _get_model(self, model_preferences: ModelPreferences | None) -> str: + if model_preferences and model_preferences.hints: + for hint in model_preferences.hints: + if hint.name and hint.name.startswith("gemini"): + return hint.name + return self.default_model + + +def _convert_tool_to_google_genai(tool: MCPTool) -> GoogleTool: + """Convert an MCP Tool to Google GenAI format. + + Google's parameters_json_schema accepts standard JSON Schema format, + so we pass tool.inputSchema directly without conversion. + """ + return GoogleTool( + function_declarations=[ + FunctionDeclaration( + name=tool.name, + description=tool.description or "", + parameters_json_schema=tool.inputSchema, + ) + ] + ) + + +def _convert_tool_choice_to_google_genai(tool_choice: ToolChoice | None) -> ToolConfig: + """Convert MCP ToolChoice to Google GenAI ToolConfig.""" + if tool_choice is None: + return ToolConfig( + function_calling_config=FunctionCallingConfig( + mode=FunctionCallingConfigMode.AUTO + ) + ) + + if tool_choice.mode == "required": + return ToolConfig( + function_calling_config=FunctionCallingConfig( + mode=FunctionCallingConfigMode.ANY + ) + ) + if tool_choice.mode == "none": + return ToolConfig( + function_calling_config=FunctionCallingConfig( + mode=FunctionCallingConfigMode.NONE + ) + ) + + # Default to AUTO for "auto" or any other value + return ToolConfig( + function_calling_config=FunctionCallingConfig( + mode=FunctionCallingConfigMode.AUTO + ) + ) + + +def _sampling_content_to_google_genai_part( + content: TextContent + | ImageContent + | AudioContent + | ToolUseContent + | ToolResultContent, +) -> Part: + """Convert MCP content to Google GenAI Part.""" + if isinstance(content, TextContent): + return Part(text=content.text) + + if isinstance(content, ToolUseContent): + # Note: thought_signature bypass is required for manually constructed tool calls. + # Google's Gemini 3+ models enforce thought signature validation for function calls. + # Since we're constructing these Parts from MCP protocol data (not from model responses), + # they lack legitimate signatures. The bypass value allows validation to pass. + # See: https://ai.google.dev/gemini-api/docs/thought-signatures + return Part( + function_call=FunctionCall( + name=content.name, + args=content.input, + ), + thought_signature=b"skip_thought_signature_validator", + ) + + if isinstance(content, ToolResultContent): + # Extract text from tool result content + result_parts: list[str] = [] + if content.content: + for item in content.content: + if isinstance(item, TextContent): + result_parts.append(item.text) + else: + msg = f"Unsupported tool result content type: {type(item).__name__}" + raise ValueError(msg) + result_text = "".join(result_parts) + + # Extract function name from toolUseId + # Our IDs are formatted as "{function_name}_{uuid8}", so extract the name. + # Note: This is a limitation of MCP's ToolResultContent which only carries + # toolUseId, while Google's FunctionResponse requires the function name. + tool_use_id = content.toolUseId + if "_" in tool_use_id: + # Split and rejoin all but the last part (the UUID suffix) + parts = tool_use_id.rsplit("_", 1) + function_name = parts[0] + else: + # Fallback: use the full ID as the name + function_name = tool_use_id + + return Part( + function_response=FunctionResponse( + name=function_name, + response={"result": result_text}, + ) + ) + + msg = f"Unsupported content type: {type(content)}" + raise ValueError(msg) + + +def _convert_messages_to_google_genai_content( + messages: Sequence[SamplingMessage], +) -> list[Content]: + """Convert MCP messages to Google GenAI content.""" + google_messages: list[Content] = [] + + for message in messages: + content = message.content + + # Handle list content (tool calls + results) + if isinstance(content, list): + parts: list[Part] = [] + for item in content: + parts.append(_sampling_content_to_google_genai_part(item)) + + if message.role == "user": + google_messages.append(UserContent(parts=parts)) + elif message.role == "assistant": + google_messages.append(ModelContent(parts=parts)) + else: + msg = f"Invalid message role: {message.role}" + raise ValueError(msg) + continue + + # Handle single content item + part = _sampling_content_to_google_genai_part(content) + + if message.role == "user": + google_messages.append(UserContent(parts=[part])) + elif message.role == "assistant": + google_messages.append(ModelContent(parts=[part])) + else: + msg = f"Invalid message role: {message.role}" + raise ValueError(msg) + + return google_messages + + +def _get_candidate_from_response(response: GenerateContentResponse) -> Candidate: + """Extract the first candidate from a response.""" + if response.candidates and response.candidates[0]: + return response.candidates[0] + msg = "No candidate in response from completion." + raise ValueError(msg) + + +def _response_to_create_message_result( + response: GenerateContentResponse, + model: str, +) -> CreateMessageResult: + """Convert Google GenAI response to CreateMessageResult (no tools).""" + if not (text := response.text): + candidate = _get_candidate_from_response(response) + msg = f"No content in response: {candidate.finish_reason}" + raise ValueError(msg) + + return CreateMessageResult( + content=TextContent(type="text", text=text), + role="assistant", + model=model, + ) + + +def _response_to_result_with_tools( + response: GenerateContentResponse, + model: str, +) -> CreateMessageResultWithTools: + """Convert Google GenAI response to CreateMessageResultWithTools.""" + candidate = _get_candidate_from_response(response) + + # Determine stop reason and check for function calls + stop_reason: StopReason + finish_reason = candidate.finish_reason + has_function_calls = False + + if candidate.content and candidate.content.parts: + for part in candidate.content.parts: + if part.function_call is not None: + has_function_calls = True + break + + if has_function_calls: + stop_reason = "toolUse" + elif finish_reason == "STOP": + stop_reason = "endTurn" + elif finish_reason == "MAX_TOKENS": + stop_reason = "maxTokens" + else: + stop_reason = "endTurn" + + # Build content list + content: list[SamplingMessageContentBlock] = [] + + if candidate.content and candidate.content.parts: + for part in candidate.content.parts: + # Note: Skip thought parts from thinking_config - not relevant for MCP responses + if part.text: + content.append(TextContent(type="text", text=part.text)) + elif part.function_call is not None: + fc = part.function_call + fc_name: str = fc.name or "unknown" + content.append( + ToolUseContent( + type="tool_use", + id=f"{fc_name}_{uuid4().hex[:8]}", # Generate unique ID + name=fc_name, + input=dict(fc.args) if fc.args else {}, + ) + ) + + if not content: + raise ValueError("No content in response from completion") + + return CreateMessageResultWithTools( + content=content, + role="assistant", + model=model, + stopReason=stop_reason, + ) diff --git a/tests/client/sampling/handlers/test_google_genai_handler.py b/tests/client/sampling/handlers/test_google_genai_handler.py new file mode 100644 index 0000000000..92403461e5 --- /dev/null +++ b/tests/client/sampling/handlers/test_google_genai_handler.py @@ -0,0 +1,360 @@ +from unittest.mock import MagicMock + +import pytest + +try: + from google.genai import Client as GoogleGenaiClient + from google.genai.types import ( + Candidate, + FunctionCall, + FunctionCallingConfigMode, + GenerateContentResponse, + ModelContent, + Part, + UserContent, + ) + from mcp.types import ( + CreateMessageResult, + ModelHint, + ModelPreferences, + TextContent, + ToolChoice, + ToolResultContent, + ToolUseContent, + ) + + from fastmcp.client.sampling.handlers.google_genai import ( + GoogleGenaiSamplingHandler, + _convert_messages_to_google_genai_content, + _convert_tool_choice_to_google_genai, + _response_to_create_message_result, + _response_to_result_with_tools, + _sampling_content_to_google_genai_part, + ) + + GOOGLE_GENAI_AVAILABLE = True +except ImportError: + GOOGLE_GENAI_AVAILABLE = False + +pytestmark = pytest.mark.skipif( + not GOOGLE_GENAI_AVAILABLE, reason="google-genai not installed" +) + + +def test_convert_sampling_messages_to_google_genai_content(): + from mcp.types import SamplingMessage, TextContent + + msgs = _convert_messages_to_google_genai_content( + messages=[ + SamplingMessage( + role="user", content=TextContent(type="text", text="hello") + ), + SamplingMessage( + role="assistant", content=TextContent(type="text", text="ok") + ), + ], + ) + + assert len(msgs) == 2 + assert isinstance(msgs[0], UserContent) + assert isinstance(msgs[1], ModelContent) + assert msgs[0].parts[0].text == "hello" + assert msgs[1].parts[0].text == "ok" + + +def test_convert_to_google_genai_messages_raises_on_non_text(): + from mcp.types import SamplingMessage + + from fastmcp.utilities.types import Image + + with pytest.raises(ValueError): + _convert_messages_to_google_genai_content( + messages=[ + SamplingMessage( + role="user", + content=Image(data=b"abc").to_image_content(), + ) + ], + ) + + +def test_get_model(): + mock_client = MagicMock(spec=GoogleGenaiClient) + handler = GoogleGenaiSamplingHandler( + default_model="fallback-model", client=mock_client + ) + + # Test with Gemini model hint + prefs = ModelPreferences(hints=[ModelHint(name="gemini-2.0-flash-exp")]) + assert handler._get_model(prefs) == "gemini-2.0-flash-exp" + + # Test with None + assert handler._get_model(None) == "fallback-model" + + # Test with empty hints + prefs_empty = ModelPreferences(hints=[]) + assert handler._get_model(prefs_empty) == "fallback-model" + + # Test with non-Gemini hint falls back to default + prefs_other = ModelPreferences(hints=[ModelHint(name="gpt-4o")]) + assert handler._get_model(prefs_other) == "fallback-model" + + # Test with mixed hints selects first Gemini model + prefs_mixed = ModelPreferences( + hints=[ModelHint(name="claude-3.5-sonnet"), ModelHint(name="gemini-2.0-flash")] + ) + assert handler._get_model(prefs_mixed) == "gemini-2.0-flash" + + +async def test_response_to_create_message_result(): + # Create a mock response + mock_response = MagicMock(spec=GenerateContentResponse) + mock_response.text = "HELPFUL CONTENT FROM GEMINI" + + result: CreateMessageResult = _response_to_create_message_result( + response=mock_response, model="gemini-2.0-flash-exp" + ) + assert result == CreateMessageResult( + content=TextContent(type="text", text="HELPFUL CONTENT FROM GEMINI"), + role="assistant", + model="gemini-2.0-flash-exp", + ) + + +def test_convert_tool_choice_to_google_genai(): + # Test auto mode + result = _convert_tool_choice_to_google_genai(ToolChoice(mode="auto")) + assert result.function_calling_config is not None + assert result.function_calling_config.mode == FunctionCallingConfigMode.AUTO + + # Test required mode + result = _convert_tool_choice_to_google_genai(ToolChoice(mode="required")) + assert result.function_calling_config is not None + assert result.function_calling_config.mode == FunctionCallingConfigMode.ANY + + # Test none mode + result = _convert_tool_choice_to_google_genai(ToolChoice(mode="none")) + assert result.function_calling_config is not None + assert result.function_calling_config.mode == FunctionCallingConfigMode.NONE + + # Test None (defaults to auto) + result = _convert_tool_choice_to_google_genai(None) + assert result.function_calling_config is not None + assert result.function_calling_config.mode == FunctionCallingConfigMode.AUTO + + +def test_sampling_content_to_google_genai_part_tool_use(): + """Test converting ToolUseContent to Google GenAI Part with FunctionCall.""" + content = ToolUseContent( + type="tool_use", + id="get_weather_abc123", + name="get_weather", + input={"city": "London"}, + ) + + part = _sampling_content_to_google_genai_part(content) + + assert part.function_call is not None + assert part.function_call.name == "get_weather" + assert part.function_call.args == {"city": "London"} + + +def test_sampling_content_to_google_genai_part_tool_result(): + """Test converting ToolResultContent to Google GenAI Part with FunctionResponse.""" + content = ToolResultContent( + type="tool_result", + toolUseId="get_weather_abc123", + content=[TextContent(type="text", text="Weather is sunny")], + ) + + part = _sampling_content_to_google_genai_part(content) + + assert part.function_response is not None + # Function name is extracted from toolUseId by removing the UUID suffix + assert part.function_response.name == "get_weather" + assert part.function_response.response == {"result": "Weather is sunny"} + + +def test_sampling_content_to_google_genai_part_tool_result_empty(): + """Test converting empty ToolResultContent to Google GenAI Part.""" + content = ToolResultContent( + type="tool_result", + toolUseId="my_tool_xyz789", + content=[], + ) + + part = _sampling_content_to_google_genai_part(content) + + assert part.function_response is not None + assert part.function_response.name == "my_tool" + assert part.function_response.response == {"result": ""} + + +def test_sampling_content_to_google_genai_part_tool_result_no_underscore(): + """Test ToolResultContent when toolUseId has no underscore (fallback).""" + content = ToolResultContent( + type="tool_result", + toolUseId="simplefunction", + content=[TextContent(type="text", text="Result")], + ) + + part = _sampling_content_to_google_genai_part(content) + + # When no underscore, the full ID is used as the name + assert part.function_response is not None + assert part.function_response.name == "simplefunction" + + +def test_convert_messages_with_tool_use(): + """Test converting messages containing ToolUseContent.""" + from mcp.types import SamplingMessage + + msgs = _convert_messages_to_google_genai_content( + messages=[ + SamplingMessage( + role="user", + content=TextContent(type="text", text="What's the weather?"), + ), + SamplingMessage( + role="assistant", + content=ToolUseContent( + type="tool_use", + id="get_weather_123", + name="get_weather", + input={"city": "NYC"}, + ), + ), + ], + ) + + assert len(msgs) == 2 + assert isinstance(msgs[0], UserContent) + assert isinstance(msgs[1], ModelContent) + assert msgs[1].parts[0].function_call is not None + assert msgs[1].parts[0].function_call.name == "get_weather" + + +def test_convert_messages_with_tool_result(): + """Test converting messages containing ToolResultContent.""" + from mcp.types import SamplingMessage + + msgs = _convert_messages_to_google_genai_content( + messages=[ + SamplingMessage( + role="user", + content=ToolResultContent( + type="tool_result", + toolUseId="get_weather_123", + content=[TextContent(type="text", text="Sunny, 72°F")], + ), + ), + ], + ) + + assert len(msgs) == 1 + assert isinstance(msgs[0], UserContent) + assert msgs[0].parts[0].function_response is not None + assert msgs[0].parts[0].function_response.name == "get_weather" + + +def test_convert_messages_with_multiple_content_blocks(): + """Test converting messages with multiple content blocks (list content).""" + from mcp.types import SamplingMessage + + msgs = _convert_messages_to_google_genai_content( + messages=[ + SamplingMessage( + role="user", + content=[ + TextContent(type="text", text="I need weather info."), + ToolResultContent( + type="tool_result", + toolUseId="get_weather_xyz", + content=[TextContent(type="text", text="Cloudy")], + ), + ], + ), + ], + ) + + assert len(msgs) == 1 + assert isinstance(msgs[0], UserContent) + assert len(msgs[0].parts) == 2 + assert msgs[0].parts[0].text == "I need weather info." + assert msgs[0].parts[1].function_response is not None + + +def test_response_to_result_with_tools_text_only(): + """Test _response_to_result_with_tools with a text-only response.""" + mock_candidate = MagicMock(spec=Candidate) + mock_candidate.content = MagicMock() + mock_candidate.content.parts = [Part(text="Here's the answer")] + mock_candidate.finish_reason = "STOP" + + mock_response = MagicMock(spec=GenerateContentResponse) + mock_response.candidates = [mock_candidate] + + result = _response_to_result_with_tools(mock_response, model="gemini-2.0-flash") + + assert result.role == "assistant" + assert result.model == "gemini-2.0-flash" + assert result.stopReason == "endTurn" + assert isinstance(result.content, list) + assert len(result.content) == 1 + assert result.content[0].type == "text" + assert isinstance(result.content[0], TextContent) + assert result.content[0].text == "Here's the answer" + + +def test_response_to_result_with_tools_function_call(): + """Test _response_to_result_with_tools with a function call response.""" + mock_candidate = MagicMock(spec=Candidate) + mock_candidate.content = MagicMock() + mock_candidate.content.parts = [ + Part(function_call=FunctionCall(name="get_weather", args={"city": "Paris"})) + ] + mock_candidate.finish_reason = "STOP" + + mock_response = MagicMock(spec=GenerateContentResponse) + mock_response.candidates = [mock_candidate] + + result = _response_to_result_with_tools(mock_response, model="gemini-2.0-flash") + + assert result.stopReason == "toolUse" + assert isinstance(result.content, list) + assert len(result.content) == 1 + tool_use = result.content[0] + assert isinstance(tool_use, ToolUseContent) + assert tool_use.type == "tool_use" + assert tool_use.name == "get_weather" + assert tool_use.input == {"city": "Paris"} + # ID should be in format "get_weather_{uuid}" + assert tool_use.id.startswith("get_weather_") + + +def test_response_to_result_with_tools_mixed_content(): + """Test _response_to_result_with_tools with text and function call.""" + mock_candidate = MagicMock(spec=Candidate) + mock_candidate.content = MagicMock() + mock_candidate.content.parts = [ + Part(text="Let me check that for you."), + Part(function_call=FunctionCall(name="search", args={"query": "test"})), + ] + mock_candidate.finish_reason = "STOP" + + mock_response = MagicMock(spec=GenerateContentResponse) + mock_response.candidates = [mock_candidate] + + result = _response_to_result_with_tools(mock_response, model="gemini-2.0-flash") + + assert result.stopReason == "toolUse" + assert isinstance(result.content, list) + assert len(result.content) == 2 + text_content = result.content[0] + assert isinstance(text_content, TextContent) + assert text_content.type == "text" + assert text_content.text == "Let me check that for you." + tool_use = result.content[1] + assert isinstance(tool_use, ToolUseContent) + assert tool_use.type == "tool_use" + assert tool_use.name == "search" diff --git a/uv.lock b/uv.lock index db0d229ccf..5819d078ae 100644 --- a/uv.lock +++ b/uv.lock @@ -796,6 +796,9 @@ azure = [ code-mode = [ { name = "pydantic-monty" }, ] +gemini = [ + { name = "google-genai" }, +] openai = [ { name = "openai" }, ] @@ -807,7 +810,7 @@ tasks = [ dev = [ { name = "dirty-equals" }, { name = "fastapi" }, - { name = "fastmcp", extra = ["anthropic", "apps", "azure", "code-mode", "openai", "tasks"] }, + { name = "fastmcp", extra = ["anthropic", "apps", "azure", "code-mode", "gemini", "openai", "tasks"] }, { name = "inline-snapshot", extra = ["dirty-equals"] }, { name = "ipython", version = "8.38.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, { name = "ipython", version = "9.10.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, @@ -840,6 +843,7 @@ requires-dist = [ { name = "azure-identity", marker = "extra == 'azure'", specifier = ">=1.16.0" }, { name = "cyclopts", specifier = ">=4.0.0" }, { name = "exceptiongroup", specifier = ">=1.2.2" }, + { name = "google-genai", marker = "extra == 'gemini'", specifier = ">=1.18.0" }, { name = "httpx", specifier = ">=0.28.1,<1.0" }, { name = "jsonref", specifier = ">=1.1.0" }, { name = "jsonschema-path", specifier = ">=0.3.4" }, @@ -863,13 +867,13 @@ requires-dist = [ { name = "watchfiles", specifier = ">=1.0.0" }, { name = "websockets", specifier = ">=15.0.1" }, ] -provides-extras = ["anthropic", "apps", "azure", "code-mode", "openai", "tasks"] +provides-extras = ["anthropic", "apps", "azure", "code-mode", "gemini", "openai", "tasks"] [package.metadata.requires-dev] dev = [ { name = "dirty-equals", specifier = ">=0.9.0" }, { name = "fastapi", specifier = ">=0.115.12" }, - { name = "fastmcp", extras = ["anthropic", "apps", "azure", "code-mode", "openai", "tasks"] }, + { name = "fastmcp", extras = ["anthropic", "apps", "azure", "code-mode", "gemini", "openai", "tasks"] }, { name = "inline-snapshot", extras = ["dirty-equals"], specifier = ">=0.27.2" }, { name = "ipython", specifier = ">=8.12.3" }, { name = "loq", specifier = ">=0.1.0a3" }, @@ -894,6 +898,46 @@ dev = [ { name = "ty", specifier = ">=0.0.15" }, ] +[[package]] +name = "google-auth" +version = "2.48.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cryptography" }, + { name = "pyasn1-modules" }, + { name = "rsa" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0c/41/242044323fbd746615884b1c16639749e73665b718209946ebad7ba8a813/google_auth-2.48.0.tar.gz", hash = "sha256:4f7e706b0cd3208a3d940a19a822c37a476ddba5450156c3e6624a71f7c841ce", size = 326522, upload-time = "2026-01-26T19:22:47.157Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/83/1d/d6466de3a5249d35e832a52834115ca9d1d0de6abc22065f049707516d47/google_auth-2.48.0-py3-none-any.whl", hash = "sha256:2e2a537873d449434252a9632c28bfc268b0adb1e53f9fb62afc5333a975903f", size = 236499, upload-time = "2026-01-26T19:22:45.099Z" }, +] + +[package.optional-dependencies] +requests = [ + { name = "requests" }, +] + +[[package]] +name = "google-genai" +version = "1.65.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "distro" }, + { name = "google-auth", extra = ["requests"] }, + { name = "httpx" }, + { name = "pydantic" }, + { name = "requests" }, + { name = "sniffio" }, + { name = "tenacity" }, + { name = "typing-extensions" }, + { name = "websockets" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/79/f9/cc1191c2540d6a4e24609a586c4ed45d2db57cfef47931c139ee70e5874a/google_genai-1.65.0.tar.gz", hash = "sha256:d470eb600af802d58a79c7f13342d9ea0d05d965007cae8f76c7adff3d7a4750", size = 497206, upload-time = "2026-02-26T00:20:33.824Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/68/3c/3fea4e7c91357c71782d7dcaad7a2577d636c90317e003386893c25bc62c/google_genai-1.65.0-py3-none-any.whl", hash = "sha256:68c025205856919bc03edb0155c11b4b833810b7ce17ad4b7a9eeba5158f6c44", size = 724429, upload-time = "2026-02-26T00:20:32.186Z" }, +] + [[package]] name = "googleapis-common-protos" version = "1.72.0" @@ -1864,6 +1908,27 @@ redis = [ { name = "redis" }, ] +[[package]] +name = "pyasn1" +version = "0.6.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fe/b6/6e630dff89739fcd427e3f72b3d905ce0acb85a45d4ec3e2678718a3487f/pyasn1-0.6.2.tar.gz", hash = "sha256:9b59a2b25ba7e4f8197db7686c09fb33e658b98339fadb826e9512629017833b", size = 146586, upload-time = "2026-01-16T18:04:18.534Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/44/b5/a96872e5184f354da9c84ae119971a0a4c221fe9b27a4d94bd43f2596727/pyasn1-0.6.2-py3-none-any.whl", hash = "sha256:1eb26d860996a18e9b6ed05e7aae0e9fc21619fcee6af91cca9bad4fbea224bf", size = 83371, upload-time = "2026-01-16T18:04:17.174Z" }, +] + +[[package]] +name = "pyasn1-modules" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyasn1" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e9/e6/78ebbb10a8c8e4b61a59249394a4a594c1a7af95593dc933a349c8d00964/pyasn1_modules-0.4.2.tar.gz", hash = "sha256:677091de870a80aae844b1ca6134f54652fa2c8c5a52aa396440ac3106e941e6", size = 307892, upload-time = "2025-03-28T02:41:22.17Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/47/8d/d529b5d697919ba8c11ad626e835d4039be708a35b0d22de83a269a6682c/pyasn1_modules-0.4.2-py3-none-any.whl", hash = "sha256:29253a9207ce32b64c3ac6600edc75368f98473906e8fd1043bd6b5b1de2c14a", size = 181259, upload-time = "2025-03-28T02:41:19.028Z" }, +] + [[package]] name = "pycparser" version = "3.0" @@ -2688,6 +2753,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d1/b7/b95708304cd49b7b6f82fdd039f1748b66ec2b21d6a45180910802f1abf1/rpds_py-0.30.0-pp311-pypy311_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:ac37f9f516c51e5753f27dfdef11a88330f04de2d564be3991384b2f3535d02e", size = 562191, upload-time = "2025-11-30T20:24:36.853Z" }, ] +[[package]] +name = "rsa" +version = "4.9.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyasn1" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/da/8a/22b7beea3ee0d44b1916c0c1cb0ee3af23b700b6da9f04991899d0c555d4/rsa-4.9.1.tar.gz", hash = "sha256:e7bdbfdb5497da4c07dfd35530e1a902659db6ff241e39d9953cad06ebd0ae75", size = 29034, upload-time = "2025-04-16T09:51:18.218Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/64/8d/0133e4eb4beed9e425d9a98ed6e081a55d195481b7632472be1af08d2f6b/rsa-4.9.1-py3-none-any.whl", hash = "sha256:68635866661c6836b8d39430f97a996acbd61bfa49406748ea243539fe239762", size = 34696, upload-time = "2025-04-16T09:51:17.142Z" }, +] + [[package]] name = "ruff" version = "0.15.1" @@ -2815,6 +2892,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d1/b1/74babcc824a57904e919f3af16d86c08b524c0691504baf038ef2d7f655c/taskgroup-0.2.2-py2.py3-none-any.whl", hash = "sha256:e2c53121609f4ae97303e9ea1524304b4de6faf9eb2c9280c7f87976479a52fb", size = 14237, upload-time = "2025-01-03T09:24:11.41Z" }, ] +[[package]] +name = "tenacity" +version = "9.1.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/47/c6/ee486fd809e357697ee8a44d3d69222b344920433d3b6666ccd9b374630c/tenacity-9.1.4.tar.gz", hash = "sha256:adb31d4c263f2bd041081ab33b498309a57c77f9acf2db65aadf0898179cf93a", size = 49413, upload-time = "2026-02-07T10:45:33.841Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d7/c1/eb8f9debc45d3b7918a32ab756658a0904732f75e555402972246b0b8e71/tenacity-9.1.4-py3-none-any.whl", hash = "sha256:6095a360c919085f28c6527de529e76a06ad89b23659fa881ae0649b867a9d55", size = 28926, upload-time = "2026-02-07T10:45:32.24Z" }, +] + [[package]] name = "tomli" version = "2.4.0"