From c97124d0f157fea0e69effa39807f0fec6ba260f Mon Sep 17 00:00:00 2001 From: Trung Nguyen Date: Tue, 26 May 2026 14:33:02 +0200 Subject: [PATCH] Extend unmanaged OAuth flow to drive code exchange in-process MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a new sub-behavior to the unmanaged OAuth flow that lets the runtime own PKCE / state / DCR / token exchange while the connected client acts as a thin courier (open browser, receive deeplink, forward {code, state}). Activated by a new server-level flag --mcp-oauth-redirect-uri (also exposed on the runtime as WithUnmanagedOAuthRedirectURI and on the RuntimeConfig as MCPOAuthRedirectURI). When set, the runtime: - generates state + PKCE in-process - runs DCR if needed (or uses per-toolset explicit credentials) - builds the full authorize URL with the configured redirect_uri - emits an elicitation whose Meta carries cagent/authorize_url + cagent/state alongside the existing auth_server_metadata - accepts {code, state} as a ResumeElicitation payload, verifies state in constant time, and exchanges the code at the token endpoint using the same redirect_uri (RFC 6749 §4.1.3 binding) - stores the resulting token in the keychain with client_id / client_secret stamped on for later silent refresh When the flag is empty, the existing client-driven contract is preserved verbatim: the elicitation carries only metadata and the client is expected to reply with {access_token, refresh_token, …}. The {access_token, …} reply shape is also accepted on the new path so a client that prefers to do the exchange itself is still free to. A per-toolset RemoteOAuthConfig.CallbackRedirectURL overrides the runtime-wide flag for that toolset. Docker-agent never learns anything about the URL it advertises: it is opaque, and what happens at the URL (HTML bouncer, OS deeplink, universal-link claim, …) is the host's concern. Plumbing: - cmd/root/flags.go: add --mcp-oauth-redirect-uri - pkg/config/runtime.go: MCPOAuthRedirectURI field - pkg/runtime/runtime.go: unmanagedOAuthRedirectURI field + WithUnmanagedOAuthRedirectURI Opt - pkg/runtime/loop.go: pass through to ConfigureHandlers - pkg/server/session_manager.go: forward from runtime config - pkg/tools/capabilities.go: extend OAuthCapable interface + ConfigureHandlers - pkg/tools/mcp/mcp.go, remote.go, session_client.go, builtin/mcpcatalog: implement the new SetUnmanagedOAuthRedirectURI - pkg/tools/mcp/oauth.go: rework handleUnmanagedOAuthFlow, factor out resolveClientCredentials helper and consumeUnmanagedElicitationReply - docs/features/remote-mcp/index.md: new 'Unmanaged OAuth flow' section documenting the meta keys and behavior Tests: six new oauth_test.go tests covering the drive-flow happy path (incl. asserting RFC 6749 §4.1.3 redirect_uri parity at /token), state-mismatch rejection, legacy access-token reply on the new path, legacy mode shape (no authorize_url emitted), legacy mode rejection of {code, state}, and the per-toolset > runtime-wide precedence. Signed-off-by: Trung Nguyen --- cmd/root/flags.go | 6 + docs/features/remote-mcp/index.md | 24 ++ pkg/config/runtime.go | 21 ++ pkg/runtime/loop.go | 1 + pkg/runtime/runtime.go | 47 ++- pkg/runtime/runtime_test.go | 2 + pkg/server/session_manager.go | 1 + pkg/tools/builtin/mcpcatalog/mcpcatalog.go | 28 +- pkg/tools/capabilities.go | 9 +- pkg/tools/mcp/mcp.go | 5 + pkg/tools/mcp/mcp_test.go | 2 + pkg/tools/mcp/oauth.go | 298 ++++++++++++++----- pkg/tools/mcp/oauth_test.go | 326 +++++++++++++++++++++ pkg/tools/mcp/reconnect_test.go | 1 + pkg/tools/mcp/remote.go | 39 ++- pkg/tools/mcp/session_client.go | 6 + 16 files changed, 712 insertions(+), 104 deletions(-) diff --git a/cmd/root/flags.go b/cmd/root/flags.go index c3d91b9b0..479eb147f 100644 --- a/cmd/root/flags.go +++ b/cmd/root/flags.go @@ -36,6 +36,12 @@ func addRuntimeConfigFlags(cmd *cobra.Command, runConfig *config.RuntimeConfig) cmd.PersistentFlags().StringArrayVar(&runConfig.HookSessionEnd, "hook-session-end", nil, "Add a session-end hook command (repeatable)") cmd.PersistentFlags().StringArrayVar(&runConfig.HookOnUserInput, "hook-on-user-input", nil, "Add an on-user-input hook command (repeatable)") cmd.PersistentFlags().StringArrayVar(&runConfig.HookStop, "hook-stop", nil, "Add a stop hook command, fired when the model finishes responding (repeatable)") + cmd.PersistentFlags().StringVar(&runConfig.MCPOAuthRedirectURI, "mcp-oauth-redirect-uri", "", + "Public HTTPS URL to advertise as the OAuth `redirect_uri` for MCP servers "+ + "running in unmanaged OAuth mode. When set, docker-agent drives the OAuth flow "+ + "itself (PKCE + DCR + token exchange) and expects clients to return `{code, state}` "+ + "via ResumeElicitation. When empty, the client is expected to perform the OAuth "+ + "flow and return an access token (legacy behavior).") } func setupWorkingDirectory(workingDir string) error { diff --git a/docs/features/remote-mcp/index.md b/docs/features/remote-mcp/index.md index 7b2a3c050..70ee4083f 100644 --- a/docs/features/remote-mcp/index.md +++ b/docs/features/remote-mcp/index.md @@ -106,6 +106,30 @@ The local callback server still listens on the loopback interface on `callbackPo - Only `http` and `https` schemes are accepted. - `http` is only allowed when the host is a loopback address (`127.0.0.1`, `::1`, `localhost`); any other host must use `https` to avoid exposing the authorization `code` on the wire (RFC 8252 §7.3). +### Unmanaged OAuth flow (server mode) + +When running `docker-agent serve api` (no local browser, no callback server), the runtime delegates the OAuth dance to the connected client via an MCP elicitation. There are two sub-behaviors, selected by the `--mcp-oauth-redirect-uri` flag: + +- **`--mcp-oauth-redirect-uri=` set** (recommended for hosts like Docker Desktop): the runtime generates `state` + PKCE + (optional) Dynamic Client Registration in-process, builds the full authorize URL, and emits an elicitation whose `Meta` includes: + + | Key | Value | + | ---------------------------- | ---------------------------------------------------------------- | + | `cagent/type` | `"oauth_flow"` | + | `cagent/server_url` | The MCP server URL (for display / favicon) | + | `cagent/authorize_url` | The full URL the client should open in the user's browser | + | `cagent/state` | The `state` value the client must echo back when replying | + | `auth_server` | Issuer of the authorization server | + | `auth_server_metadata` | RFC 8414 authorization-server metadata document | + | `resource_metadata` | RFC 9728 protected-resource metadata document | + + The client opens the browser at `cagent/authorize_url`, receives the OAuth callback at whatever endpoint the configured `redirect_uri` resolves to (typically a host-controlled bouncer that 302s into a deeplink), and replies to the elicitation with `accept` and `Content = {"code": "...", "state": "..."}`. The runtime verifies the `state`, exchanges the `code` at the token endpoint (using the same `redirect_uri` for RFC 6749 §4.1.3 binding), stores the token, and replays the original MCP request with `Authorization: Bearer ...`. + +- **Flag not set** (legacy): the runtime emits only `auth_server_metadata` + `resource_metadata`; the client is expected to drive the OAuth flow itself (PKCE, DCR, token exchange) and reply with `Content = {"access_token": "...", "refresh_token": "...", ...}`. + +The legacy `{access_token, ...}` reply shape is still accepted on the `--mcp-oauth-redirect-uri` path too: a client that prefers to do the exchange itself can ignore the `cagent/authorize_url`/`cagent/state` keys. + +A per-toolset `callbackRedirectURL` (in the YAML) overrides the runtime-wide `--mcp-oauth-redirect-uri` for that toolset. + ## Project Management & Collaboration | Service | URL | Transport | Description | diff --git a/pkg/config/runtime.go b/pkg/config/runtime.go index 455baa0ae..7c247dd7c 100644 --- a/pkg/config/runtime.go +++ b/pkg/config/runtime.go @@ -44,6 +44,27 @@ type Config struct { MCPToolName string MCPKeepAlive time.Duration + + // MCPOAuthRedirectURI is an opaque public HTTPS URL the runtime advertises + // as the OAuth `redirect_uri` when running an MCP server OAuth flow in + // unmanaged mode (see WithManagedOAuth(false)). When set, docker-agent + // generates state + PKCE + DCR in-process and emits an elicitation + // carrying the `authorize_url` + `state`; the client is then a thin + // relay that opens the browser, receives the callback (typically via a + // host-controlled bouncer + deeplink), and returns {code, state} via + // ResumeElicitation. docker-agent then exchanges the code for the + // token using this same URI as redirect_uri (RFC 6749 §4.1.3 requires + // the value to match the one sent at the /authorize step). + // + // When empty, the unmanaged flow keeps its original contract: the + // client is expected to drive the OAuth dance end-to-end and return + // {access_token, refresh_token, …}. This preserves backward compat + // with existing CLI-mirror clients. + // + // The URI itself is opaque to docker-agent — what it points at and how + // the browser eventually lands back in the host application is the + // caller's concern. + MCPOAuthRedirectURI string } func (runConfig *RuntimeConfig) Clone() *RuntimeConfig { diff --git a/pkg/runtime/loop.go b/pkg/runtime/loop.go index aaa1ba6cd..25d6411f3 100644 --- a/pkg/runtime/loop.go +++ b/pkg/runtime/loop.go @@ -841,6 +841,7 @@ func (r *LocalRuntime) configureToolsetHandlers(a *agent.Agent, events EventSink r.samplingHandler, func() { events.Emit(Authorization(tools.ElicitationActionAccept, a.Name())) }, r.managedOAuth, + r.unmanagedOAuthRedirectURI, ) // Wire RAG event forwarding so the TUI shows indexing progress. diff --git a/pkg/runtime/runtime.go b/pkg/runtime/runtime.go index 1339d7728..95f2e0f11 100644 --- a/pkg/runtime/runtime.go +++ b/pkg/runtime/runtime.go @@ -177,22 +177,23 @@ type ModelStore interface { // LocalRuntime manages the execution of agents type LocalRuntime struct { - toolMap map[string]ToolHandlerFunc - team *team.Team - agents *agentRouter - resumeChan chan ResumeRequest - tracer trace.Tracer - modelsStore ModelStore - sessionCompaction bool - managedOAuth bool - nonInteractive bool - startupInfoEmitted bool // Track if startup info has been emitted to avoid unnecessary duplication - elicitationRequestCh chan ElicitationResult // Channel for receiving elicitation responses - elicitation elicitationBridge // Owns the per-stream events channel for outbound elicitation requests - sessionStore session.Store - workingDir string // Working directory for hooks execution - env []string // Environment variables for hooks execution - modelSwitcherCfg *ModelSwitcherConfig + toolMap map[string]ToolHandlerFunc + team *team.Team + agents *agentRouter + resumeChan chan ResumeRequest + tracer trace.Tracer + modelsStore ModelStore + sessionCompaction bool + managedOAuth bool + unmanagedOAuthRedirectURI string + nonInteractive bool + startupInfoEmitted bool // Track if startup info has been emitted to avoid unnecessary duplication + elicitationRequestCh chan ElicitationResult // Channel for receiving elicitation responses + elicitation elicitationBridge // Owns the per-stream events channel for outbound elicitation requests + sessionStore session.Store + workingDir string // Working directory for hooks execution + env []string // Environment variables for hooks execution + modelSwitcherCfg *ModelSwitcherConfig // hooksRegistry is the runtime-private hooks.Registry used to build // every Executor. It carries the runtime-owned builtin hooks @@ -291,6 +292,20 @@ func WithManagedOAuth(managed bool) Opt { } } +// WithUnmanagedOAuthRedirectURI configures the redirect_uri the runtime +// advertises when running MCP server OAuth flows in unmanaged mode (i.e. +// when WithManagedOAuth(false) is set). When set, docker-agent generates +// state + PKCE + DCR in-process and emits an elicitation carrying the +// `authorize_url` + `state`; the client returns `{code, state}` via +// ResumeElicitation and docker-agent does the token exchange itself. +// When empty, the runtime falls back to the legacy unmanaged contract +// where the client performs the OAuth flow and returns an access token. +func WithUnmanagedOAuthRedirectURI(uri string) Opt { + return func(r *LocalRuntime) { + r.unmanagedOAuthRedirectURI = uri + } +} + // WithNonInteractive marks the runtime as headless (e.g., MCP serve mode). // When set, blocking operations like elicitation requests are automatically // declined instead of waiting for user interaction that will never come. diff --git a/pkg/runtime/runtime_test.go b/pkg/runtime/runtime_test.go index d67459585..292b84ee9 100644 --- a/pkg/runtime/runtime_test.go +++ b/pkg/runtime/runtime_test.go @@ -963,6 +963,8 @@ func (s *oauthAwareToolSet) SetManagedOAuth(managed bool) { s.managedOAuthSet = true } +func (s *oauthAwareToolSet) SetUnmanagedOAuthRedirectURI(string) {} + // TestEmitStartupInfo_DoesNotBlockOnInteractiveOAuth verifies that the // startup path does NOT trigger interactive flows on toolsets. In particular: // diff --git a/pkg/server/session_manager.go b/pkg/server/session_manager.go index 63c734fa3..fc49604dd 100644 --- a/pkg/server/session_manager.go +++ b/pkg/server/session_manager.go @@ -665,6 +665,7 @@ func (sm *SessionManager) runtimeForSession(ctx context.Context, sess *session.S opts := []runtime.Opt{ runtime.WithCurrentAgent(currentAgent), runtime.WithManagedOAuth(false), + runtime.WithUnmanagedOAuthRedirectURI(rc.MCPOAuthRedirectURI), runtime.WithSessionStore(sm.sessionStore), runtime.WithTracer(otel.Tracer("cagent")), runtime.WithModelSwitcherConfig(modelSwitcherCfg), diff --git a/pkg/tools/builtin/mcpcatalog/mcpcatalog.go b/pkg/tools/builtin/mcpcatalog/mcpcatalog.go index ed0a7f1ea..97b64a341 100644 --- a/pkg/tools/builtin/mcpcatalog/mcpcatalog.go +++ b/pkg/tools/builtin/mcpcatalog/mcpcatalog.go @@ -84,11 +84,12 @@ type Toolset struct { // OAuth-success refreshes, the managed-vs-unmanaged flag and // tool-list change notifications behave identically to a YAML- // declared `mcp.remote` toolset. - elicitationHandler tools.ElicitationHandler - oauthSuccessHandler func() - toolsChangedHandler func() - managedOAuth bool - managedOAuthSet bool // distinguishes "default" from "explicitly false" + elicitationHandler tools.ElicitationHandler + oauthSuccessHandler func() + toolsChangedHandler func() + managedOAuth bool + managedOAuthSet bool // distinguishes "default" from "explicitly false" + unmanagedOAuthRedirectURI string // removeOAuthToken drops a persisted OAuth token by resource URL. // Defaults to mcp.RemoveOAuthToken; tests inject a stub to avoid @@ -226,6 +227,20 @@ func (t *Toolset) SetManagedOAuth(managed bool) { } } +// SetUnmanagedOAuthRedirectURI forwards the unmanaged-OAuth redirect URI +// to every enabled toolset; new toolsets pick it up at enable time. +func (t *Toolset) SetUnmanagedOAuthRedirectURI(uri string) { + t.mu.Lock() + t.unmanagedOAuthRedirectURI = uri + enabled := t.snapshotEnabled() + t.mu.Unlock() + for _, ts := range enabled { + if o, ok := tools.As[tools.OAuthCapable](ts); ok { + o.SetUnmanagedOAuthRedirectURI(uri) + } + } +} + // SetToolsChangedHandler is invoked by the runtime to be notified when // the set of available tools changes. We forward to the activated MCP // toolsets *and* call it ourselves on every Enable / Disable so the @@ -563,6 +578,9 @@ func (t *Toolset) handleEnable(ctx context.Context, args EnableArgs) (*tools.Too if t.managedOAuthSet { mcpToolset.SetManagedOAuth(t.managedOAuth) } + if t.unmanagedOAuthRedirectURI != "" { + mcpToolset.SetUnmanagedOAuthRedirectURI(t.unmanagedOAuthRedirectURI) + } wrapped := tools.NewStartable(mcpToolset) t.enabled[id] = wrapped diff --git a/pkg/tools/capabilities.go b/pkg/tools/capabilities.go index d457bd8c4..878c7feb9 100644 --- a/pkg/tools/capabilities.go +++ b/pkg/tools/capabilities.go @@ -57,6 +57,12 @@ type Sampleable interface { type OAuthCapable interface { SetOAuthSuccessHandler(handler func()) SetManagedOAuth(managed bool) + // SetUnmanagedOAuthRedirectURI sets the `redirect_uri` that docker-agent + // advertises when running an MCP server OAuth flow in unmanaged mode. + // When non-empty, docker-agent drives PKCE + DCR + token exchange itself + // and expects the client to return {code, state} (in addition to the + // existing {access_token, …} reply shape). Ignored in managed mode. + SetUnmanagedOAuthRedirectURI(uri string) } // GetInstructions returns instructions if the toolset implements Instructable. @@ -78,7 +84,7 @@ type ChangeNotifier interface { // It checks for Elicitable, Sampleable and OAuthCapable interfaces and // configures them. This is a convenience function that handles the capability // checking internally. -func ConfigureHandlers(ts ToolSet, elicitHandler ElicitationHandler, samplingHandler SamplingHandler, oauthHandler func(), managedOAuth bool) { +func ConfigureHandlers(ts ToolSet, elicitHandler ElicitationHandler, samplingHandler SamplingHandler, oauthHandler func(), managedOAuth bool, unmanagedOAuthRedirectURI string) { if e, ok := As[Elicitable](ts); ok { e.SetElicitationHandler(elicitHandler) } @@ -88,5 +94,6 @@ func ConfigureHandlers(ts ToolSet, elicitHandler ElicitationHandler, samplingHan if o, ok := As[OAuthCapable](ts); ok { o.SetOAuthSuccessHandler(oauthHandler) o.SetManagedOAuth(managedOAuth) + o.SetUnmanagedOAuthRedirectURI(unmanagedOAuthRedirectURI) } } diff --git a/pkg/tools/mcp/mcp.go b/pkg/tools/mcp/mcp.go index 42208e4fc..367a21e3a 100644 --- a/pkg/tools/mcp/mcp.go +++ b/pkg/tools/mcp/mcp.go @@ -137,6 +137,7 @@ type mcpClient interface { SetSamplingHandler(handler tools.SamplingHandler) SetOAuthSuccessHandler(handler func()) SetManagedOAuth(managed bool) + SetUnmanagedOAuthRedirectURI(uri string) SetToolListChangedHandler(handler func()) SetPromptListChangedHandler(handler func()) // Wait blocks until the underlying connection is closed by the server. @@ -785,6 +786,10 @@ func (ts *Toolset) SetManagedOAuth(managed bool) { ts.mcpClient.SetManagedOAuth(managed) } +func (ts *Toolset) SetUnmanagedOAuthRedirectURI(uri string) { + ts.mcpClient.SetUnmanagedOAuthRedirectURI(uri) +} + func (ts *Toolset) SetToolsChangedHandler(handler func()) { ts.mu.Lock() defer ts.mu.Unlock() diff --git a/pkg/tools/mcp/mcp_test.go b/pkg/tools/mcp/mcp_test.go index 997ac7c8f..d4fc6c776 100644 --- a/pkg/tools/mcp/mcp_test.go +++ b/pkg/tools/mcp/mcp_test.go @@ -48,6 +48,8 @@ func (m *mockMCPClient) SetOAuthSuccessHandler(func()) {} func (m *mockMCPClient) SetManagedOAuth(bool) {} +func (m *mockMCPClient) SetUnmanagedOAuthRedirectURI(string) {} + func (m *mockMCPClient) SetToolListChangedHandler(func()) {} func (m *mockMCPClient) SetPromptListChangedHandler(func()) {} diff --git a/pkg/tools/mcp/oauth.go b/pkg/tools/mcp/oauth.go index 0d1e6a5de..1a9ff55e7 100644 --- a/pkg/tools/mcp/oauth.go +++ b/pkg/tools/mcp/oauth.go @@ -4,6 +4,7 @@ import ( "bytes" "cmp" "context" + "crypto/subtle" "encoding/json" "errors" "fmt" @@ -265,13 +266,14 @@ func callbackRedirectURLFrom(c *latest.RemoteOAuthConfig) string { type oauthTransport struct { base http.RoundTripper // TODO(rumpl): remove client reference, we need to find a better way to send elicitation requests - client *remoteMCPClient - tokenStore OAuthTokenStore - baseURL string - managed bool - oauthConfig *latest.RemoteOAuthConfig - oauthHTTPClient *http.Client - oauthFlowMu sync.Mutex + client *remoteMCPClient + tokenStore OAuthTokenStore + baseURL string + managed bool + unmanagedOAuthRedirectURI string + oauthConfig *latest.RemoteOAuthConfig + oauthHTTPClient *http.Client + oauthFlowMu sync.Mutex // mu protects refreshFailedAt and lastErr* from concurrent access. mu sync.Mutex @@ -728,34 +730,9 @@ func (t *oauthTransport) handleManagedOAuthFlow(ctx context.Context, authServer, redirectURI := callbackServer.resolveRedirectURI(callbackRedirectURLFrom(t.oauthConfig)) slog.DebugContext(ctx, "Using redirect URI", "uri", redirectURI) - var clientID string - var clientSecret string - var scopes []string - - switch { - case t.oauthConfig != nil && t.oauthConfig.ClientID != "": - // Use explicit credentials from config - slog.DebugContext(ctx, "Using explicit OAuth credentials from config") - clientID = t.oauthConfig.ClientID - clientSecret = t.oauthConfig.ClientSecret - scopes = t.oauthConfig.Scopes - case authServerMetadata.RegistrationEndpoint != "": - slog.DebugContext(ctx, "Attempting dynamic client registration") - clientID, clientSecret, err = registerClient( - ctx, - t.oauthClient(), - authServerMetadata, - redirectURI, - nil, - ) - if err != nil { - slog.DebugContext(ctx, "Dynamic registration failed", "error", err) - // TODO(rumpl): fall back to requesting client ID from user - return err - } - default: - // TODO(rumpl): fall back to requesting client ID from user - return errors.New("authorization server does not support dynamic client registration and no explicit OAuth credentials configured") + clientID, clientSecret, scopes, err := t.resolveClientCredentials(ctx, authServerMetadata, redirectURI) + if err != nil { + return err } state, err := GenerateState() @@ -838,8 +815,73 @@ func (t *oauthTransport) handleManagedOAuthFlow(ctx context.Context, authServer, return nil } -// handleUnmanagedOAuthFlow performs the OAuth flow for remote/unmanaged scenarios -// where the client handles the OAuth interaction instead of us +// resolveClientCredentials picks the OAuth client_id (and optional secret + +// scopes) used for both the authorize URL and the token exchange. Explicit +// credentials from the per-toolset config take precedence; otherwise we +// attempt RFC 7591 Dynamic Client Registration against the authorization +// server. Returns an error if neither path is available. +func (t *oauthTransport) resolveClientCredentials(ctx context.Context, authServerMetadata *AuthorizationServerMetadata, redirectURI string) (clientID, clientSecret string, scopes []string, err error) { + switch { + case t.oauthConfig != nil && t.oauthConfig.ClientID != "": + // Use explicit credentials from config + slog.DebugContext(ctx, "Using explicit OAuth credentials from config") + return t.oauthConfig.ClientID, t.oauthConfig.ClientSecret, t.oauthConfig.Scopes, nil + case authServerMetadata.RegistrationEndpoint != "": + slog.DebugContext(ctx, "Attempting dynamic client registration") + clientID, clientSecret, err = registerClient( + ctx, + t.oauthClient(), + authServerMetadata, + redirectURI, + nil, + ) + if err != nil { + slog.DebugContext(ctx, "Dynamic registration failed", "error", err) + // TODO(rumpl): fall back to requesting client ID from user + return "", "", nil, err + } + return clientID, clientSecret, nil, nil + default: + // TODO(rumpl): fall back to requesting client ID from user + return "", "", nil, errors.New("authorization server does not support dynamic client registration and no explicit OAuth credentials configured") + } +} + +// unmanagedRedirectURI returns the redirect_uri docker-agent should use when +// driving the unmanaged OAuth flow itself. Per-toolset config takes +// precedence (RemoteOAuthConfig.CallbackRedirectURL) over the runtime-wide +// default (--mcp-oauth-redirect-uri). +// +// Returns the empty string when neither is set, in which case the unmanaged +// flow falls back to the legacy client-driven behavior (client returns +// {access_token, …} via ResumeElicitation). +func (t *oauthTransport) unmanagedRedirectURI() string { + if t.oauthConfig != nil && t.oauthConfig.CallbackRedirectURL != "" { + return t.oauthConfig.CallbackRedirectURL + } + return t.unmanagedOAuthRedirectURI +} + +// handleUnmanagedOAuthFlow runs the OAuth flow when the runtime is not +// managing the browser/callback machinery itself. Two sub-behaviors: +// +// - If a redirect URI is configured (either via per-toolset +// CallbackRedirectURL or the runtime-wide --mcp-oauth-redirect-uri), +// docker-agent drives the OAuth flow: generates state + PKCE, runs DCR +// if needed, builds the authorize URL, emits an elicitation carrying +// authorize_url + state, and expects the client to return {code, state} +// via ResumeElicitation. docker-agent then exchanges the code for the +// token. The client never touches the OAuth endpoints — it just opens +// the browser and forwards the deeplink payload back. +// +// - Otherwise the client is expected to drive the OAuth flow end-to-end +// and return {access_token, refresh_token, …} via ResumeElicitation +// (the legacy contract). +// +// Both reply shapes are accepted by the elicitation-result handling below +// regardless of which sub-behavior emitted the request, so a client that +// receives an authorize_url but still wants to do the exchange itself is +// free to return an access token. func (t *oauthTransport) handleUnmanagedOAuthFlow(ctx context.Context, authServer, wwwAuth string) error { slog.DebugContext(ctx, "Starting unmanaged OAuth flow for server", "url", t.baseURL) @@ -878,18 +920,66 @@ func (t *oauthTransport) handleUnmanagedOAuthFlow(ctx context.Context, authServe return fmt.Errorf("failed to fetch authorization server metadata: %w", err) } - slog.DebugContext(ctx, "Sending OAuth elicitation request to client") + // Decide which sub-behavior to run based on whether a redirect URI is + // configured. When set, docker-agent does the OAuth dance itself and + // emits authorize_url + state in the elicitation; otherwise it emits + // only metadata and waits for the client to return a ready token. + redirectURI := t.unmanagedRedirectURI() + driveFlow := redirectURI != "" + + meta := map[string]any{ + "cagent/type": "oauth_flow", + "cagent/server_url": t.baseURL, + "auth_server": resourceMetadata.AuthorizationServers[0], + "auth_server_metadata": authServerMetadata, + "resource_metadata": resourceMetadata, + } + + // Variables populated only on the docker-agent-driven path; needed + // after the elicitation if the client returns {code, state}. + var ( + clientID string + clientSecret string + scopes []string + expectedState string + pkceVerifier string + resourceIndicator string + ) + + if driveFlow { + clientID, clientSecret, scopes, err = t.resolveClientCredentials(ctx, authServerMetadata, redirectURI) + if err != nil { + return err + } + + expectedState, err = GenerateState() + if err != nil { + return fmt.Errorf("failed to generate state: %w", err) + } + pkceVerifier = GeneratePKCEVerifier() + resourceIndicator = cmp.Or(resourceMetadata.Resource, t.baseURL) + + authURL := BuildAuthorizationURL( + authServerMetadata.AuthorizationEndpoint, + clientID, + redirectURI, + expectedState, + oauth2.S256ChallengeFromVerifier(pkceVerifier), + resourceIndicator, + scopes, + ) + // Forward the values the client needs to open the browser and + // later correlate the deeplink callback back to this flow. + meta["cagent/authorize_url"] = authURL + meta["cagent/state"] = expectedState + } + + slog.DebugContext(ctx, "Sending OAuth elicitation request to client", "drive_flow", driveFlow) result, err := t.client.requestElicitation(ctx, &mcpsdk.ElicitParams{ Message: "OAuth authorization required for " + t.baseURL, RequestedSchema: nil, - Meta: map[string]any{ - "cagent/type": "oauth_flow", - "cagent/server_url": t.baseURL, - "auth_server": resourceMetadata.AuthorizationServers[0], - "auth_server_metadata": authServerMetadata, - "resource_metadata": resourceMetadata, - }, + Meta: meta, }) if err != nil { return fmt.Errorf("failed to send elicitation request: %w", err) @@ -901,35 +991,37 @@ func (t *oauthTransport) handleUnmanagedOAuthFlow(ctx context.Context, authServe return errors.New("OAuth flow declined or cancelled by client") } if result.Content == nil { - return errors.New("no token received from client") - } - - tokenData := result.Content - - token := &OAuthToken{} - - if accessToken, ok := tokenData["access_token"].(string); ok { - token.AccessToken = accessToken - } else { - return errors.New("access_token missing or invalid in client response") + return errors.New("no payload received from client") } - if tokenType, ok := tokenData["token_type"].(string); ok { - token.TokenType = tokenType + token, err := t.consumeUnmanagedElicitationReply( + ctx, + result.Content, + authServerMetadata, + resourceMetadata, + driveFlow, + expectedState, + pkceVerifier, + clientID, + clientSecret, + redirectURI, + resourceIndicator, + ) + if err != nil { + return err } - if expiresIn, ok := tokenData["expires_in"].(float64); ok { - token.ExpiresIn = int(expiresIn) - token.ExpiresAt = time.Now().Add(time.Duration(token.ExpiresIn) * time.Second) + if driveFlow { + // On the docker-agent-driven path we generated the credentials, so + // stamp them onto the token for silent refresh later. + token.ClientID = clientID + token.ClientSecret = clientSecret + token.RequestedScopes = scopes + } else if t.oauthConfig != nil { + token.RequestedScopes = t.oauthConfig.Scopes } token.AuthServer = resourceMetadata.AuthorizationServers[0] - if refreshToken, ok := tokenData["refresh_token"].(string); ok { - token.RefreshToken = refreshToken - } - if t.oauthConfig != nil { - token.RequestedScopes = t.oauthConfig.Scopes - } if err := t.tokenStore.StoreToken(t.baseURL, token); err != nil { return fmt.Errorf("failed to store token: %w", err) } @@ -937,6 +1029,76 @@ func (t *oauthTransport) handleUnmanagedOAuthFlow(ctx context.Context, authServe // Notify the runtime that the OAuth flow was successful t.client.oauthSuccess() - slog.DebugContext(ctx, "Managed OAuth flow completed successfully") + slog.DebugContext(ctx, "Unmanaged OAuth flow completed successfully") return nil } + +// consumeUnmanagedElicitationReply turns the ResumeElicitation payload into +// an OAuthToken. It accepts two shapes: +// +// - {access_token, token_type?, expires_in?, refresh_token?, scope?} +// The client did the OAuth dance itself; we just record the token. +// +// - {code, state} +// The client received the deeplink callback; docker-agent verifies the +// state, exchanges the code at the token endpoint, and returns the +// resulting token. Only valid on the docker-agent-driven path (we need +// the stored PKCE verifier + client credentials to make the exchange). +// +// If the client mixes shapes (e.g. supplies both access_token and code), the +// access_token wins to preserve the legacy behavior. +func (t *oauthTransport) consumeUnmanagedElicitationReply( + ctx context.Context, + content map[string]any, + authServerMetadata *AuthorizationServerMetadata, + resourceMetadata protectedResourceMetadata, + driveFlow bool, + expectedState, pkceVerifier, clientID, clientSecret, redirectURI, resourceIndicator string, +) (*OAuthToken, error) { + if accessToken, ok := content["access_token"].(string); ok && accessToken != "" { + token := &OAuthToken{AccessToken: accessToken} + if tokenType, ok := content["token_type"].(string); ok { + token.TokenType = tokenType + } + if expiresIn, ok := content["expires_in"].(float64); ok { + token.ExpiresIn = int(expiresIn) + token.ExpiresAt = time.Now().Add(time.Duration(token.ExpiresIn) * time.Second) + } + if refreshToken, ok := content["refresh_token"].(string); ok { + token.RefreshToken = refreshToken + } + return token, nil + } + + code, hasCode := content["code"].(string) + state, hasState := content["state"].(string) + if !hasCode || code == "" || !hasState || state == "" { + return nil, errors.New("elicitation reply must include either access_token or {code, state}") + } + if !driveFlow { + // We never sent an authorize_url; receiving {code, state} means the + // client is confused about which contract is in effect. + return nil, errors.New("received {code, state} in elicitation reply but no redirect URI was configured for unmanaged OAuth flow") + } + if subtle.ConstantTimeCompare([]byte(state), []byte(expectedState)) != 1 { + return nil, errors.New("state mismatch in elicitation reply") + } + + slog.DebugContext(ctx, "Exchanging authorization code received from client") + token, err := exchangeCodeForToken( + ctx, + t.oauthClient(), + authServerMetadata.TokenEndpoint, + code, + pkceVerifier, + clientID, + clientSecret, + redirectURI, + resourceIndicator, + ) + if err != nil { + return nil, fmt.Errorf("failed to exchange code for token: %w", err) + } + _ = resourceMetadata // captured for future audit logging; intentionally unused here + return token, nil +} diff --git a/pkg/tools/mcp/oauth_test.go b/pkg/tools/mcp/oauth_test.go index 37e29d7ab..2f9ac0f2e 100644 --- a/pkg/tools/mcp/oauth_test.go +++ b/pkg/tools/mcp/oauth_test.go @@ -8,6 +8,7 @@ import ( "net/http/httptest" "net/url" "strings" + "sync" "sync/atomic" "testing" "time" @@ -1237,3 +1238,328 @@ func TestGetAuthorizationServerMetadata_All404FallsBackToDefaults(t *testing.T) assert.Equal(t, srv.URL+"/tenant/authorize", md.AuthorizationEndpoint, "defaults must be derived from the issuer URL") } + +// --------- Unmanaged flow with docker-agent-driven OAuth --------- + +// unmanagedOAuthTestServer stands up an httptest.Server that emulates both +// the MCP server (returns 401 + WWW-Authenticate) and the authorization +// server (metadata + DCR + token endpoint) at known paths. +type unmanagedOAuthTestServer struct { + *httptest.Server + + tokenCalls atomic.Int32 + lastForm url.Values +} + +func newUnmanagedOAuthTestServer(t *testing.T) *unmanagedOAuthTestServer { + t.Helper() + srv := &unmanagedOAuthTestServer{} + mux := http.NewServeMux() + + mux.HandleFunc("/.well-known/oauth-protected-resource", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(protectedResourceMetadata{ + Resource: srv.URL, + AuthorizationServers: []string{srv.URL}, + }) + }) + mux.HandleFunc("/.well-known/oauth-authorization-server", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(AuthorizationServerMetadata{ + Issuer: srv.URL, + AuthorizationEndpoint: srv.URL + "/authorize", + TokenEndpoint: srv.URL + "/token", + RegistrationEndpoint: srv.URL + "/register", + }) + }) + mux.HandleFunc("/.well-known/openid-configuration", func(w http.ResponseWriter, r *http.Request) { + http.NotFound(w, r) + }) + mux.HandleFunc("/register", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"client_id":"registered-client-id","client_secret":"registered-secret"}`)) + }) + mux.HandleFunc("/token", func(w http.ResponseWriter, r *http.Request) { + srv.tokenCalls.Add(1) + _ = r.ParseForm() + srv.lastForm = r.Form + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"access_token":"exchanged-at","token_type":"Bearer","expires_in":3600,"refresh_token":"refresh-tok"}`)) + }) + // The MCP endpoint at "/" returns 401 until a Bearer header is present. + mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("Authorization") != "" { + w.WriteHeader(http.StatusOK) + return + } + w.Header().Set("WWW-Authenticate", `Bearer resource="`+srv.URL+`/.well-known/oauth-protected-resource"`) + w.WriteHeader(http.StatusUnauthorized) + }) + + srv.Server = httptest.NewServer(mux) + return srv +} + +// elicitCaptured records the elicitation request that the OAuth transport +// sent and lets the test reply with a chosen payload, mirroring what a +// real client (e.g. Docker Desktop) would do. +type elicitCaptured struct { + mu sync.Mutex + req *gomcp.ElicitParams + reply tools.ElicitationResult + replyFn func(req *gomcp.ElicitParams) tools.ElicitationResult +} + +func (e *elicitCaptured) handler(_ context.Context, req *gomcp.ElicitParams) (tools.ElicitationResult, error) { + e.mu.Lock() + defer e.mu.Unlock() + e.req = req + if e.replyFn != nil { + return e.replyFn(req), nil + } + return e.reply, nil +} + +// newUnmanagedTestTransport builds an oauthTransport configured for the +// unmanaged flow and wires the supplied elicitation handler. +func newUnmanagedTestTransport(t *testing.T, baseURL, redirectURI string, capture *elicitCaptured) (*oauthTransport, *remoteMCPClient) { + t.Helper() + client := newRemoteClient(baseURL, "streamable", nil, NewInMemoryTokenStore(), nil, false) + client.unmanagedOAuthRedirectURI = redirectURI + client.SetElicitationHandler(capture.handler) + // Allow private-IP destinations so the httptest server's 127.0.0.1 URLs + // aren't blocked by the SSRF guard the production OAuth helper uses. + client.allowPrivateIPs = true + transport := &oauthTransport{ + base: http.DefaultTransport, + client: client, + tokenStore: client.tokenStore, + baseURL: baseURL, + managed: false, + unmanagedOAuthRedirectURI: redirectURI, + oauthHTTPClient: oauthHTTPClientForAllowPrivateIPs(true), + } + return transport, client +} + +// TestUnmanagedOAuthFlow_DriveFlow_ExchangesCodeForToken verifies the new +// docker-agent-driven branch end-to-end: docker-agent emits the elicitation +// with authorize_url + state, the client (test stub) replies with +// {code, state}, and docker-agent exchanges the code at the token endpoint. +func TestUnmanagedOAuthFlow_DriveFlow_ExchangesCodeForToken(t *testing.T) { + srv := newUnmanagedOAuthTestServer(t) + defer srv.Close() + + const redirectURI = "https://example.test/oauth/cb" + capture := &elicitCaptured{} + capture.replyFn = func(req *gomcp.ElicitParams) tools.ElicitationResult { + // Echo the state docker-agent sent us, simulating a real OAuth + // callback round-trip. + state, _ := req.Meta["cagent/state"].(string) + return tools.ElicitationResult{ + Action: tools.ElicitationActionAccept, + Content: map[string]any{ + "code": "abc", + "state": state, + }, + } + } + transport, client := newUnmanagedTestTransport(t, srv.URL, redirectURI, capture) + + req, err := http.NewRequestWithContext(t.Context(), http.MethodPost, srv.URL, strings.NewReader("{}")) + require.NoError(t, err) + req.Header.Set("Content-Type", "application/json") + + resp, err := transport.RoundTrip(req) + require.NoError(t, err) + resp.Body.Close() + require.Equal(t, http.StatusOK, resp.StatusCode) + + // Verify the elicitation carried the new fields. + require.NotNil(t, capture.req) + assert.Equal(t, "oauth_flow", capture.req.Meta["cagent/type"]) + assert.Equal(t, srv.URL, capture.req.Meta["cagent/server_url"]) + authorizeURL, _ := capture.req.Meta["cagent/authorize_url"].(string) + assert.NotEmpty(t, authorizeURL, "drive-flow elicitation must include cagent/authorize_url") + assert.Contains(t, authorizeURL, "redirect_uri="+url.QueryEscape(redirectURI)) + state, _ := capture.req.Meta["cagent/state"].(string) + assert.NotEmpty(t, state, "drive-flow elicitation must include cagent/state") + + // Verify docker-agent exchanged the code (not the client). + require.Equal(t, int32(1), srv.tokenCalls.Load(), "token endpoint must be hit exactly once") + assert.Equal(t, "authorization_code", srv.lastForm.Get("grant_type")) + assert.Equal(t, "abc", srv.lastForm.Get("code")) + assert.Equal(t, redirectURI, srv.lastForm.Get("redirect_uri"), + "redirect_uri sent at /token must match the one used at /authorize per RFC 6749 §4.1.3") + assert.Equal(t, "registered-client-id", srv.lastForm.Get("client_id")) + assert.NotEmpty(t, srv.lastForm.Get("code_verifier"), "PKCE verifier must be sent at exchange") + + // Verify the token landed in the store with the credentials stamped on + // (for silent refresh later). + tok, err := client.tokenStore.GetToken(srv.URL) + require.NoError(t, err) + require.NotNil(t, tok) + assert.Equal(t, "exchanged-at", tok.AccessToken) + assert.Equal(t, "refresh-tok", tok.RefreshToken) + assert.Equal(t, "registered-client-id", tok.ClientID) + assert.Equal(t, "registered-secret", tok.ClientSecret) + assert.Equal(t, srv.URL, tok.AuthServer) +} + +// TestUnmanagedOAuthFlow_DriveFlow_RejectsStateMismatch verifies the CSRF +// check: if the client returns a `state` value that doesn't match what +// docker-agent generated and embedded in the authorize URL, the flow +// aborts WITHOUT calling the token endpoint. +func TestUnmanagedOAuthFlow_DriveFlow_RejectsStateMismatch(t *testing.T) { + srv := newUnmanagedOAuthTestServer(t) + defer srv.Close() + + capture := &elicitCaptured{ + reply: tools.ElicitationResult{ + Action: tools.ElicitationActionAccept, + Content: map[string]any{ + "code": "abc", + "state": "i-made-this-up", + }, + }, + } + transport, _ := newUnmanagedTestTransport(t, srv.URL, "https://example.test/oauth/cb", capture) + + req, err := http.NewRequestWithContext(t.Context(), http.MethodPost, srv.URL, strings.NewReader("{}")) + require.NoError(t, err) + req.Header.Set("Content-Type", "application/json") + + resp, err := transport.RoundTrip(req) + if resp != nil { + resp.Body.Close() + } + require.Error(t, err) + assert.Contains(t, err.Error(), "state mismatch") + assert.Equal(t, int32(0), srv.tokenCalls.Load(), "token endpoint must not be hit on state mismatch") +} + +// TestUnmanagedOAuthFlow_DriveFlow_AcceptsLegacyAccessTokenReply verifies +// that when docker-agent is driving the flow but the client decides to do +// the exchange itself anyway (returning {access_token, …}), the legacy +// reply shape is still honored — no error, token stored verbatim, no +// /token request from docker-agent. +func TestUnmanagedOAuthFlow_DriveFlow_AcceptsLegacyAccessTokenReply(t *testing.T) { + srv := newUnmanagedOAuthTestServer(t) + defer srv.Close() + + capture := &elicitCaptured{ + reply: tools.ElicitationResult{ + Action: tools.ElicitationActionAccept, + Content: map[string]any{ + "access_token": "client-provided-at", + "token_type": "Bearer", + "refresh_token": "client-provided-refresh", + }, + }, + } + transport, client := newUnmanagedTestTransport(t, srv.URL, "https://example.test/oauth/cb", capture) + + req, err := http.NewRequestWithContext(t.Context(), http.MethodPost, srv.URL, strings.NewReader("{}")) + require.NoError(t, err) + req.Header.Set("Content-Type", "application/json") + + resp, err := transport.RoundTrip(req) + require.NoError(t, err) + resp.Body.Close() + assert.Equal(t, http.StatusOK, resp.StatusCode) + assert.Equal(t, int32(0), srv.tokenCalls.Load(), + "docker-agent must not exchange when the client supplied a ready token") + + tok, err := client.tokenStore.GetToken(srv.URL) + require.NoError(t, err) + assert.Equal(t, "client-provided-at", tok.AccessToken) + assert.Equal(t, "client-provided-refresh", tok.RefreshToken) +} + +// TestUnmanagedOAuthFlow_LegacyMode_NoAuthorizeURLInElicitation verifies the +// back-compat path: with no redirect URI configured, the elicitation does +// NOT include authorize_url/state, the client returns an access token, and +// docker-agent stores it directly. +func TestUnmanagedOAuthFlow_LegacyMode_NoAuthorizeURLInElicitation(t *testing.T) { + srv := newUnmanagedOAuthTestServer(t) + defer srv.Close() + + capture := &elicitCaptured{ + reply: tools.ElicitationResult{ + Action: tools.ElicitationActionAccept, + Content: map[string]any{ + "access_token": "legacy-at", + "token_type": "Bearer", + }, + }, + } + // Empty redirect URI → legacy client-driven mode. + transport, _ := newUnmanagedTestTransport(t, srv.URL, "", capture) + + req, err := http.NewRequestWithContext(t.Context(), http.MethodPost, srv.URL, strings.NewReader("{}")) + require.NoError(t, err) + req.Header.Set("Content-Type", "application/json") + + resp, err := transport.RoundTrip(req) + require.NoError(t, err) + resp.Body.Close() + + require.NotNil(t, capture.req) + _, hasAuthorizeURL := capture.req.Meta["cagent/authorize_url"] + assert.False(t, hasAuthorizeURL, "legacy unmanaged flow must not include cagent/authorize_url") + _, hasState := capture.req.Meta["cagent/state"] + assert.False(t, hasState, "legacy unmanaged flow must not include cagent/state") + // resource_metadata is still surfaced so the client can do its own DCR + // if desired. + assert.NotNil(t, capture.req.Meta["resource_metadata"]) +} + +// TestUnmanagedOAuthFlow_LegacyMode_RejectsCodeStateReply verifies that a +// client which sends {code, state} despite docker-agent not emitting an +// authorize_url is rejected — there is no stored PKCE verifier to exchange +// the code with, so the flow cannot complete. +func TestUnmanagedOAuthFlow_LegacyMode_RejectsCodeStateReply(t *testing.T) { + srv := newUnmanagedOAuthTestServer(t) + defer srv.Close() + + capture := &elicitCaptured{ + reply: tools.ElicitationResult{ + Action: tools.ElicitationActionAccept, + Content: map[string]any{ + "code": "abc", + "state": "xyz", + }, + }, + } + transport, _ := newUnmanagedTestTransport(t, srv.URL, "", capture) + + req, err := http.NewRequestWithContext(t.Context(), http.MethodPost, srv.URL, strings.NewReader("{}")) + require.NoError(t, err) + req.Header.Set("Content-Type", "application/json") + + resp, err := transport.RoundTrip(req) + if resp != nil { + resp.Body.Close() + } + require.Error(t, err) + assert.Contains(t, err.Error(), "no redirect URI was configured") +} + +// TestUnmanagedRedirectURI_PerToolsetTakesPrecedence verifies the precedence +// order: per-toolset RemoteOAuthConfig.CallbackRedirectURL overrides the +// runtime-wide --mcp-oauth-redirect-uri. +func TestUnmanagedRedirectURI_PerToolsetTakesPrecedence(t *testing.T) { + transport := &oauthTransport{ + unmanagedOAuthRedirectURI: "https://global.example/cb", + oauthConfig: &latest.RemoteOAuthConfig{ + CallbackRedirectURL: "https://per-toolset.example/cb", + }, + } + assert.Equal(t, "https://per-toolset.example/cb", transport.unmanagedRedirectURI()) + + transport.oauthConfig = nil + assert.Equal(t, "https://global.example/cb", transport.unmanagedRedirectURI()) + + transport.unmanagedOAuthRedirectURI = "" + assert.Empty(t, transport.unmanagedRedirectURI()) +} diff --git a/pkg/tools/mcp/reconnect_test.go b/pkg/tools/mcp/reconnect_test.go index 5bc7c094d..8ef8aaa0c 100644 --- a/pkg/tools/mcp/reconnect_test.go +++ b/pkg/tools/mcp/reconnect_test.go @@ -71,6 +71,7 @@ func (m *failingInitClient) SetElicitationHandler(tools.ElicitationHandler) {} func (m *failingInitClient) SetSamplingHandler(tools.SamplingHandler) {} func (m *failingInitClient) SetOAuthSuccessHandler(func()) {} func (m *failingInitClient) SetManagedOAuth(bool) {} +func (m *failingInitClient) SetUnmanagedOAuthRedirectURI(string) {} func (m *failingInitClient) SetToolListChangedHandler(func()) {} func (m *failingInitClient) SetPromptListChangedHandler(func()) {} diff --git a/pkg/tools/mcp/remote.go b/pkg/tools/mcp/remote.go index 0e2600323..97d82fcdd 100644 --- a/pkg/tools/mcp/remote.go +++ b/pkg/tools/mcp/remote.go @@ -15,13 +15,14 @@ import ( type remoteMCPClient struct { sessionClient - url string - transportType string - headers map[string]string - tokenStore OAuthTokenStore - managed bool - oauthConfig *latest.RemoteOAuthConfig - allowPrivateIPs bool + url string + transportType string + headers map[string]string + tokenStore OAuthTokenStore + managed bool + unmanagedOAuthRedirectURI string + oauthConfig *latest.RemoteOAuthConfig + allowPrivateIPs bool } func newRemoteClient( @@ -139,6 +140,15 @@ func (c *remoteMCPClient) SetManagedOAuth(managed bool) { c.mu.Unlock() } +// SetUnmanagedOAuthRedirectURI sets the redirect URI docker-agent advertises +// when running the OAuth flow in unmanaged mode. See OAuthCapable for full +// semantics. +func (c *remoteMCPClient) SetUnmanagedOAuthRedirectURI(uri string) { + c.mu.Lock() + c.unmanagedOAuthRedirectURI = uri + c.mu.Unlock() +} + // createHTTPClient creates an HTTP client with custom headers and OAuth support. // Header values may contain ${headers.NAME} placeholders that are resolved // at request time from upstream headers stored in the request context. @@ -151,13 +161,14 @@ func (c *remoteMCPClient) createHTTPClient() (*http.Client, *oauthTransport) { // Then wrap with OAuth support oauthT := &oauthTransport{ - base: base, - client: c, - tokenStore: c.tokenStore, - baseURL: c.url, - managed: c.managed, - oauthConfig: c.oauthConfig, - oauthHTTPClient: oauthHTTPClientForAllowPrivateIPs(c.allowPrivateIPs), + base: base, + client: c, + tokenStore: c.tokenStore, + baseURL: c.url, + managed: c.managed, + unmanagedOAuthRedirectURI: c.unmanagedOAuthRedirectURI, + oauthConfig: c.oauthConfig, + oauthHTTPClient: oauthHTTPClientForAllowPrivateIPs(c.allowPrivateIPs), } return &http.Client{Transport: oauthT}, oauthT diff --git a/pkg/tools/mcp/session_client.go b/pkg/tools/mcp/session_client.go index cdef1c9a8..86852a861 100644 --- a/pkg/tools/mcp/session_client.go +++ b/pkg/tools/mcp/session_client.go @@ -235,3 +235,9 @@ func (c *sessionClient) oauthSuccess() { // SetManagedOAuth is a no-op at the session level. The remoteMCPClient // overrides this to store the managed flag for its OAuth transport. func (c *sessionClient) SetManagedOAuth(bool) {} + +// SetUnmanagedOAuthRedirectURI is a no-op at the session level. The +// remoteMCPClient overrides this to store the URI for its OAuth transport. +// Stdio MCP clients never run OAuth (they have no HTTP transport to +// authenticate), so the URI is ignored there too. +func (c *sessionClient) SetUnmanagedOAuthRedirectURI(string) {}