fastmcp/tests/server/test_providers.py
Jeremiah Lowin 19fdac7b02
Refactor provider execution: components own their execution (#2663)
* Add test_custom_subclass_tasks.py

* Refactor provider execution: delegate to middleware via wrapper components

- Remove execution methods (call_tool, read_resource, etc.) from Provider base
- Add FastMCPProvider* wrapper classes that delegate to child server middleware
- Move task routing to Tool._run() using contextvars (_task_metadata, _tool_call_key)
- Add convert_to_tool_result(result, output_schema) utility for Docket results
- Add convert_to_prompt_result() utility for prompt task results
- Pass namespaced key via add_to_docket(name=) for mounted tool lookup

* Standardize add_to_docket() with fn_key/task_key parameters

All components now use explicit fn_key (function lookup) and task_key
(result storage) parameters instead of relying on implicit key handling.
This fixes mounted component task execution where the MCP-visible key
differs from the Docket-registered function name.

* Add middleware chain tests for three-level mount hierarchy

Tests verify middleware runs at parent, child, and grandchild levels
for tools, resources, prompts, and resource templates.

* WIP: Provider refactor - unified submit_to_docket, template _read() in progress

Work in progress on refactoring execution to use component _read()/_run()/_render() methods.
Template background tasks not yet working - needs fix for Docket key lookup.

* Fix conversion functions to take full component for attribute access

Pass Tool/Prompt/Resource/Template to conversion functions instead of
individual attributes, ensuring access to serializer, output_schema,
mime_type, etc. Also fixes mixed-content output schema validation.

* Refactor: unified convert_result() methods and check_background_task helper

- Add convert_result() instance methods to all component types (Tool, Prompt, Resource, ResourceTemplate)
- Extract duplicated task routing logic into check_background_task() helper
- Fix type annotations on FastMCPProviderResource.read() and FastMCPProviderPrompt.render()
- Update protocol.py to use component.convert_result() uniformly

* Update tests to use namespace= instead of deprecated prefix= parameter
2025-12-21 15:03:24 -05:00

451 lines
16 KiB
Python

"""Tests for providers."""
from collections.abc import Sequence
from typing import Any
import pytest
from mcp.types import AnyUrl
from mcp.types import Tool as MCPTool
from fastmcp import FastMCP
from fastmcp.client import Client
from fastmcp.client.client import CallToolResult
from fastmcp.prompts.prompt import FunctionPrompt, Prompt
from fastmcp.resources.resource import FunctionResource, Resource
from fastmcp.resources.template import FunctionResourceTemplate, ResourceTemplate
from fastmcp.server.providers import Provider
from fastmcp.tools.tool import Tool, ToolResult
class SimpleTool(Tool):
"""A simple tool for testing that performs a configured operation."""
operation: str
value: int = 0
async def run(self, arguments: dict[str, Any]) -> ToolResult:
a = arguments.get("a", 0)
b = arguments.get("b", 0)
if self.operation == "add":
result = a + b + self.value
elif self.operation == "multiply":
result = a * b + self.value
else:
result = a + b
return ToolResult(
structured_content={"result": result, "operation": self.operation}
)
class SimpleToolProvider(Provider):
"""A simple provider that returns a configurable list of tools."""
def __init__(self, tools: list[Tool] | None = None):
super().__init__()
self._tools = tools or []
self.list_tools_call_count = 0
self.get_tool_call_count = 0
async def list_tools(self) -> list[Tool]:
self.list_tools_call_count += 1
return self._tools
async def get_tool(self, name: str) -> Tool | None:
self.get_tool_call_count += 1
return next((t for t in self._tools if t.name == name), None)
class ListOnlyProvider(Provider):
"""A provider that only implements list_tools (uses default get_tool)."""
def __init__(self, tools: list[Tool]):
super().__init__()
self._tools = tools
self.list_tools_call_count = 0
async def list_tools(self) -> list[Tool]:
self.list_tools_call_count += 1
return self._tools
class TestProvider:
"""Tests for Provider."""
@pytest.fixture
def base_server(self):
"""Create a base FastMCP server with static tools."""
mcp = FastMCP("BaseServer")
@mcp.tool
def static_add(a: int, b: int) -> int:
"""Add two numbers (static tool)."""
return a + b
@mcp.tool
def static_subtract(a: int, b: int) -> int:
"""Subtract two numbers (static tool)."""
return a - b
return mcp
@pytest.fixture
def dynamic_tools(self) -> list[Tool]:
"""Create dynamic tools for testing."""
return [
SimpleTool(
name="dynamic_multiply",
description="Multiply two numbers",
parameters={
"type": "object",
"properties": {
"a": {"type": "integer"},
"b": {"type": "integer"},
},
},
operation="multiply",
),
SimpleTool(
name="dynamic_add",
description="Add two numbers with offset",
parameters={
"type": "object",
"properties": {
"a": {"type": "integer"},
"b": {"type": "integer"},
},
},
operation="add",
value=100,
),
]
async def test_list_tools_includes_dynamic_tools(
self, base_server: FastMCP, dynamic_tools: list[Tool]
):
"""Test that list_tools returns both static and dynamic tools."""
provider = SimpleToolProvider(tools=dynamic_tools)
base_server.add_provider(provider)
async with Client(base_server) as client:
tools: list[MCPTool] = await client.list_tools()
# Should have all tools: 2 static + 2 dynamic
assert len(tools) == 4
tool_names = [tool.name for tool in tools]
assert "static_add" in tool_names
assert "static_subtract" in tool_names
assert "dynamic_multiply" in tool_names
assert "dynamic_add" in tool_names
async def test_list_tools_calls_provider_each_time(
self, base_server: FastMCP, dynamic_tools: list[Tool]
):
"""Test that provider.list_tools() is called on every list_tools request."""
provider = SimpleToolProvider(tools=dynamic_tools)
base_server.add_provider(provider)
async with Client(base_server) as client:
# Call list_tools multiple times
await client.list_tools()
await client.list_tools()
await client.list_tools()
# Provider should have been called 4 times
# (1 from get_tasks() during docket registration + 3 from client)
assert provider.list_tools_call_count == 4
async def test_call_dynamic_tool(
self, base_server: FastMCP, dynamic_tools: list[Tool]
):
"""Test that dynamic tools can be called successfully."""
provider = SimpleToolProvider(tools=dynamic_tools)
base_server.add_provider(provider)
async with Client(base_server) as client:
result: CallToolResult = await client.call_tool(
name="dynamic_multiply", arguments={"a": 7, "b": 6}
)
assert result.structured_content is not None
assert result.structured_content["result"] == 42 # type: ignore[attr-defined]
assert result.structured_content["operation"] == "multiply" # type: ignore[attr-defined]
async def test_call_dynamic_tool_with_config(
self, base_server: FastMCP, dynamic_tools: list[Tool]
):
"""Test that dynamic tool config (like value offset) is used."""
provider = SimpleToolProvider(tools=dynamic_tools)
base_server.add_provider(provider)
async with Client(base_server) as client:
result: CallToolResult = await client.call_tool(
name="dynamic_add", arguments={"a": 5, "b": 3}
)
assert result.structured_content is not None
# 5 + 3 + 100 (value offset) = 108
assert result.structured_content["result"] == 108 # type: ignore[attr-defined]
async def test_call_static_tool_still_works(
self, base_server: FastMCP, dynamic_tools: list[Tool]
):
"""Test that static tools still work after adding dynamic tools."""
provider = SimpleToolProvider(tools=dynamic_tools)
base_server.add_provider(provider)
async with Client(base_server) as client:
result: CallToolResult = await client.call_tool(
name="static_add", arguments={"a": 10, "b": 5}
)
assert result.structured_content is not None
assert result.structured_content["result"] == 15 # type: ignore[attr-defined]
async def test_call_tool_uses_get_tool_for_efficient_lookup(
self, base_server: FastMCP, dynamic_tools: list[Tool]
):
"""Test that call_tool uses get_tool() for efficient single-tool lookup."""
provider = SimpleToolProvider(tools=dynamic_tools)
base_server.add_provider(provider)
async with Client(base_server) as client:
await client.call_tool(name="dynamic_multiply", arguments={"a": 2, "b": 3})
# get_tool is called once for efficient lookup:
# _call_tool() calls provider.get_tool() to get the tool and execute it
# (task config is checked inside the tool's _run() method, not via a separate lookup)
# Key point: list_tools is NOT called during tool execution (efficient lookup)
assert provider.get_tool_call_count == 1
async def test_default_get_tool_falls_back_to_list(self, base_server: FastMCP):
"""Test that BaseToolProvider's default get_tool calls list_tools."""
tools = [
SimpleTool(
name="test_tool",
description="A test tool",
parameters={"type": "object", "properties": {}},
operation="add",
),
]
provider = ListOnlyProvider(tools=tools)
base_server.add_provider(provider)
async with Client(base_server) as client:
result = await client.call_tool(
name="test_tool", arguments={"a": 1, "b": 2}
)
assert result.structured_content is not None
# Default get_tool should have called list_tools
assert provider.list_tools_call_count >= 1
async def test_dynamic_tools_come_first(
self, base_server: FastMCP, dynamic_tools: list[Tool]
):
"""Test that dynamic tools appear before static tools in list."""
provider = SimpleToolProvider(tools=dynamic_tools)
base_server.add_provider(provider)
async with Client(base_server) as client:
tools: list[MCPTool] = await client.list_tools()
tool_names = [tool.name for tool in tools]
# Dynamic tools should come first
assert tool_names[:2] == ["dynamic_multiply", "dynamic_add"]
async def test_empty_provider(self, base_server: FastMCP):
"""Test that empty provider doesn't affect behavior."""
provider = SimpleToolProvider(tools=[])
base_server.add_provider(provider)
async with Client(base_server) as client:
tools: list[MCPTool] = await client.list_tools()
# Should only have static tools
assert len(tools) == 2
async def test_tool_not_found_falls_through_to_static(
self, base_server: FastMCP, dynamic_tools: list[Tool]
):
"""Test that unknown tool name falls through to static tools."""
provider = SimpleToolProvider(tools=dynamic_tools)
base_server.add_provider(provider)
async with Client(base_server) as client:
# This tool is static, not in the dynamic provider
result: CallToolResult = await client.call_tool(
name="static_subtract", arguments={"a": 10, "b": 3}
)
assert result.structured_content is not None
assert result.structured_content["result"] == 7 # type: ignore[attr-defined]
class TestProviderClass:
"""Tests for the Provider class."""
async def test_subclass_is_instance(self):
"""Test that subclasses are instances of Provider."""
provider = SimpleToolProvider(tools=[])
assert isinstance(provider, Provider)
async def test_default_get_tool_works(self):
"""Test that the default get_tool implementation works."""
tool = SimpleTool(
name="test",
description="Test",
parameters={"type": "object", "properties": {}},
operation="add",
)
provider = ListOnlyProvider(tools=[tool])
# Default get_tool should find by name
found = await provider.get_tool("test")
assert found is not None
assert found.name == "test"
# Should return None for unknown names
not_found = await provider.get_tool("unknown")
assert not_found is None
class TestDynamicToolUpdates:
"""Tests demonstrating dynamic tool updates without restart."""
async def test_tools_update_without_restart(self):
"""Test that tools can be updated dynamically."""
mcp = FastMCP("DynamicServer")
# Start with one tool
initial_tools = [
SimpleTool(
name="tool_v1",
description="Version 1",
parameters={"type": "object", "properties": {}},
operation="add",
),
]
provider = SimpleToolProvider(tools=initial_tools)
mcp.add_provider(provider)
async with Client(mcp) as client:
tools = await client.list_tools()
assert len(tools) == 1
assert tools[0].name == "tool_v1"
# Update the provider's tools (simulating DB update)
provider._tools = [
SimpleTool(
name="tool_v2",
description="Version 2",
parameters={"type": "object", "properties": {}},
operation="multiply",
),
SimpleTool(
name="tool_v3",
description="Version 3",
parameters={"type": "object", "properties": {}},
operation="add",
),
]
# List tools again - should see new tools
tools = await client.list_tools()
assert len(tools) == 2
tool_names = [t.name for t in tools]
assert "tool_v1" not in tool_names
assert "tool_v2" in tool_names
assert "tool_v3" in tool_names
class TestProviderExecutionMethods:
"""Tests for Provider execution methods (call_tool, read_resource, render_prompt)."""
async def test_call_tool_default_implementation(self):
"""Test that default call_tool uses get_tool and runs the tool."""
tool = SimpleTool(
name="test_tool",
description="Test",
parameters={"type": "object", "properties": {"a": {}, "b": {}}},
operation="add",
)
provider = SimpleToolProvider(tools=[tool])
mcp = FastMCP("TestServer")
mcp.add_provider(provider)
async with Client(mcp) as client:
result = await client.call_tool("test_tool", {"a": 1, "b": 2})
assert result.structured_content is not None
assert result.structured_content["result"] == 3 # type: ignore[attr-defined]
async def test_read_resource_default_implementation(self):
"""Test that default read_resource uses get_resource and reads it."""
class ResourceProvider(Provider):
async def list_resources(self) -> Sequence[Resource]:
return [
FunctionResource(
uri=AnyUrl("test://data"),
name="Test Data",
fn=lambda: "hello world",
)
]
provider = ResourceProvider()
mcp = FastMCP("TestServer")
mcp.add_provider(provider)
async with Client(mcp) as client:
result = await client.read_resource("test://data")
assert len(result) == 1
assert result[0].text == "hello world"
async def test_read_resource_template_default(self):
"""Test that read_resource_template handles template-based resources."""
class TemplateProvider(Provider):
async def list_resource_templates(self) -> Sequence[ResourceTemplate]:
return [
FunctionResourceTemplate.from_function(
fn=lambda name: f"content of {name}",
uri_template="data://files/{name}",
name="Data Template",
)
]
provider = TemplateProvider()
mcp = FastMCP("TestServer")
mcp.add_provider(provider)
async with Client(mcp) as client:
result = await client.read_resource("data://files/test.txt")
assert len(result) == 1
assert result[0].text == "content of test.txt"
async def test_render_prompt_default_implementation(self):
"""Test that default render_prompt uses get_prompt and renders it."""
class PromptProvider(Provider):
async def list_prompts(self) -> Sequence[Prompt]:
return [
FunctionPrompt.from_function(
fn=lambda name: f"Hello, {name}!",
name="greeting",
description="Greet someone",
)
]
provider = PromptProvider()
mcp = FastMCP("TestServer")
mcp.add_provider(provider)
async with Client(mcp) as client:
result = await client.get_prompt("greeting", {"name": "World"})
assert len(result.messages) == 1
assert result.messages[0].content.text == "Hello, World!" # type: ignore[attr-defined]