Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions cmd/root/debug_oauth_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,35 @@ func TestFindMCPRemote_MatchesTopLevelMCPByName(t *testing.T) {
assert.Equal(t, []string{"scope-a"}, remote.OAuth.Scopes)
}

// TestFindMCPRemote_ForwardsCallbackPortAndRedirectURL proves the full
// RemoteOAuthConfig — including CallbackPort and CallbackRedirectURL —
// survives findMCPRemote's lookup verbatim, so PerformOAuthLogin's
// runtime-aligned callback wiring (NewCallbackServerOnPort/
// ResolveRedirectURI) sees the exact same per-toolset config the runtime
// would use for the same remote MCP server.
func TestFindMCPRemote_ForwardsCallbackPortAndRedirectURL(t *testing.T) {
t.Parallel()

oauthConfig := &latest.RemoteOAuthConfig{
CallbackPort: 8765,
CallbackRedirectURL: "https://proxy.example.test/cb?port=${callbackPort}",
}
cfg := &latest.Config{
MCPs: map[string]latest.MCPToolset{
"atlassian": {Toolset: latest.Toolset{
Type: "mcp",
Remote: latest.Remote{URL: "https://mcp.atlassian.com/v1/mcp/authv2", OAuth: oauthConfig},
}},
},
}

remote, err := findMCPRemote(cfg, "atlassian")
require.NoError(t, err)
require.NotNil(t, remote.OAuth)
assert.Equal(t, 8765, remote.OAuth.CallbackPort)
assert.Equal(t, "https://proxy.example.test/cb?port=${callbackPort}", remote.OAuth.CallbackRedirectURL)
}

// TestFindMCPRemote_MatchesAgentEmbeddedToolsetByName covers the
// agent.Toolsets lookup path (an MCP toolset declared inline on an agent
// rather than in the top-level `mcps:` map).
Expand Down
16 changes: 11 additions & 5 deletions pkg/tools/mcp/oauth.go
Original file line number Diff line number Diff line change
Expand Up @@ -452,6 +452,16 @@ func callbackRedirectURLFrom(c *latest.RemoteOAuthConfig) string {
return c.CallbackRedirectURL
}

// callbackPortFrom is a nil-safe accessor for the optional CallbackPort
// field on a RemoteOAuthConfig; zero means "let the OS pick a free port",
// both here and as NewCallbackServerOnPort's port argument.
func callbackPortFrom(c *latest.RemoteOAuthConfig) int {
if c == nil {
return 0
}
return c.CallbackPort
}

// oauthTransport wraps an HTTP transport with OAuth support
type oauthTransport struct {
base http.RoundTripper
Expand Down Expand Up @@ -1243,11 +1253,7 @@ func (t *oauthTransport) handleManagedOAuthFlow(ctx context.Context, authServer,
}

slog.DebugContext(ctx, "Creating OAuth callback server")
var callbackPort int
if t.oauthConfig != nil {
callbackPort = t.oauthConfig.CallbackPort
}
callbackServer, err := NewCallbackServerOnPort(ctx, callbackPort)
callbackServer, err := NewCallbackServerOnPort(ctx, callbackPortFrom(t.oauthConfig))
if err != nil {
return fmt.Errorf("failed to create callback server: %w", err)
}
Expand Down
21 changes: 13 additions & 8 deletions pkg/tools/mcp/oauth_login.go
Original file line number Diff line number Diff line change
Expand Up @@ -61,11 +61,14 @@ func setOAuthLoginHTTPClientForTesting(c *http.Client) (restore func()) {
// attempted candidate is a hard error: no later candidate, DCR, browser, or
// token request follows.
//
// The callback/redirect mechanics below (NewCallbackServer, GetRedirectURI)
// are deliberately left unchanged: aligning them with the runtime's
// NewCallbackServerOnPort/ResolveRedirectURI (and honoring
// RemoteOAuthConfig.CallbackPort/CallbackRedirectURL here) is a separate,
// not-yet-made decision.
// The callback/redirect mechanics below mirror the runtime's managed OAuth
// flow exactly: NewCallbackServerOnPort honors RemoteOAuthConfig.CallbackPort
// (0 lets the OS pick a free port, matching the prior NewCallbackServer
// behavior) and ResolveRedirectURI honors CallbackRedirectURL (verbatim,
// with the ${callbackPort} placeholder substituted; the local callback
// server's own address is used unchanged when it is empty). The resolved
// redirect URI is then reused, identically, for DCR, /authorize, and the
// token exchange below.
func PerformOAuthLogin(ctx context.Context, remote latest.Remote) error {
tokenStore := NewKeyringTokenStore()
client := oauthLoginHTTPClient()
Expand Down Expand Up @@ -94,8 +97,10 @@ func PerformOAuthLogin(ctx context.Context, remote latest.Remote) error {
return fmt.Errorf("failed to fetch authorization server metadata: %w", err)
}

// Set up the callback server for the redirect.
callbackServer, err := NewCallbackServer(ctx)
// Set up the callback server for the redirect, honoring
// RemoteOAuthConfig.CallbackPort/CallbackRedirectURL exactly like the
// runtime's managed OAuth flow does.
callbackServer, err := NewCallbackServerOnPort(ctx, callbackPortFrom(remote.OAuth))
if err != nil {
return fmt.Errorf("failed to create callback server: %w", err)
}
Expand All @@ -113,7 +118,7 @@ func PerformOAuthLogin(ctx context.Context, remote latest.Remote) error {
return fmt.Errorf("failed to start callback server: %w", err)
}

redirectURI := callbackServer.GetRedirectURI()
redirectURI := callbackServer.ResolveRedirectURI(callbackRedirectURLFrom(remote.OAuth))

clientID, clientSecret, scopes, err := resolveStandaloneClientCredentials(
ctx, client, remote.OAuth, authServerMetadata, redirectURI,
Expand Down
Loading
Loading