Skip to content

Commit 4e3856b

Browse files
authored
Merge pull request #2896 from trungutt/extend-unmanaged-oauth-flow
Extend unmanaged OAuth flow to drive code exchange in-process
2 parents ff17cf1 + 10c7a3c commit 4e3856b

22 files changed

Lines changed: 1485 additions & 112 deletions

cmd/root/flags.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,12 @@ func addRuntimeConfigFlags(cmd *cobra.Command, runConfig *config.RuntimeConfig)
3636
cmd.PersistentFlags().StringArrayVar(&runConfig.HookSessionEnd, "hook-session-end", nil, "Add a session-end hook command (repeatable)")
3737
cmd.PersistentFlags().StringArrayVar(&runConfig.HookOnUserInput, "hook-on-user-input", nil, "Add an on-user-input hook command (repeatable)")
3838
cmd.PersistentFlags().StringArrayVar(&runConfig.HookStop, "hook-stop", nil, "Add a stop hook command, fired when the model finishes responding (repeatable)")
39+
cmd.PersistentFlags().StringVar(&runConfig.MCPOAuthRedirectURI, "mcp-oauth-redirect-uri", "",
40+
"Public HTTPS URL to advertise as the OAuth `redirect_uri` for MCP servers "+
41+
"running in unmanaged OAuth mode. When set, docker-agent drives the OAuth flow "+
42+
"itself (PKCE + DCR + token exchange) and expects clients to return `{code, state}` "+
43+
"via ResumeElicitation. When empty, the client is expected to perform the OAuth "+
44+
"flow and return an access token (legacy behavior).")
3945
}
4046

4147
func setupWorkingDirectory(workingDir string) error {

docs/features/remote-mcp/index.md

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,30 @@ The local callback server still listens on the loopback interface on `callbackPo
106106
- Only `http` and `https` schemes are accepted.
107107
- `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).
108108

109+
### Unmanaged OAuth flow (server mode)
110+
111+
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:
112+
113+
- **`--mcp-oauth-redirect-uri=<URL>` 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:
114+
115+
| Key | Value |
116+
| ---------------------------- | ---------------------------------------------------------------- |
117+
| `cagent/type` | `"oauth_flow"` |
118+
| `cagent/server_url` | The MCP server URL (for display / favicon) |
119+
| `cagent/authorize_url` | The full URL the client should open in the user's browser |
120+
| `cagent/state` | The `state` value the client must echo back when replying |
121+
| `auth_server` | Issuer of the authorization server |
122+
| `auth_server_metadata` | RFC 8414 authorization-server metadata document |
123+
| `resource_metadata` | RFC 9728 protected-resource metadata document |
124+
125+
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 ...`.
126+
127+
- **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": "...", ...}`.
128+
129+
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.
130+
131+
A per-toolset `callbackRedirectURL` (in the YAML) overrides the runtime-wide `--mcp-oauth-redirect-uri` for that toolset.
132+
109133
## Project Management &amp; Collaboration
110134

111135
| Service | URL | Transport | Description |

pkg/config/runtime.go

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,27 @@ type Config struct {
4444

4545
MCPToolName string
4646
MCPKeepAlive time.Duration
47+
48+
// MCPOAuthRedirectURI is an opaque public HTTPS URL the runtime advertises
49+
// as the OAuth `redirect_uri` when running an MCP server OAuth flow in
50+
// unmanaged mode (see WithManagedOAuth(false)). When set, docker-agent
51+
// generates state + PKCE + DCR in-process and emits an elicitation
52+
// carrying the `authorize_url` + `state`; the client is then a thin
53+
// relay that opens the browser, receives the callback (typically via a
54+
// host-controlled bouncer + deeplink), and returns {code, state} via
55+
// ResumeElicitation. docker-agent then exchanges the code for the
56+
// token using this same URI as redirect_uri (RFC 6749 §4.1.3 requires
57+
// the value to match the one sent at the /authorize step).
58+
//
59+
// When empty, the unmanaged flow keeps its original contract: the
60+
// client is expected to drive the OAuth dance end-to-end and return
61+
// {access_token, refresh_token, …}. This preserves backward compat
62+
// with existing CLI-mirror clients.
63+
//
64+
// The URI itself is opaque to docker-agent — what it points at and how
65+
// the browser eventually lands back in the host application is the
66+
// caller's concern.
67+
MCPOAuthRedirectURI string
4768
}
4869

4970
func (runConfig *RuntimeConfig) Clone() *RuntimeConfig {

pkg/runtime/loop.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -841,6 +841,7 @@ func (r *LocalRuntime) configureToolsetHandlers(a *agent.Agent, events EventSink
841841
r.samplingHandler,
842842
func() { events.Emit(Authorization(tools.ElicitationActionAccept, a.Name())) },
843843
r.managedOAuth,
844+
r.unmanagedOAuthRedirectURI,
844845
)
845846

846847
// Wire RAG event forwarding so the TUI shows indexing progress.

pkg/runtime/runtime.go

Lines changed: 31 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -177,22 +177,23 @@ type ModelStore interface {
177177

178178
// LocalRuntime manages the execution of agents
179179
type LocalRuntime struct {
180-
toolMap map[string]ToolHandlerFunc
181-
team *team.Team
182-
agents *agentRouter
183-
resumeChan chan ResumeRequest
184-
tracer trace.Tracer
185-
modelsStore ModelStore
186-
sessionCompaction bool
187-
managedOAuth bool
188-
nonInteractive bool
189-
startupInfoEmitted bool // Track if startup info has been emitted to avoid unnecessary duplication
190-
elicitationRequestCh chan ElicitationResult // Channel for receiving elicitation responses
191-
elicitation elicitationBridge // Owns the per-stream events channel for outbound elicitation requests
192-
sessionStore session.Store
193-
workingDir string // Working directory for hooks execution
194-
env []string // Environment variables for hooks execution
195-
modelSwitcherCfg *ModelSwitcherConfig
180+
toolMap map[string]ToolHandlerFunc
181+
team *team.Team
182+
agents *agentRouter
183+
resumeChan chan ResumeRequest
184+
tracer trace.Tracer
185+
modelsStore ModelStore
186+
sessionCompaction bool
187+
managedOAuth bool
188+
unmanagedOAuthRedirectURI string
189+
nonInteractive bool
190+
startupInfoEmitted bool // Track if startup info has been emitted to avoid unnecessary duplication
191+
elicitationRequestCh chan ElicitationResult // Channel for receiving elicitation responses
192+
elicitation elicitationBridge // Owns the per-stream events channel for outbound elicitation requests
193+
sessionStore session.Store
194+
workingDir string // Working directory for hooks execution
195+
env []string // Environment variables for hooks execution
196+
modelSwitcherCfg *ModelSwitcherConfig
196197

197198
// hooksRegistry is the runtime-private hooks.Registry used to build
198199
// every Executor. It carries the runtime-owned builtin hooks
@@ -291,6 +292,20 @@ func WithManagedOAuth(managed bool) Opt {
291292
}
292293
}
293294

295+
// WithUnmanagedOAuthRedirectURI configures the redirect_uri the runtime
296+
// advertises when running MCP server OAuth flows in unmanaged mode (i.e.
297+
// when WithManagedOAuth(false) is set). When set, docker-agent generates
298+
// state + PKCE + DCR in-process and emits an elicitation carrying the
299+
// `authorize_url` + `state`; the client returns `{code, state}` via
300+
// ResumeElicitation and docker-agent does the token exchange itself.
301+
// When empty, the runtime falls back to the legacy unmanaged contract
302+
// where the client performs the OAuth flow and returns an access token.
303+
func WithUnmanagedOAuthRedirectURI(uri string) Opt {
304+
return func(r *LocalRuntime) {
305+
r.unmanagedOAuthRedirectURI = uri
306+
}
307+
}
308+
294309
// WithNonInteractive marks the runtime as headless (e.g., MCP serve mode).
295310
// When set, blocking operations like elicitation requests are automatically
296311
// declined instead of waiting for user interaction that will never come.

pkg/runtime/runtime_test.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -963,6 +963,8 @@ func (s *oauthAwareToolSet) SetManagedOAuth(managed bool) {
963963
s.managedOAuthSet = true
964964
}
965965

966+
func (s *oauthAwareToolSet) SetUnmanagedOAuthRedirectURI(string) {}
967+
966968
// TestEmitStartupInfo_DoesNotBlockOnInteractiveOAuth verifies that the
967969
// startup path does NOT trigger interactive flows on toolsets. In particular:
968970
//

pkg/server/mcp_oauth_test.go

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
package server
2+
3+
import (
4+
"context"
5+
"io"
6+
"net"
7+
"net/http"
8+
"path/filepath"
9+
"strings"
10+
"testing"
11+
12+
"github.com/stretchr/testify/assert"
13+
"github.com/stretchr/testify/require"
14+
15+
"github.com/docker/docker-agent/pkg/config"
16+
)
17+
18+
// httpDoStatus is a slim variant of httpDo that exposes the response
19+
// status code. The standard helper assumes 2xx and only returns the body;
20+
// these tests assert on 4xx, so they need direct access to the status.
21+
func httpDoStatus(t *testing.T, ctx context.Context, method, socketPath, path string) int {
22+
t.Helper()
23+
req, err := http.NewRequestWithContext(ctx, method, "http://_"+path, http.NoBody)
24+
require.NoError(t, err)
25+
client := &http.Client{
26+
Transport: &http.Transport{
27+
DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) {
28+
var d net.Dialer
29+
return d.DialContext(ctx, "unix", strings.TrimPrefix(socketPath, "unix://"))
30+
},
31+
},
32+
}
33+
resp, err := client.Do(req)
34+
require.NoError(t, err)
35+
defer resp.Body.Close()
36+
_, err = io.Copy(io.Discard, resp.Body)
37+
require.NoError(t, err)
38+
return resp.StatusCode
39+
}
40+
41+
func startServerBare(t *testing.T, ctx context.Context) string {
42+
t.Helper()
43+
var store mockStore
44+
runConfig := config.RuntimeConfig{}
45+
sources, err := config.ResolveSources(t.TempDir(), nil)
46+
require.NoError(t, err)
47+
srv, err := New(ctx, store, &runConfig, 0, sources, "")
48+
require.NoError(t, err)
49+
50+
socketPath := "unix://" + filepath.Join(t.TempDir(), "sock")
51+
ln, err := Listen(ctx, socketPath)
52+
require.NoError(t, err)
53+
go func() { <-ctx.Done(); _ = ln.Close() }()
54+
go func() { _ = srv.Serve(ctx, ln) }()
55+
return socketPath
56+
}
57+
58+
// The happy path (waiter registered, callback delivered) is covered end
59+
// to end in TestUnmanagedOAuthFlow_DriveFlow_AcceptsDirectCallback in
60+
// pkg/tools/mcp. The server-side tests here focus on the input
61+
// validation and the 404 response shape so the embedder's HTTP client
62+
// can rely on it.
63+
64+
// Short test names because the macOS unix-socket path limit (104 bytes)
65+
// includes t.TempDir() which embeds the test name.
66+
67+
func TestMcpOAuthCb_Unknown(t *testing.T) {
68+
ctx := t.Context()
69+
lnPath := startServerBare(t, ctx)
70+
71+
status := httpDoStatus(t, ctx, http.MethodPost, lnPath,
72+
"/api/mcp-oauth/callback?state=unknown-state&code=abc")
73+
assert.Equal(t, http.StatusNotFound, status)
74+
}
75+
76+
func TestMcpOAuthCb_NoState(t *testing.T) {
77+
ctx := t.Context()
78+
lnPath := startServerBare(t, ctx)
79+
80+
status := httpDoStatus(t, ctx, http.MethodPost, lnPath,
81+
"/api/mcp-oauth/callback?code=abc")
82+
assert.Equal(t, http.StatusBadRequest, status)
83+
}
84+
85+
func TestMcpOAuthCb_NoCode(t *testing.T) {
86+
ctx := t.Context()
87+
lnPath := startServerBare(t, ctx)
88+
89+
status := httpDoStatus(t, ctx, http.MethodPost, lnPath,
90+
"/api/mcp-oauth/callback?state=some-state")
91+
assert.Equal(t, http.StatusBadRequest, status)
92+
}

pkg/server/server.go

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import (
2121
"github.com/docker/docker-agent/pkg/echolog"
2222
"github.com/docker/docker-agent/pkg/runtime"
2323
"github.com/docker/docker-agent/pkg/session"
24+
"github.com/docker/docker-agent/pkg/tools/mcp"
2425
"github.com/docker/docker-agent/pkg/upstream"
2526
)
2627

@@ -90,6 +91,8 @@ func (s *Server) registerRoutes() {
9091

9192
group.GET("/agents/:id/:agent_name/tools/count", s.getAgentToolCount)
9293

94+
group.POST("/mcp-oauth/callback", s.mcpOAuthCallback)
95+
9396
group.GET("/ping", func(c echo.Context) error {
9497
return c.JSON(http.StatusOK, map[string]string{"status": "ok"})
9598
})
@@ -406,6 +409,52 @@ func (s *Server) elicitation(c echo.Context) error {
406409
return c.JSON(http.StatusOK, nil)
407410
}
408411

412+
// mcpOAuthCallback is the out-of-band entry point used by embedders
413+
// that receive an OAuth deeplink (e.g. a system-wide URL-scheme handler
414+
// or an OS-integrated launcher) and want to forward the resulting
415+
// {code, state} to docker-agent without going through the session-keyed
416+
// ResumeElicitation path.
417+
//
418+
// The state value is opaque, high-entropy and was generated in-process by
419+
// docker-agent's unmanaged OAuth flow (see GenerateState in
420+
// pkg/tools/mcp). Looking it up in the pending-oauth registry IS the
421+
// authentication: docker-agent only accepts callbacks for states it is
422+
// currently awaiting. An unknown state returns 404 (which is the
423+
// expected outcome for replays and any state value the agent did not
424+
// itself generate).
425+
//
426+
// The handler never blocks: it hands the callback to the buffered
427+
// channel of the waiting flow and returns immediately. The token
428+
// exchange and storage happen inside that flow's goroutine, which then
429+
// emits the existing authorization_event on the session SSE stream.
430+
func (s *Server) mcpOAuthCallback(c echo.Context) error {
431+
q := c.QueryParams()
432+
state := q.Get("state")
433+
if state == "" {
434+
return echo.NewHTTPError(http.StatusBadRequest, "missing state query parameter")
435+
}
436+
code := q.Get("code")
437+
errStr := q.Get("error")
438+
errDesc := q.Get("error_description")
439+
if code == "" && errStr == "" {
440+
return echo.NewHTTPError(http.StatusBadRequest, "missing both code and error query parameters")
441+
}
442+
443+
err := mcp.DeliverPendingOAuthCallback(state, mcp.PendingOAuthCallback{
444+
Code: code,
445+
Error: errStr,
446+
ErrDesc: errDesc,
447+
})
448+
if errors.Is(err, mcp.ErrPendingOAuthNoWaiter) {
449+
return echo.NewHTTPError(http.StatusNotFound, "no pending OAuth flow for the given state")
450+
}
451+
if err != nil {
452+
slog.WarnContext(c.Request().Context(), "Failed to deliver pending oauth callback", "error", err)
453+
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to deliver pending oauth callback: %v", err))
454+
}
455+
return c.JSON(http.StatusOK, nil)
456+
}
457+
409458
func (s *Server) steerSession(c echo.Context) error {
410459
sessionID := c.Param("id")
411460
var req api.SteerSessionRequest

pkg/server/session_manager.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -665,6 +665,7 @@ func (sm *SessionManager) runtimeForSession(ctx context.Context, sess *session.S
665665
opts := []runtime.Opt{
666666
runtime.WithCurrentAgent(currentAgent),
667667
runtime.WithManagedOAuth(false),
668+
runtime.WithUnmanagedOAuthRedirectURI(rc.MCPOAuthRedirectURI),
668669
runtime.WithSessionStore(sm.sessionStore),
669670
runtime.WithTracer(otel.Tracer("cagent")),
670671
runtime.WithModelSwitcherConfig(modelSwitcherCfg),

pkg/tools/builtin/mcpcatalog/mcpcatalog.go

Lines changed: 23 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -84,11 +84,12 @@ type Toolset struct {
8484
// OAuth-success refreshes, the managed-vs-unmanaged flag and
8585
// tool-list change notifications behave identically to a YAML-
8686
// declared `mcp.remote` toolset.
87-
elicitationHandler tools.ElicitationHandler
88-
oauthSuccessHandler func()
89-
toolsChangedHandler func()
90-
managedOAuth bool
91-
managedOAuthSet bool // distinguishes "default" from "explicitly false"
87+
elicitationHandler tools.ElicitationHandler
88+
oauthSuccessHandler func()
89+
toolsChangedHandler func()
90+
managedOAuth bool
91+
managedOAuthSet bool // distinguishes "default" from "explicitly false"
92+
unmanagedOAuthRedirectURI string
9293

9394
// removeOAuthToken drops a persisted OAuth token by resource URL.
9495
// Defaults to mcp.RemoveOAuthToken; tests inject a stub to avoid
@@ -226,6 +227,20 @@ func (t *Toolset) SetManagedOAuth(managed bool) {
226227
}
227228
}
228229

230+
// SetUnmanagedOAuthRedirectURI forwards the unmanaged-OAuth redirect URI
231+
// to every enabled toolset; new toolsets pick it up at enable time.
232+
func (t *Toolset) SetUnmanagedOAuthRedirectURI(uri string) {
233+
t.mu.Lock()
234+
t.unmanagedOAuthRedirectURI = uri
235+
enabled := t.snapshotEnabled()
236+
t.mu.Unlock()
237+
for _, ts := range enabled {
238+
if o, ok := tools.As[tools.OAuthCapable](ts); ok {
239+
o.SetUnmanagedOAuthRedirectURI(uri)
240+
}
241+
}
242+
}
243+
229244
// SetToolsChangedHandler is invoked by the runtime to be notified when
230245
// the set of available tools changes. We forward to the activated MCP
231246
// 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
563578
if t.managedOAuthSet {
564579
mcpToolset.SetManagedOAuth(t.managedOAuth)
565580
}
581+
if t.unmanagedOAuthRedirectURI != "" {
582+
mcpToolset.SetUnmanagedOAuthRedirectURI(t.unmanagedOAuthRedirectURI)
583+
}
566584

567585
wrapped := tools.NewStartable(mcpToolset)
568586
t.enabled[id] = wrapped

0 commit comments

Comments
 (0)