fastmcp icon indicating copy to clipboard operation
fastmcp copied to clipboard

Stdio Transport: client.call_tool() hangs forever when the subprocess dies unexpectedly

Open Cherwayway opened this issue 7 months ago • 4 comments

Description

When a FastMCP server started via PythonStdioTransport (or any other stdio-based transport) crashes after the MCP handshake, the client keeps awaiting the response indefinitely. No exception is propagated, which makes upstream HTTP/CLI requests appear ā€œstuckā€.

Example Code

# server.py
import os, signal
from fastmcp.server import FastMCP

mcp = FastMCP()

@mcp.tool()
def bye():
    # Simulate a fatal crash *after* the call_tool request was received
    os.kill(os.getpid(), signal.SIGKILL)

if __name__ == "__main__":
    mcp.run()

# client.py
import asyncio
from fastmcp import Client
from fastmcp.client.transports import PythonStdioTransport

async def main():
    transport = PythonStdioTransport(
        script_path="server.py",
        python_cmd="python",           # Adjust if needed
        args=["--transport", "stdio"]  # FastMCP server uses stdio transport
    )
    async with Client(transport) as client:
        print("Calling tool… this will hang ā³")
        await client.call_tool("bye", {})   # ← never returns

if __name__ == "__main__":
    asyncio.run(main())

Version Information

v2.3.4

Additional Context

Thanks for the great project! This edge-case fix would help services that embed FastMCP inside long-running HTTP endpoints survive user script crashes gracefully.

Cherwayway avatar May 19 '25 14:05 Cherwayway

Thanks for raising!

jlowin avatar May 19 '25 17:05 jlowin

Hi @Cherwayway! I ran the example you provided to reproduce the issue:


# server.py
import os, signal
from fastmcp.server import FastMCP

mcp = FastMCP()

@mcp.tool()
def bye():
    # Simulate a fatal crash *after* the call_tool request was received
    os.kill(os.getpid(), signal.SIGKILL)

if __name__ == "__main__":
    mcp.run()

#client.py
import asyncio
from fastmcp import Client
from fastmcp.client.transports import PythonStdioTransport

async def main():
    transport = PythonStdioTransport(
        script_path="server.py",
        python_cmd="python",
        args=["--transport", "stdio"]
    )
    async with Client(transport) as client:
        print("Calling tool… this will hang ā³")
        await client.call_tool("bye", {}, timeout=5) # NOTE THE TIMEOUT

if __name__ == "__main__":
    asyncio.run(main())

With a timeout set, the client no longer hangs—it raises an error. The only side effect is a ProcessLookupError during cleanup, since the server process is already gone.

Maybe FastMCP should catch this error internally to ensure a clean shutdown?

davenpi avatar May 19 '25 21:05 davenpi

Yes, setting a timeout can solve this problem, but this always requires waiting until the timeout, rather than throwing an exception when an error occurs, which will cause confusion and waste of resources. What I am doing is allowing users to customize the deployment of any MCP protocol, so it is difficult to add a fixed timeout for them.

Cherwayway avatar May 20 '25 05:05 Cherwayway

This appears to be a common issue in the low-level protocol (we are re-exposing the client connector from the low-level SDK)

jlowin avatar May 20 '25 22:05 jlowin