charmbracelet/crush · error

failed to start OAuth callback listener: all candidate ports

Error message

failed to start OAuth callback listener: all candidate ports in use

What it means

NewHandler could not bind its local HTTP callback listener: every candidate port was already in use. The handler needs a localhost port to receive the OAuth redirect, so without one it cannot start the authorization flow.

Source

Thrown at internal/oauth/mcp/handler.go:149

	// first free candidate, just long enough to learn which is open. Either
	// way the chosen port is baked into the redirect URI below and pinned on
	// the receiver, so bindLocked always rebinds the SAME port. The probe is
	// not a reservation — another process can take the port before the first
	// real login — but a busy port then fails loudly rather than silently
	// binding a port the redirect URI does not point at.
	port := callbackPort
	if port == 0 {
		lc := &net.ListenConfig{}
		for _, p := range callbackPorts {
			probe, err := lc.Listen(context.Background(), "tcp", fmt.Sprintf("localhost:%d", p))
			if err == nil {
				_ = probe.Close()
				port = p
				break
			}
		}
		if port == 0 {
			return nil, errors.New("failed to start OAuth callback listener: all candidate ports in use")
		}
	}
	receiver.fixedPort = port

	redirectURL := fmt.Sprintf("http://localhost:%d%s", port, callbackPath)

	h := &Handler{
		receiver:       receiver,
		serverURL:      serverURL,
		openURL:        browser.OpenURL,
		interactive:    interactive,
		onTokenRefresh: onTokenRefresh,
	}
	receiver.handler = h

	// newTokenSource is the SDK hook invoked once after a successful code
	// exchange. The token it hands us is brand new, so persist it right
	// away, then wrap the source so later refreshes persist on change. The

View on GitHub (pinned to 7944b8e522)

Solutions

  1. Find and stop the process holding the port (lsof -i :<port> / ss -ltnp) or wait for it to exit.
  2. Remove or change any configured fixed callback port so the handler can pick a free port from the candidate range.
  3. Retry creating the handler after the conflicting process exits.
  4. Run only one crush instance that performs MCP OAuth at a time.

Example fix

// before
handler, err := oauth.NewHandler(cfg) // fails when fixed port is taken
// after
listener, err := net.Listen("tcp", "localhost:0")
if err != nil { return err }
listener.Close() // probe a free port first, or drop fixedPort from cfg
cfg.CallbackPort = 0
handler, err := oauth.NewHandler(cfg)
Defensive patterns

Strategy: fallback

Validate before calling

ln, err := net.Listen("tcp", "localhost:0")
if err != nil { /* ports exhausted or networking broken */ }
ln.Close()

Try / catch

handler, err := oauth.NewHandler(cfg)
if err != nil && strings.Contains(err.Error(), "ports in use") {
    freeConflictingPort()
    handler, err = oauth.NewHandler(cfg)
}

Prevention

When it happens

Trigger: Creating a new MCP OAuth handler when all candidate callback ports (including the fixed port when configured) fail to bind — typically because another process holds them.

Common situations: Running multiple crush instances simultaneously, a stale crashed process still holding the port, or a configured fixed callback port occupied by another service.

Related errors


AI-assisted analysis of charmbracelet/crush@7944b8e522 (2026-08-29). Data as JSON: /api/errors/412559617052b51f. Report an issue: GitHub.