Skip to content

Support a pinned OAuth callback port for MCP servers - #491

Draft
samcx wants to merge 5 commits into
vercel-labs:mainfrom
samcx:samcx/mcp-oauth-callback-port
Draft

Support a pinned OAuth callback port for MCP servers#491
samcx wants to merge 5 commits into
vercel-labs:mainfrom
samcx:samcx/mcp-oauth-callback-port

Conversation

@samcx

@samcx samcx commented Aug 28, 2026

Copy link
Copy Markdown

Summary

  • add an optional oauth.callback_port to MCP server config
  • bind the interactive authorization callback to that port instead of an ephemeral one
  • advertise http://localhost:<port>/callback when a port is pinned, matching the host form providers register

Problem

authorizeInteractive binds the loopback callback listener to port 0, so the OS assigns a different port on every attempt and the advertised redirect_uri changes each run:

redirect_uri=http://127.0.0.1:53230/callback   run 1
redirect_uri=http://127.0.0.1:53268/callback   run 2

Providers that require a pre-registered redirect URI can never be authorized, because no registration can match a port that changes per run. Slack's hosted MCP server (https://mcp.slack.com/mcp) is the case that surfaced this: it does not support Dynamic Client Registration, so the client must supply a client_id whose redirect URI is already registered.

There is a second, quieter mismatch. Registered loopback redirects are conventionally written as localhost, and providers compare redirect URIs as exact strings, so emitting 127.0.0.1 fails to match even when the port is correct.

Setting oauth.client_id alone already clears ClientRegistrationUnavailable, so today the flow gets past discovery and then fails at the provider's redirect validation with no way to proceed.

Reproduction

{
  "mcp": {
    "slack": {
      "type": "http",
      "url": "https://mcp.slack.com/mcp",
      "enabled": true,
      "oauth": { "client_id": "<a registered Slack app client id>" }
    }
  }
}

fx mcp auth slack opens an authorize URL whose redirect_uri is a fresh ephemeral port, which the provider rejects.

Fix

"oauth": {
  "client_id": "<client id>",
  "callback_port": 3118
}

When callback_port is set the listener binds it and the redirect becomes http://localhost:3118/callback. When it is absent, behavior is byte-for-byte unchanged: ephemeral port, 127.0.0.1 host. Binding still happens on 127.0.0.1; only the advertised string changes. isLoopbackHost already accepts localhost, 127.0.0.1, and [::1], so nothing downstream shifts.

A pinned port that is already in use now fails as McpCallbackPortUnavailable rather than a bare listen error, since that is a routine and recoverable user situation.

Ports are validated at config parse time and must be 1-65535.

This mirrors prior art: opencode exposes callbackPort and redirectUri, and Claude Code pins 3118.

Verification

  • zig build clean, zig fmt src/ clean
  • zig build test: 8747/8755 pass, +3 from this change. The 6 failures are pre-existing and environmental on this machine (iTerm2 shell-integration escapes leaking into captured command output in command_runner / tool_runtime / builtins.context); the identical 6 fail on an unmodified checkout, and none touch the files in this diff.
  • exercised end to end with ./zig-out/bin/fx mcp auth slack against an isolated profile:

with callback_port: 3118

redirect_uri=http://localhost:3118/callback

with the key removed

redirect_uri=http://127.0.0.1:59235/callback

The pinned run produced a complete authorize URL carrying the configured client_id, PKCE S256, the resource parameter, and all scopes advertised by the server's protected-resource metadata.

@fazxes fazxes added the type: feature Adds a new user-facing capability label Aug 29, 2026
samcx and others added 5 commits August 29, 2026 11:22
Interactive MCP authorization bound the loopback callback listener to
port 0, so every attempt advertised a different redirect_uri. Providers
that require a pre-registered redirect could never be authorized, since
no registration can match a port that changes per run.

Add an optional oauth.callback_port to the MCP server config. When set,
the listener binds that port and advertises http://localhost:<port>/callback,
matching the host form providers register. Without it the ephemeral
127.0.0.1 behavior is unchanged.
Allow configured callback ports to be reused after authorization without sharing active listeners. Continue with IPv4 when IPv6 loopback is unavailable, and create callback sockets with close-on-exec atomically where supported.
@fazxes
fazxes force-pushed the samcx/mcp-oauth-callback-port branch from 8d82d94 to e2f6863 Compare August 29, 2026 15:27
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

type: feature Adds a new user-facing capability

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants