Skip to content

MCP Tool execution hangs indefinitely in stdio mode when calling external Python scripts #671

Description

@ycycycl

Describe the bug
When using FastMCP in stdio transport mode, any tool that attempts to execute an external Python script will hang indefinitely until timeout, without returning any result or error. The same code works perfectly when using SSE transport mode.

To Reproduce
Steps to reproduce the behavior:

  1. Create a minimal external script (minimal_script.py) that simply prints output and exits
import sys

def main():
    print("Successfully Call!")
    return 0

if __name__ == "__main__":
    sys.exit(main())
  1. Create an MCP server with a tool that calls this external script
import os
import sys
import asyncio
import subprocess
from typing import Dict, Any

from mcp.server.fastmcp import FastMCP

# Initialize MCP
mcp = FastMCP("bug_demo")

@mcp.tool()
async def call_external_script() -> Dict[str, Any]:
    """Call external script and return result"""
    # Get script path
    current_dir = os.path.dirname(os.path.abspath(__file__))
    script_path = os.path.join(current_dir, "minimal_script.py")
    
    # Build command
    cmd = [sys.executable, script_path]
    
    try:
        # Execute external command
        process = await asyncio.create_subprocess_exec(
            *cmd,
            stdout=subprocess.PIPE,
            stderr=subprocess.PIPE
        )
        
        # Wait for process to complete and get output
        stdout, stderr = await process.communicate()
        
        return {
            "success": process.returncode == 0,
            "stdout": stdout.decode().strip(),
            "stderr": stderr.decode().strip(),
            "return_code": process.returncode
        }
    except Exception as e:
        return {
            "success": False,
            "error": str(e)
        }

if __name__ == "__main__":
    mcp.run(transport="stdio")
    # mcp.run(transport="sse")
  1. Run the server in stdio mode
  2. Call the tool (Using MCP Inspector) that executes the external script
  3. Observe that the tool call hangs indefinitely with no response

Expected behavior
The tool should execute the external Python script, capture its output, and return the results promptly, regardless of the transport mode used (stdio or SSE).

Screenshots
When using stdio, the tool will hang indefinitely until timeout.
Image

However, correct results can be obtained by using SSE mode.
Image

Desktop (please complete the following information):

  • OS: Windows 11 24H2 26100.3775
  • Python 3.10.0
  • mcp 1.7.1
  • asyncio 3.4.3

Additional context
· The issue persists regardless of whether using asyncio.create_subprocess_exec() or subprocess.run().
· I tried printing something in an external py script and found that when calling the tool in STDIO mode, it would be unresponsive, but after timeout, the external py script would be executed and something would be printed out. This is like the running of a py script being blocked until the tool call times out before it can run

Activity

  1. DanielAvdar commented on May 13, 2025

    @DanielAvdar
    Contributor

    @ycycycl
    take a look here: dev-kit-mcp-server
    it works smoothly.

  2. Hellwz commented on May 17, 2025

    @Hellwz

    Met the exact same issue here. The function mentioned by @DanielAvdar solved it perfectly. Thanks a lot!

  3. atongmuuu commented on May 27, 2025

    @atongmuuu

    @Hellwz
    Hello, could you provide me with an example to have a look?
    The code I wrote by referring to that function still has problems and reports the following error.

    the code:

    async def create_sub_proccess(cmd: str) -> asyncio.subprocess.Process:
        """Create a subprocess to execute a shell command.
    
        Args:
            cmd: The shell command to execute
    
        Returns:
            A subprocess object with stdout and stderr pipes
    
        """
        process_get = await asyncio.create_subprocess_shell(
            cmd,
            cwd=field(init=False, repr=False).as_posix(),
            stdin=asyncio.subprocess.DEVNULL,
            stdout=asyncio.subprocess.PIPE,
            stderr=asyncio.subprocess.PIPE,
        )
        return process_get
    
    
    @mcp.tool()
    async def plot_by_script(script: str, path: str) -> str:
        """
        Draw through a python script in the form of command-line parameters, input the python script path and file path, draw the image and convert it to base64 format to return.
        :param script: python scripts can pass in a parameter to specify the data path
        :param path: file path
        :return: image base64
        """
        cmd = f"python {script} {path}"
        process = await create_sub_proccess(cmd)
        stdout, stderr = await process.communicate()
        res = {
            "stdout": stdout.decode(errors="replace"),
            "stderr": stderr.decode(errors="replace"),
            "exitcode": process.returncode,
            "cwd": field(init=False, repr=False).as_posix(),
        }
        return res["stdout"]
    

    the error:

    ⚠️ error: 'coroutine' object has no attribute 'content'
    /project/mcp/client.py:151: RuntimeWarning: coroutine 'ClientSession.call_tool' was never awaited
      print(f"\n⚠️ error: {str(e)}")
    RuntimeWarning: Enable tracemalloc to get the object allocation traceback
    
    
  4. Hellwz commented on May 27, 2025

    @Hellwz

    Hi @atongmuuu,
    I made simple modifications based on your script and used it to call a very simple Python script. It works just fine. Hope it helps.

    async def create_sub_process(cmd: str, cwd: str) -> asyncio.subprocess.Process:
        """Create a subprocess to execute a shell command."""
        process_get = await asyncio.create_subprocess_shell(
            cmd,
            cwd=cwd,
            stdin=asyncio.subprocess.DEVNULL,
            stdout=asyncio.subprocess.PIPE,
            stderr=asyncio.subprocess.PIPE,
        )
        return process_get
    
    @mcp.tool()
    async def call_script(script_path: str) -> str:
        """Call an external script."""
        current_dir = os.path.dirname(os.path.abspath(__file__))
        cmd = f"{sys.executable} {script_path}"
        process = await create_sub_process(cmd, current_dir)
        stdout, stderr = await process.communicate()
        res = {
            "stdout": stdout.decode(errors="replace"),
            "stderr": stderr.decode(errors="replace"),
            "exitcode": process.returncode,
            "cwd": current_dir,
        }
        return res["stdout"]
    
  5. atongmuuu commented on May 28, 2025

    @atongmuuu

    @Hellwz
    Thank you. I changed the code later and the script has been successfully called.

  6. Gallaecio commented on Aug 29, 2025

    @Gallaecio

    I can:

    • Reproduce the issue.
    • Confirm it is a Windows-only issue.
    • Confirm stdin=DEVNULL works as a workaround.
  7. added
    bugSomething isn't working
    ready for workEnough information for someone to start working on
    P3Nice to haves, rare edge cases
    on Oct 3, 2025
  8. AndreKalberer commented on Jul 8, 2026

    @AndreKalberer
    Contributor

    I would like to work on this. I’ll first reproduce the Windows stdio behavior on current main, then keep any PR scoped to the smallest fix or docs/tests needed.

    Disclosure: I may use AI assistance while working, but I will review and own the final change.

  9. added 4 commits that reference this issue on Jul 16, 2026
    1eec799
    e1d6e64
    bf24e5e
    42ef19a
  10. added a commit that references this issue on Jul 24, 2026
    fbe9841
  11. maxisbey commented on Jul 25, 2026

    @maxisbey
    Contributor

    Fixed on main in #3117 (merge commit 629ca29), shipping with v2.

    The root cause here was that a tool-spawned child inherited the server's stdin - the protocol pipe - and, on Windows, hung in interpreter startup reading it (CPython gh-78961). stdio_server() now moves the wire onto private descriptors while serving and points fd 0 at an end-of-file source and fd 1 at stderr, so a child spawned from a handler reads EOF instead of hanging, and stray output can't corrupt the stream either. There's a regression test for this exact scenario that fails on the pre-fix code and passes on main.

    If anyone still hits this on v2, please open a fresh issue with the repro.

    AI Disclaimer

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    P3Nice to haves, rare edge casesbugSomething isn't workingready for workEnough information for someone to start working on

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions