fastmcp/tests/server/proxy/test_stateful_proxy_client.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

147 lines
4.9 KiB
Python

import asyncio
import pytest
from anyio import create_task_group
from mcp.types import LoggingLevel
from fastmcp import Client, Context, FastMCP
from fastmcp.client.logging import LogMessage
from fastmcp.client.transports import FastMCPTransport
from fastmcp.exceptions import ToolError
from fastmcp.server.proxy import FastMCPProxy, StatefulProxyClient
from fastmcp.utilities.tests import find_available_port
@pytest.fixture
def fastmcp_server():
mcp = FastMCP("TestServer")
states: dict[int, int] = {}
@mcp.tool
async def log(
message: str, level: LoggingLevel, logger: str, context: Context
) -> None:
await context.log(message=message, level=level, logger_name=logger)
@mcp.tool
async def stateful_put(value: int, context: Context) -> None:
"""put a value associated with the server session"""
key = id(context.session)
states[key] = value
@mcp.tool
async def stateful_get(context: Context) -> int:
"""get the value associated with the server session"""
key = id(context.session)
try:
return states[key]
except KeyError:
raise ToolError("Value not found")
return mcp
@pytest.fixture
async def stateful_proxy_server(fastmcp_server: FastMCP):
client = StatefulProxyClient(transport=FastMCPTransport(fastmcp_server))
return FastMCPProxy(client_factory=client.new_stateful)
@pytest.fixture
async def stateless_server(stateful_proxy_server: FastMCP):
port = find_available_port()
url = f"http://127.0.0.1:{port}/mcp/"
task = asyncio.create_task(
stateful_proxy_server.run_http_async(
host="127.0.0.1", port=port, stateless_http=True
)
)
await stateful_proxy_server._started.wait()
yield url
task.cancel()
try:
await task
except asyncio.CancelledError:
pass
class TestStatefulProxyClient:
async def test_concurrent_log_requests_no_mixing(
self, stateful_proxy_server: FastMCP
):
"""Test that concurrent log requests don't mix handlers (fixes #1068)."""
results: dict[str, LogMessage] = {}
async def log_handler_a(message: LogMessage) -> None:
results["logger_a"] = message
async def log_handler_b(message: LogMessage) -> None:
results["logger_b"] = message
async with (
Client(stateful_proxy_server, log_handler=log_handler_a) as client_a,
Client(stateful_proxy_server, log_handler=log_handler_b) as client_b,
):
async with create_task_group() as tg:
tg.start_soon(
client_a.call_tool,
"log",
{"message": "Hello, world!", "level": "info", "logger": "a"},
)
tg.start_soon(
client_b.call_tool,
"log",
{"message": "Hello, world!", "level": "info", "logger": "b"},
)
assert results["logger_a"].logger == "a"
assert results["logger_b"].logger == "b"
async def test_stateful_proxy(self, stateful_proxy_server: FastMCP):
"""Test that the state shared across multiple calls for the same client (fixes #959)."""
async with Client(stateful_proxy_server) as client:
with pytest.raises(ToolError, match="Value not found"):
await client.call_tool("stateful_get", {})
await client.call_tool("stateful_put", {"value": 1})
result = await client.call_tool("stateful_get", {})
assert result.data == 1
async def test_stateless_proxy(self, stateless_server: str):
"""Test that the state will not be shared across different calls,
even if they are from the same client."""
async with Client(stateless_server) as client:
await client.call_tool("stateful_put", {"value": 1})
with pytest.raises(ToolError, match="Value not found"):
await client.call_tool("stateful_get", {})
async def test_multi_proxies_no_mixing(self):
"""Test that the stateful proxy client won't be mixed in multi-proxies sessions."""
mcp_a, mcp_b = FastMCP(), FastMCP()
@mcp_a.tool
def tool_a() -> str:
return "a"
@mcp_b.tool
def tool_b() -> str:
return "b"
proxy_mcp_a = FastMCPProxy(
client_factory=StatefulProxyClient(mcp_a).new_stateful
)
proxy_mcp_b = FastMCPProxy(
client_factory=StatefulProxyClient(mcp_b).new_stateful
)
multi_proxy_mcp = FastMCP()
multi_proxy_mcp.mount(proxy_mcp_a, namespace="a")
multi_proxy_mcp.mount(proxy_mcp_b, namespace="b")
async with Client(multi_proxy_mcp) as client:
result_a = await client.call_tool("a_tool_a", {})
result_b = await client.call_tool("b_tool_b", {})
assert result_a.data == "a"
assert result_b.data == "b"