"""Tests for SamplingTool.""" import pytest from mcp.server.auth.middleware.auth_context import auth_context_var from mcp.server.auth.middleware.bearer_auth import AuthenticatedUser from fastmcp.exceptions import AuthorizationError from fastmcp.server.auth import AccessToken, require_scopes from fastmcp.server.context import _current_transport from fastmcp.server.sampling import SamplingTool from fastmcp.tools.function_tool import FunctionTool from fastmcp.tools.tool_transform import ArgTransform, TransformedTool class TestSamplingToolFromFunction: """Tests for SamplingTool.from_function().""" def test_from_simple_function(self): def search(query: str) -> str: """Search the web.""" return f"Results for: {query}" tool = SamplingTool.from_function(search) assert tool.name == "search" assert tool.description == "Search the web." assert "query" in tool.parameters.get("properties", {}) assert tool.fn is search def test_from_function_with_overrides(self): def search(query: str) -> str: return f"Results for: {query}" tool = SamplingTool.from_function( search, name="web_search", description="Search the internet", ) assert tool.name == "web_search" assert tool.description == "Search the internet" def test_from_lambda_requires_name(self): with pytest.raises(ValueError, match="must provide a name for lambda"): SamplingTool.from_function(lambda x: x) def test_from_lambda_with_name(self): tool = SamplingTool.from_function(lambda x: x * 2, name="double") assert tool.name == "double" def test_from_async_function(self): async def async_search(query: str) -> str: """Async search.""" return f"Async results for: {query}" tool = SamplingTool.from_function(async_search) assert tool.name == "async_search" assert tool.description == "Async search." def test_multiple_parameters(self): def search(query: str, limit: int = 10, include_images: bool = False) -> str: """Search with options.""" return f"Results for: {query}" tool = SamplingTool.from_function(search) props = tool.parameters.get("properties", {}) assert "query" in props assert "limit" in props assert "include_images" in props class TestSamplingToolRun: """Tests for SamplingTool.run().""" async def test_run_sync_function(self): def add(a: int, b: int) -> int: """Add two numbers.""" return a + b tool = SamplingTool.from_function(add) result = await tool.run({"a": 2, "b": 3}) assert result == 5 async def test_run_async_function(self): async def async_add(a: int, b: int) -> int: """Add two numbers asynchronously.""" return a + b tool = SamplingTool.from_function(async_add) result = await tool.run({"a": 2, "b": 3}) assert result == 5 async def test_run_with_no_arguments(self): def get_value() -> str: """Return a fixed value.""" return "hello" tool = SamplingTool.from_function(get_value) result = await tool.run() assert result == "hello" async def test_run_with_none_arguments(self): def get_value() -> str: """Return a fixed value.""" return "hello" tool = SamplingTool.from_function(get_value) result = await tool.run(None) assert result == "hello" class TestSamplingToolSDKConversion: """Tests for SamplingTool._to_sdk_tool() internal method.""" def test_to_sdk_tool(self): def search(query: str) -> str: """Search the web.""" return f"Results for: {query}" tool = SamplingTool.from_function(search) sdk_tool = tool._to_sdk_tool() assert sdk_tool.name == "search" assert sdk_tool.description == "Search the web." assert "query" in sdk_tool.input_schema.get("properties", {}) class TestSamplingToolFromCallableTool: """Tests for SamplingTool.from_callable_tool().""" def test_from_function_tool(self): """Test converting a FunctionTool to SamplingTool.""" def search(query: str) -> str: """Search the web.""" return f"Results for: {query}" function_tool = FunctionTool.from_function(search) sampling_tool = SamplingTool.from_callable_tool(function_tool) assert sampling_tool.name == "search" assert sampling_tool.description == "Search the web." assert "query" in sampling_tool.parameters.get("properties", {}) # fn is now a wrapper that calls tool.run() for proper result processing assert callable(sampling_tool.fn) def test_from_function_tool_with_overrides(self): """Test converting FunctionTool with name/description overrides.""" def search(query: str) -> str: """Search the web.""" return f"Results for: {query}" function_tool = FunctionTool.from_function(search) sampling_tool = SamplingTool.from_callable_tool( function_tool, name="web_search", description="Search the internet", ) assert sampling_tool.name == "web_search" assert sampling_tool.description == "Search the internet" def test_from_transformed_tool(self): """Test converting a TransformedTool to SamplingTool.""" def original(query: str, limit: int) -> str: """Original tool.""" return f"Results for: {query} (limit: {limit})" function_tool = FunctionTool.from_function(original) transformed_tool = TransformedTool.from_tool( function_tool, name="search_transformed", transform_args={"query": ArgTransform(name="q")}, ) sampling_tool = SamplingTool.from_callable_tool(transformed_tool) assert sampling_tool.name == "search_transformed" assert sampling_tool.description == "Original tool." # The transformed tool should have 'q' instead of 'query' assert "q" in sampling_tool.parameters.get("properties", {}) assert "limit" in sampling_tool.parameters.get("properties", {}) async def test_from_function_tool_execution(self): """Test that converted FunctionTool executes correctly.""" def add(a: int, b: int) -> int: """Add two numbers.""" return a + b function_tool = FunctionTool.from_function(add) sampling_tool = SamplingTool.from_callable_tool(function_tool) result = await sampling_tool.run({"a": 2, "b": 3}) assert result == 5 async def test_from_transformed_tool_execution(self): """Test that converted TransformedTool executes correctly.""" def multiply(x: int, y: int) -> int: """Multiply two numbers.""" return x * y function_tool = FunctionTool.from_function(multiply) transformed_tool = TransformedTool.from_tool( function_tool, transform_args={"x": ArgTransform(name="a"), "y": ArgTransform(name="b")}, ) sampling_tool = SamplingTool.from_callable_tool(transformed_tool) # Use the transformed parameter names result = await sampling_tool.run({"a": 3, "b": 4}) # Result should be unwrapped from ToolResult assert result == 12 def test_from_invalid_tool_type(self): """Test that from_callable_tool rejects non-tool objects.""" class NotATool: pass with pytest.raises( TypeError, match="Expected FunctionTool or TransformedTool", ): SamplingTool.from_callable_tool(NotATool()) # type: ignore[arg-type] # ty:ignore[invalid-argument-type] def test_from_plain_function_fails(self): """Test that plain functions are rejected by from_callable_tool.""" def my_function(): pass with pytest.raises(TypeError, match="Expected FunctionTool or TransformedTool"): SamplingTool.from_callable_tool(my_function) # type: ignore[arg-type] # ty:ignore[invalid-argument-type] async def test_from_function_tool_with_output_schema(self): """Test that FunctionTool with output_schema is handled correctly.""" def search(query: str) -> dict: """Search for something.""" return {"results": ["item1", "item2"], "count": 2} # Create FunctionTool with x-fastmcp-wrap-result function_tool = FunctionTool.from_function( search, output_schema={ "type": "object", "properties": { "results": {"type": "array"}, "count": {"type": "integer"}, }, "x-fastmcp-wrap-result": True, }, ) sampling_tool = SamplingTool.from_callable_tool(function_tool) # Run the tool - should unwrap the {"result": {...}} wrapper result = await sampling_tool.run({"query": "test"}) # Should get the unwrapped dict, not ToolResult assert isinstance(result, dict) assert result == {"results": ["item1", "item2"], "count": 2} async def test_from_function_tool_without_wrap_result(self): """Test that FunctionTool without x-fastmcp-wrap-result is handled correctly.""" def get_data() -> dict: """Get some data.""" return {"status": "ok", "value": 42} # Create FunctionTool with output_schema but no wrap-result flag function_tool = FunctionTool.from_function( get_data, output_schema={ "type": "object", "properties": { "status": {"type": "string"}, "value": {"type": "integer"}, }, }, ) sampling_tool = SamplingTool.from_callable_tool(function_tool) # Run the tool - should return structured_content directly result = await sampling_tool.run({}) assert isinstance(result, dict) assert result == {"status": "ok", "value": 42} class TestSamplingToolAuthEnforcement: """Tests that auth-protected tools enforce auth when used via sampling.""" async def test_auth_protected_tool_blocked_without_token(self): """An auth-protected tool wrapped as SamplingTool must reject calls when no valid token is present in a non-stdio transport.""" def secret_action() -> str: """Do something privileged.""" return "secret" function_tool = FunctionTool.from_function( secret_action, auth=require_scopes("admin"), ) sampling_tool = SamplingTool.from_callable_tool(function_tool) transport_token = _current_transport.set("streamable-http") try: with pytest.raises(AuthorizationError, match="insufficient permissions"): await sampling_tool.run({}) finally: _current_transport.reset(transport_token) async def test_auth_protected_tool_blocked_with_wrong_scopes(self): """An auth-protected tool rejects calls when the token lacks the required scopes.""" def secret_action() -> str: """Do something privileged.""" return "secret" function_tool = FunctionTool.from_function( secret_action, auth=require_scopes("admin"), ) sampling_tool = SamplingTool.from_callable_tool(function_tool) token = AccessToken( token="test", client_id="c", scopes=["read"], expires_at=None, claims={}, ) transport_token = _current_transport.set("streamable-http") auth_token = auth_context_var.set(AuthenticatedUser(token)) try: with pytest.raises(AuthorizationError, match="insufficient permissions"): await sampling_tool.run({}) finally: auth_context_var.reset(auth_token) _current_transport.reset(transport_token) async def test_auth_protected_tool_allowed_with_correct_scopes(self): """An auth-protected tool succeeds when the token has the required scopes.""" def secret_action() -> str: """Do something privileged.""" return "secret" function_tool = FunctionTool.from_function( secret_action, auth=require_scopes("admin"), ) sampling_tool = SamplingTool.from_callable_tool(function_tool) token = AccessToken( token="test", client_id="c", scopes=["admin"], expires_at=None, claims={}, ) transport_token = _current_transport.set("streamable-http") auth_token = auth_context_var.set(AuthenticatedUser(token)) try: result = await sampling_tool.run({}) assert result == "secret" finally: auth_context_var.reset(auth_token) _current_transport.reset(transport_token) async def test_auth_protected_tool_skipped_on_stdio(self): """Auth checks are skipped for stdio transport, matching server dispatcher behavior.""" def secret_action() -> str: """Do something privileged.""" return "secret" function_tool = FunctionTool.from_function( secret_action, auth=require_scopes("admin"), ) sampling_tool = SamplingTool.from_callable_tool(function_tool) transport_token = _current_transport.set("stdio") try: result = await sampling_tool.run({}) assert result == "secret" finally: _current_transport.reset(transport_token) async def test_tool_without_auth_runs_normally(self): """Tools without auth still run without any auth context.""" def public_action() -> str: """Do something public.""" return "public" function_tool = FunctionTool.from_function(public_action) sampling_tool = SamplingTool.from_callable_tool(function_tool) result = await sampling_tool.run({}) assert result == "public" async def test_auth_protected_transformed_tool_blocked(self): """Auth checks also apply to TransformedTools with auth.""" def secret_action(x: int) -> int: """Privileged computation.""" return x * 2 function_tool = FunctionTool.from_function( secret_action, auth=require_scopes("compute"), ) transformed_tool = TransformedTool.from_tool( function_tool, transform_args={"x": ArgTransform(name="value")}, ) sampling_tool = SamplingTool.from_callable_tool(transformed_tool) transport_token = _current_transport.set("streamable-http") try: with pytest.raises(AuthorizationError, match="insufficient permissions"): await sampling_tool.run({"value": 5}) finally: _current_transport.reset(transport_token)