github/github-mcp-server · error
OAuth callback port %d is not available; another process may
Error message
OAuth callback port %d is not available; another process may be using it — free the port or set a different --oauth-callback-port: %w
What it means
The fixed callback port (m.config.CallbackPort != 0) failed to bind and begin treats it as fatal instead of downgrading to device flow. The comment is explicit: the port was deliberately chosen and registered on the OAuth app, so a bind failure may mean another process is positioned to intercept the authorization redirect — silently switching flows would mask that. The error chains errCallbackBind plus the OS bind error, and names both remediations.
Source
Thrown at internal/oauth/flow.go:59
// begin selects and prepares the appropriate flow. PKCE is preferred for its
// stronger security; device flow is the fallback. A random callback port inside
// Docker cannot be reached from the host browser, so that combination goes
// straight to device flow.
func (m *Manager) begin(prompter Prompter) (*flowPlan, error) {
canPKCE := m.config.CallbackPort != 0 || !m.inDocker()
if canPKCE {
plan, err := m.beginPKCE(prompter)
if err == nil {
return plan, nil
}
// A fixed callback port that won't bind is fatal, not a cue to downgrade.
// The port was chosen deliberately (and registered with the OAuth app), so
// a bind failure means another process holds it — possibly one positioned
// to intercept the authorization redirect. Silently switching to device
// flow would mask that, so stop and make the user resolve it. Only genuine
// bind failures qualify; other errors fall through to device flow.
if m.config.CallbackPort != 0 && errors.Is(err, errCallbackBind) {
return nil, fmt.Errorf("OAuth callback port %d is not available; another process may be using it — free the port or set a different --oauth-callback-port: %w", m.config.CallbackPort, err)
}
m.logger.Info("PKCE flow unavailable, falling back to device flow", "reason", err)
} else {
m.logger.Info("no callback port inside container; using device flow")
}
return m.beginDevice(prompter)
}
// beginPKCE prepares the authorization-code + PKCE flow. It binds the callback
// server and selects the most secure available display channel: browser
// auto-open, then URL elicitation, then a tool-response message. On a headless
// host with a random callback port it diverts to device flow, whose redirect
// does not depend on reaching this machine's localhost.
func (m *Manager) beginPKCE(prompter Prompter) (*flowPlan, error) {
state, err := randomState()
if err != nil {
return nil, err
}View on GitHub (pinned to 0ea1f775a7)
Solutions
- Identify the holder and stop it: lsof -nP -iTCP:8085 -sTCP:LISTEN or ss -ltnp
- If the holder is legitimate, pick a new port and update BOTH --oauth-callback-port and the callback URL registered on the OAuth/GitHub App
- Do not treat this as a fallback cue — the design intentionally refuses device-flow downgrade here
- If interception is plausible (unknown process), investigate before re-running login
Example fix
// before $ github-mcp-server --oauth-callback-port 8085 // error: OAuth callback port 8085 is not available; another process may be using it ... // after $ lsof -tiTCP:8085 -sTCP:LISTEN | xargs kill # free the registered port $ github-mcp-server --oauth-callback-port 8085
Defensive patterns
Strategy: validation
Validate before calling
func callbackPortAvailable(port int) bool {
ln, err := net.Listen("tcp", fmt.Sprintf("127.0.0.1:%d", port))
if err != nil { return false }
_ = ln.Close()
return true
}
if fixedPort != 0 && !callbackPortAvailable(fixedPort) {
return fmt.Errorf("refusing to start: callback port %d held by another process", fixedPort)
} Type guard
errors.Is(err, errCallbackBind) // inside the package: distinguish bind failures from other prep errors
Try / catch
// caller of begin(): this error is fatal by design — do NOT auto-downgrade to device flow
if err != nil && strings.Contains(err.Error(), "is not available") {
os.Exit(1) // surface to the operator to free/replace the port
} Prevention
- Run exactly one server instance per fixed callback port
- Pre-check the port at startup and fail with a clear message before any OAuth state is created
- If the port must change, update the OAuth app's registered callback URL in the same change
When it happens
Trigger: listenCallback returns errCallbackBind (wrapped at flow.go:84) while m.config.CallbackPort is a configured non-zero port; the check at internal/oauth/flow.go:58 escalates it. Concretely: a previous server instance still holds --oauth-callback-port 8085, or an unrelated process squats the port registered as the OAuth app's callback URL.
Common situations: Duplicate server processes (systemd unit + manual run); orphaned container republishing the port; another developer tool claiming the same fixed localhost port; a stale process after a crash where the socket lingers.
Related errors
- starting callback listener on %s: %w
- failed to create OAuth handler: %w
- authentication required: set GITHUB_PERSONAL_ACCESS_TOKEN, c
- failed to unmarshal toolsets: %w
- failed to unmarshal tools: %w
AI-assisted analysis of github/github-mcp-server@0ea1f775a7 (2026-08-15).
Data as JSON: /api/errors/1e2218284eb13ed4.
Report an issue: GitHub.