60e0ffc959
Upgrade checks / Notify on failure (push) Has been cancelled
Upgrade checks / Close issue on success (push) Has been cancelled
Schema Crash Test / Real-world schema crash test (232K schemas) (push) Has been cancelled
Run static analysis / static_analysis (push) Has been cancelled
Tests / Tests: Python 3.10 on ubuntu-latest (push) Has been cancelled
Tests / Tests: Python 3.13 on ubuntu-latest (push) Has been cancelled
Tests / Tests: Python 3.10 on windows-latest (push) Has been cancelled
Tests / Tests with lowest-direct dependencies (push) Has been cancelled
Tests / MCP conformance tests (push) Has been cancelled
Tests / Integration tests (push) Has been cancelled
Tests / Package install smoke (push) Has been cancelled
Upgrade checks / Static analysis (push) Has been cancelled
Upgrade checks / Tests: Python 3.10 on ubuntu-latest (push) Has been cancelled
Upgrade checks / Tests: Python 3.13 on ubuntu-latest (push) Has been cancelled
Upgrade checks / Tests: Python 3.10 on windows-latest (push) Has been cancelled
Upgrade checks / Integration tests (push) Has been cancelled
Update MCPServerConfig Schema / update-config-schema (push) Has been cancelled
Update SDK Documentation / update-sdk-docs (push) Has been cancelled
185 lines
6.4 KiB
Python
185 lines
6.4 KiB
Python
"""Tests for custom component subclasses with task support.
|
|
|
|
Verifies that custom Tool, Resource, and Prompt subclasses can use
|
|
background task execution by setting task_config.
|
|
"""
|
|
|
|
import asyncio
|
|
from typing import Any
|
|
from unittest.mock import MagicMock
|
|
|
|
import pytest
|
|
|
|
from fastmcp import FastMCP
|
|
from fastmcp.client import Client
|
|
from fastmcp.server.tasks import TaskConfig
|
|
from fastmcp.tools.base import Tool, ToolResult
|
|
from fastmcp.utilities.components import FastMCPComponent
|
|
|
|
|
|
class CustomTool(Tool):
|
|
"""A custom tool subclass with task support."""
|
|
|
|
task_config: TaskConfig = TaskConfig(mode="optional")
|
|
parameters: dict[str, Any] = {"type": "object", "properties": {}}
|
|
|
|
async def run(self, arguments: dict[str, Any]) -> ToolResult:
|
|
return ToolResult(content=f"Custom tool executed with {arguments}")
|
|
|
|
|
|
class CustomToolWithLogic(Tool):
|
|
"""A custom tool with actual async work."""
|
|
|
|
task_config: TaskConfig = TaskConfig(mode="optional")
|
|
parameters: dict[str, Any] = {
|
|
"type": "object",
|
|
"properties": {"duration": {"type": "integer"}},
|
|
}
|
|
|
|
async def run(self, arguments: dict[str, Any]) -> ToolResult:
|
|
duration = arguments.get("duration", 0)
|
|
await asyncio.sleep(duration * 0.01) # Short sleep for testing
|
|
return ToolResult(content=f"Completed after {duration} units")
|
|
|
|
|
|
class CustomToolForbidden(Tool):
|
|
"""A custom tool with task_config forbidden (default)."""
|
|
|
|
parameters: dict[str, Any] = {"type": "object", "properties": {}}
|
|
|
|
async def run(self, arguments: dict[str, Any]) -> ToolResult:
|
|
return ToolResult(content="Sync only")
|
|
|
|
|
|
@pytest.fixture
|
|
def custom_tool_server():
|
|
"""Create a server with custom tool subclasses."""
|
|
mcp = FastMCP("custom-tool-server")
|
|
mcp.add_tool(CustomTool(name="custom_tool", description="A custom tool"))
|
|
mcp.add_tool(
|
|
CustomToolWithLogic(name="custom_logic", description="Custom tool with logic")
|
|
)
|
|
mcp.add_tool(
|
|
CustomToolForbidden(name="custom_forbidden", description="No task support")
|
|
)
|
|
return mcp
|
|
|
|
|
|
async def test_custom_tool_sync_execution(custom_tool_server):
|
|
"""Custom tool executes synchronously when no task metadata."""
|
|
async with Client(custom_tool_server) as client:
|
|
result = await client.call_tool("custom_tool", {})
|
|
assert "Custom tool executed" in str(result)
|
|
|
|
|
|
async def test_custom_tool_background_execution(custom_tool_server):
|
|
"""Custom tool executes as background task when task=True."""
|
|
async with Client(custom_tool_server) as client:
|
|
task = await client.call_tool("custom_tool", {}, task=True)
|
|
|
|
assert task is not None
|
|
assert not task.returned_immediately
|
|
assert task.task_id is not None
|
|
|
|
# Wait for result
|
|
result = await task.result()
|
|
assert "Custom tool executed" in str(result)
|
|
|
|
|
|
async def test_custom_tool_with_arguments(custom_tool_server):
|
|
"""Custom tool receives arguments correctly in background execution."""
|
|
async with Client(custom_tool_server) as client:
|
|
task = await client.call_tool("custom_logic", {"duration": 1}, task=True)
|
|
|
|
assert task is not None
|
|
result = await task.result()
|
|
assert "Completed after 1 units" in str(result)
|
|
|
|
|
|
async def test_custom_tool_forbidden_sync_only(custom_tool_server):
|
|
"""Custom tool with forbidden mode executes sync only."""
|
|
async with Client(custom_tool_server) as client:
|
|
# Sync execution works
|
|
result = await client.call_tool("custom_forbidden", {})
|
|
assert "Sync only" in str(result)
|
|
|
|
|
|
async def test_custom_tool_forbidden_rejects_task(custom_tool_server):
|
|
"""Custom tool with forbidden mode returns error for task request."""
|
|
async with Client(custom_tool_server) as client:
|
|
task = await client.call_tool("custom_forbidden", {}, task=True)
|
|
|
|
# Should return immediately with error
|
|
assert task.returned_immediately
|
|
|
|
|
|
async def test_custom_tool_registers_with_docket():
|
|
"""Verify custom tool's register_with_docket is called during server startup."""
|
|
from unittest.mock import MagicMock
|
|
|
|
tool = CustomTool(name="test", description="test")
|
|
mock_docket = MagicMock()
|
|
|
|
tool.register_with_docket(mock_docket)
|
|
|
|
# Should register self.run with docket using prefixed key
|
|
mock_docket.register.assert_called_once()
|
|
call_args = mock_docket.register.call_args
|
|
assert call_args[1]["names"] == ["tool:test@"]
|
|
|
|
|
|
async def test_custom_tool_forbidden_does_not_register():
|
|
"""Verify custom tool with forbidden mode doesn't register with docket."""
|
|
tool = CustomToolForbidden(name="test", description="test")
|
|
mock_docket = MagicMock()
|
|
|
|
tool.register_with_docket(mock_docket)
|
|
|
|
# Should NOT register
|
|
mock_docket.register.assert_not_called()
|
|
|
|
|
|
# ==============================================================================
|
|
# Base FastMCPComponent Tests
|
|
# ==============================================================================
|
|
|
|
|
|
class TestFastMCPComponentDocketMethods:
|
|
"""Tests for base FastMCPComponent docket integration."""
|
|
|
|
def test_default_task_config_is_forbidden(self):
|
|
"""Base component defaults to task_config mode='forbidden'."""
|
|
component = FastMCPComponent(name="test")
|
|
assert component.task_config.mode == "forbidden"
|
|
|
|
def test_register_with_docket_is_noop(self):
|
|
"""Base register_with_docket does nothing (subclasses override)."""
|
|
component = FastMCPComponent(name="test")
|
|
mock_docket = MagicMock()
|
|
|
|
# Should not raise, just no-op
|
|
component.register_with_docket(mock_docket)
|
|
|
|
# Should not have called any docket methods
|
|
mock_docket.register.assert_not_called()
|
|
|
|
async def test_add_to_docket_raises_when_forbidden(self):
|
|
"""Base add_to_docket raises RuntimeError when mode is 'forbidden'."""
|
|
component = FastMCPComponent(name="test")
|
|
mock_docket = MagicMock()
|
|
|
|
with pytest.raises(RuntimeError, match="task execution not supported"):
|
|
await component.add_to_docket(mock_docket)
|
|
|
|
async def test_add_to_docket_raises_not_implemented_when_allowed(self):
|
|
"""Base add_to_docket raises NotImplementedError when not forbidden."""
|
|
component = FastMCPComponent(
|
|
name="test", task_config=TaskConfig(mode="optional")
|
|
)
|
|
mock_docket = MagicMock()
|
|
|
|
with pytest.raises(
|
|
NotImplementedError, match="does not implement add_to_docket"
|
|
):
|
|
await component.add_to_docket(mock_docket)
|