Offload sync function calls to a thread
Summary
Sync tool functions registered with FastMCP were called directly on the
asyncio event loop, blocking it for the duration of the call. Any sync
tool performing I/O (e.g. using requests, time.sleep, file reads)
would stall all other concurrent tasks on the same server.
Change
In call_fn_with_arg_validation (src/mcp/server/fastmcp/utilities/func_metadata.py),
wrap sync calls with anyio.to_thread.run_sync() instead of calling
them directly:
Before:
if fn_is_async:
return await fn(**arguments_parsed_dict)
else:
return fn(**arguments_parsed_dict)
After:
if fn_is_async:
return await fn(**arguments_parsed_dict)
else:
return await anyio.to_thread.run_sync(lambda: fn(**arguments_parsed_dict))
This is consistent with how FastAPI handles sync route handlers, and how
FileResource and DirectoryResource already handle blocking reads
within this codebase.
anyio.to_thread.run_sync() uses copy_context() internally so
contextvars propagate correctly to the worker thread. The MCP Context
object is unaffected as it is passed explicitly via
arguments_to_pass_directly.
Testing
Added a test that registers a sync tool using time.sleep and asserts
that two concurrent calls complete in roughly the time of one sleep,
confirming they run in parallel without blocking the event loop.
Closes #1839