The .fn Problem: How FastMCP Breaks Your Test Assumptions
There’s a specific kind of frustration that comes from code that looks callable but isn’t.
I ran into it while writing tests for a set of MCP servers built with FastMCP. The pattern was everywhere: define an async function, decorate it with @mcp.tool(), and now you have a registered tool. Clean. Declarative. Elegant.
Until you try to test it.
What FastMCP Actually Does
When you write this:
@mcp.tool()
async def analyze_content(text: str) -> str:
result = await client.analyze(text)
return format_result(result)
You don’t end up with analyze_content being a callable async function anymore. FastMCP transforms it into a FunctionTool object — a wrapper that handles schema generation, parameter validation, and MCP protocol serialization.
If you try to call analyze_content("some text") in a test, you get a coroutine for the wrong thing, or an error, or silence. Not what you want.
The Seam
The fix is simple once you know it: FunctionTool objects expose the original underlying function via .fn.
# This doesn't work — calling the tool object directly
result = await analyze_content("some text")
# This works — calling the underlying function
result = await analyze_content.fn(text="some text")
The .fn attribute is the raw coroutine you defined before the decorator transformed it. It skips all the MCP machinery — which is exactly what you want in a unit test.
What You Can Test With This
With direct .fn access, unit tests look like ordinary async Python tests:
@pytest.mark.asyncio
async def test_analyze_content():
with patch("myserver.server.client") as mock_client:
mock_client.analyze = AsyncMock(return_value={"score": 0.9})
result = await analyze_content.fn(text="hello world")
mock_client.analyze.assert_called_once()
assert "0.9" in result
No MCP server needed. No HTTP requests. No containers. Just the function logic under test.
The Mocking Layer Matters
Once you’re testing via .fn, the next challenge is knowing what to mock. Three patterns I’ve encountered:
Module-level singletons — some servers create a client at import time:
client: SomeClient = SomeClient() # module-level
You mock it with patch("module.server.client").
Factory functions — some servers use a getter:
def _get_client() -> SomeClient:
return SomeClient(api_key=os.environ["KEY"])
You mock the factory: patch("module.server._get_client", return_value=mock_client).
No client at all — pure analysis functions with no external dependencies. You mock the analysis layer directly:
patch("module.server.analyze_text", return_value=mock_result)
Identifying which pattern a server uses is step one. The rest follows naturally.
AsyncMock vs MagicMock
One more gotcha: if your client has async methods, use AsyncMock. If it’s synchronous, use MagicMock. Mixing them up produces confusing failures — the mock will return a coroutine where a plain value is expected, or vice versa.
# Async client methods
mock_client.search = AsyncMock(return_value=[...])
# Sync client methods
mock_client.list_items = MagicMock(return_value=[...])
The .fn pattern isn’t documented prominently in FastMCP. You find it by reading source, or by getting frustrated enough to look. Once you know it, testing MCP servers becomes straightforward — same async/mock patterns as any other Python service, just accessed through a single extra attribute.
Find the seam. Test the logic. Let the framework handle the protocol.