Skip to content

When running mcphost in non-interactive mode (e.g., mcphost -p "some prompt") on a Windows system, the process hangs #130

@zx9597446

Description

@zx9597446

Issue Title: Non-interactive mode hangs on Windows when using stdio MCP servers

Bug Description

When running mcphost in non-interactive mode (e.g., mcphost -p "some prompt") on a Windows system, the process hangs
indefinitely after completing the request and does not exit automatically. This issue specifically occurs when one or
more local (stdio) MCP servers are configured and active. The user is forced to manually terminate the process (e.g.,
via Ctrl+C).

Environment

 Operating System*: Windows
 go install github.com/mark3labs/mcphost@latest
 Go version*: go version go1.25.1 windows/amd64

Steps to Reproduce

  1. Configure .mcphost.yml on a Windows machine to include at least one local MCP server that runs an external command.
    For example:
    yaml mcpServers: searxng: type: "local" command: ["bunx", "mcp-searxng@latest"] environment: SEARXNG_URL: "https://example.com"

  2. Run mcphost from the command line in non-interactive mode:
    shell mcphost -p "hello"

  3. Expected Behavior: The application should print the AI's response and then exit, returning control to the command
    prompt.
    The issue stems from the application's shutdown sequence when handling stdio-based child processes on Windows.

  4. Shutdown Path: The shutdown sequence is agent.Close() → toolManager.Close() → connectionPool.Close().

  5. Blocking Call: The connectionPool.Close() method iterates through all active connections and calls
    conn.client.Close() for each one. For stdio transports, this is a synchronous, blocking call that attempts to
    terminate the child process and waits for it to exit completely.

  6. Dependency Issue: The underlying mcp-go library, which mcphost uses to handle stdio transport, appears to have
    difficulty correctly terminating process trees on Windows. When it tries to close a process started by a command like
    bunx or npx, the Close() call hangs. This is likely because the child process (bunx) or a grandchild process it
    spawned (e.g., node.exe) is not exiting cleanly, and the library is stuck waiting indefinitely.

  7. Deadlock: Because the conn.client.Close() call for the first stdio server hangs, the loop in connectionPool.Close()
    is blocked. This prevents the shutdown of subsequent servers and, most importantly, prevents the Close() method from
    ever returning. This blocks the entire application shutdown sequence, causing the mcphost process to hang.
    Since the root cause lies within an external dependency (mcp-go), a workaround was implemented within mcphost to
    prevent the application from hanging.

The fix was applied to the Close() method in internal/tools/connection_pool.go.

  1. Asynchronous Close with Timeout: Instead of calling conn.client.Close() directly in the main loop, the call is now
    wrapped in a dedicated goroutine for each connection.
  2. Graceful Degradation:
  3. Synchronization: A sync.WaitGroup is used to ensure that the main Close() function waits for all the goroutines to
    either complete successfully or time out before it returns.
    Modified Code in internal/tools/connection_pool.go:

`go
// Close closes the connection pool
func (p *MCPConnectionPool) Close() error {
p.cancel()

    p.mu.Lock()
    defer p.mu.Unlock()

    var wg sync.WaitGroup
    for name, conn := range p.connections {
            wg.Add(1)
            go func(name string, conn *MCPConnection) {
                    defer wg.Done()

                    closeChan := make(chan error, 1)
                    go func() {
                            closeChan <- conn.client.Close()
                    }()

                    select {
                    case err := <-closeChan:
                            if err != nil {
                                    if p.debugLogger != nil && p.debugLogger.IsDebugEnabled() {
                                            p.debugLogger.LogDebug(fmt.Sprintf("[POOL] Failed to close connection %s: %v", name, err))
                                    }
                            }
                    case <-time.After(2 * time.Second):
                            if p.debugLogger != nil && p.debugLogger.IsDebugEnabled() {
                                    p.debugLogger.LogDebug(fmt.Sprintf("[POOL] Timeout closing connection %s, process may be orphaned.", name))
                            }
                    }
            }(name, conn)
    }
    wg.Wait()

    if p.debugLogger != nil && p.debugLogger.IsDebugEnabled() {
            p.debugLogger.LogDebug("[POOL] Connection pool closed")
    }
    return nil

}

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions