#2546·mcp-use

client: NodeOAuthClientProvider hangs event loop on browser keep-alive sockets after callback completion

Author: rohith500Created Sep 14, 2026Updated Sep 15, 2026
LabelsbugTypeScriptserverclient

Description

In @mcp-use/client, NodeOAuthClientProvider manages a temporary localhost HTTP server to listen for OAuth authorization callbacks (/callback) and serve launcher redirects (/authorize).

When the browser receives a response from the loopback server, the process often hangs or fails to exit cleanly because:

  1. Missing Connection: close Header: Modern web browsers (Chrome, Safari, Edge) default to persistent HTTP/1.1 keep-alive connections. Because handleCallback omits the Connection: close header on responses (including 200 SUCCESS_HTML, 400 FAILURE_HTML, and redirects), the browser's TCP socket remains open in an active keep-alive state.
  2. server.close() Does Not Terminate Existing Sockets: In stopLoopback():
    typescript
    private stopLoopback(): void {
      if (this.pendingTimer) {
        clearTimeout(this.pendingTimer);
        this.pendingTimer = null;
      }
      if (this.server) {
        this.server.close();
        this.server = null;
      }
      this.authorizationUrl = null;
    }
    Under Node.js semantics, http.Server.close([callback]) stops accepting new connections, but does not close existing client connections. It waits indefinitely for connected clients to close their keep-alive sockets or for the keep-alive idle timeout (which can range from minutes in browsers to infinite in misbehaving clients) to elapse. Consequently, the Node.js event loop remains active, causing CLI tools and test suites to hang after authorization succeeds.

Steps to Reproduce

  1. Initialize NodeOAuthClientProvider and trigger authorization:
    typescript
    const provider = await NodeOAuthClientProvider.create("https://mcp.example.com");
    await provider.redirectToAuthorization(new URL("https://auth.example.com/authorize?state=xyz"));
  2. Open a persistent TCP connection to the callback server with HTTP/1.1 Keep-Alive:
    typescript
    const socket = net.connect(provider.callbackPort, "127.0.0.1");
    socket.write("GET /callback?code=abc&state=xyz HTTP/1.1\r\nHost: 127.0.0.1\r\n\r\n");
  3. Await authorization response:
    typescript
    const response = await provider.getAuthorizationResponse();
  4. Observe that provider.stopLoopback() calls this.server.close(), but the underlying TCP connection remains open in keep-alive mode, preventing Node's event loop from winding down.

Expected Behavior

  1. The loopback HTTP server should explicitly return Connection: close on all responses to signal the browser and HTTP clients to close their socket immediately upon receiving the response.
  2. In stopLoopback() and error teardowns, NodeOAuthClientProvider should track active sockets and invoke server.closeAllConnections() (or socket.destroy()) so that keep-alive or in-flight connections are terminated without waiting for browser idle timeouts.

Proposed Solution

  1. Send Connection: close on all responses in handleCallback:
    typescript
    res.setHeader("connection", "close");
  2. Track open sockets in startLoopback:
    typescript
    private readonly sockets: Set<Socket> = new Set();
    
    server.on("connection", (socket: Socket) => {
      this.sockets.add(socket);
      socket.once("close", () => this.sockets.delete(socket));
    });
  3. In stopLoopback(), invoke server.closeAllConnections?.() and destroy all tracked sockets:
    typescript
    if (typeof server.closeAllConnections === "function") {
      server.closeAllConnections();
    }
    for (const socket of this.sockets) {
      socket.destroy();
    }
    this.sockets.clear();