60 lines
2.4 KiB
Python
60 lines
2.4 KiB
Python
"""Boot-time guard: every registered MCP tool must be async.
|
|
|
|
Sync (`def`) tools make FastMCP run pydantic argument validation in an anyio
|
|
worker threadpool. Under concurrent calls the shared pydantic-core validator is
|
|
re-entered across threads and panics with ``pyo3_runtime.PanicException:
|
|
dictionary changed size during iteration``, which tears down the
|
|
StreamableHTTP task group and turns every later request into a 500. Async
|
|
(`async def`) tools validate on the single-threaded event loop and are safe.
|
|
|
|
This guard runs at server boot (and therefore during ``mcp-proxy gen``, which
|
|
boots every server): a non-conformant package fails fast instead of shipping a
|
|
server that can crash under load. The same check is intentionally duplicated in
|
|
each package because the bundled prod images install only that package's own
|
|
dependencies, so there is no shared runtime module to import.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import inspect
|
|
from functools import partial
|
|
from typing import Any
|
|
|
|
|
|
def _unwrap(fn: Any) -> Any:
|
|
while isinstance(fn, partial):
|
|
fn = fn.func
|
|
return fn
|
|
|
|
|
|
def find_sync_tools(mcp: Any) -> list[str]:
|
|
"""Return the names of registered tools whose function is not a coroutine."""
|
|
# mcp.server.fastmcp.FastMCP exposes a synchronous tool manager.
|
|
manager = getattr(mcp, "_tool_manager", None)
|
|
if manager is not None and hasattr(manager, "list_tools"):
|
|
return sorted(t.name for t in manager.list_tools() if not inspect.iscoroutinefunction(_unwrap(t.fn)))
|
|
|
|
# fastmcp.FastMCP exposes an async API.
|
|
async def _collect() -> list[str]:
|
|
sync: list[str] = []
|
|
for descriptor in await mcp.list_tools():
|
|
tool = await mcp.get_tool(descriptor.name)
|
|
if not inspect.iscoroutinefunction(_unwrap(tool.fn)):
|
|
sync.append(descriptor.name)
|
|
return sorted(sync)
|
|
|
|
return asyncio.run(_collect())
|
|
|
|
|
|
def assert_tools_async(mcp: Any) -> None:
|
|
"""Raise if any registered tool is synchronous."""
|
|
sync = find_sync_tools(mcp)
|
|
if sync:
|
|
raise RuntimeError(
|
|
"MCP tools must be async (`async def`). Sync tools run pydantic argument "
|
|
"validation in a worker threadpool and can trigger a pydantic-core "
|
|
"'dictionary changed size during iteration' panic under concurrent calls, "
|
|
"which kills the server. Make these tools async: " + ", ".join(sync)
|
|
)
|