Skip to content
Draft
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
6 changes: 6 additions & 0 deletions cmd/root/flags.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
24 changes: 24 additions & 0 deletions docs/features/remote-mcp/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -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=<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:

| 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 &amp; Collaboration

| Service | URL | Transport | Description |
Expand Down
21 changes: 21 additions & 0 deletions pkg/config/runtime.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
1 change: 1 addition & 0 deletions pkg/runtime/loop.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
47 changes: 31 additions & 16 deletions pkg/runtime/runtime.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down
2 changes: 2 additions & 0 deletions pkg/runtime/runtime_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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:
//
Expand Down
1 change: 1 addition & 0 deletions pkg/server/session_manager.go
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
28 changes: 23 additions & 5 deletions pkg/tools/builtin/mcpcatalog/mcpcatalog.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
9 changes: 8 additions & 1 deletion pkg/tools/capabilities.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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)
}
Expand All @@ -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)
}
}
5 changes: 5 additions & 0 deletions pkg/tools/mcp/mcp.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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()
Expand Down
2 changes: 2 additions & 0 deletions pkg/tools/mcp/mcp_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()) {}
Expand Down
Loading
Loading