Skip to content
Merged
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
92 changes: 92 additions & 0 deletions pkg/server/mcp_oauth_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
package server

import (
"context"
"io"
"net"
"net/http"
"path/filepath"
"strings"
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"

"github.com/docker/docker-agent/pkg/config"
)

// httpDoStatus is a slim variant of httpDo that exposes the response
// status code. The standard helper assumes 2xx and only returns the body;
// these tests assert on 4xx, so they need direct access to the status.
func httpDoStatus(t *testing.T, ctx context.Context, method, socketPath, path string) int {
t.Helper()
req, err := http.NewRequestWithContext(ctx, method, "http://_"+path, http.NoBody)
require.NoError(t, err)
client := &http.Client{
Transport: &http.Transport{
DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) {
var d net.Dialer
return d.DialContext(ctx, "unix", strings.TrimPrefix(socketPath, "unix://"))
},
},
}
resp, err := client.Do(req)
require.NoError(t, err)
defer resp.Body.Close()
_, err = io.Copy(io.Discard, resp.Body)
require.NoError(t, err)
return resp.StatusCode
}

func startServerBare(t *testing.T, ctx context.Context) string {
t.Helper()
var store mockStore
runConfig := config.RuntimeConfig{}
sources, err := config.ResolveSources(t.TempDir(), nil)
require.NoError(t, err)
srv, err := New(ctx, store, &runConfig, 0, sources, "")
require.NoError(t, err)

socketPath := "unix://" + filepath.Join(t.TempDir(), "sock")
ln, err := Listen(ctx, socketPath)
require.NoError(t, err)
go func() { <-ctx.Done(); _ = ln.Close() }()
go func() { _ = srv.Serve(ctx, ln) }()
return socketPath
}

// The happy path (waiter registered, callback delivered) is covered end
// to end in TestUnmanagedOAuthFlow_DriveFlow_AcceptsDirectCallback in
// pkg/tools/mcp. The server-side tests here focus on the input
// validation and the 404 response shape so the embedder's HTTP client
// can rely on it.

// Short test names because the macOS unix-socket path limit (104 bytes)
// includes t.TempDir() which embeds the test name.

func TestMcpOAuthCb_Unknown(t *testing.T) {
ctx := t.Context()
lnPath := startServerBare(t, ctx)

status := httpDoStatus(t, ctx, http.MethodPost, lnPath,
"/api/mcp-oauth/callback?state=unknown-state&code=abc")
assert.Equal(t, http.StatusNotFound, status)
}

func TestMcpOAuthCb_NoState(t *testing.T) {
ctx := t.Context()
lnPath := startServerBare(t, ctx)

status := httpDoStatus(t, ctx, http.MethodPost, lnPath,
"/api/mcp-oauth/callback?code=abc")
assert.Equal(t, http.StatusBadRequest, status)
}

func TestMcpOAuthCb_NoCode(t *testing.T) {
ctx := t.Context()
lnPath := startServerBare(t, ctx)

status := httpDoStatus(t, ctx, http.MethodPost, lnPath,
"/api/mcp-oauth/callback?state=some-state")
assert.Equal(t, http.StatusBadRequest, status)
}
49 changes: 49 additions & 0 deletions pkg/server/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import (
"github.com/docker/docker-agent/pkg/echolog"
"github.com/docker/docker-agent/pkg/runtime"
"github.com/docker/docker-agent/pkg/session"
"github.com/docker/docker-agent/pkg/tools/mcp"
"github.com/docker/docker-agent/pkg/upstream"
)

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

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

group.POST("/mcp-oauth/callback", s.mcpOAuthCallback)
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[MEDIUM] /api/mcp-oauth/callback is unauthenticated when no bearer token is configured

The endpoint is registered under the group that only has BearerTokenMiddleware when authToken != "":

if authToken != "" {
    e.Use(BearerTokenMiddleware(authToken))
}

When the server runs without --auth-token (a documented/valid configuration), any network-reachable client can POST to /api/mcp-oauth/callback?state=<s>&code=<c> with no credentials.

The pending-OAuth registry provides meaningful defence: only states the runtime has generated and is currently awaiting are accepted (unknown states → 404). However, if an attacker can observe a live state value (it appears in the elicitation meta sent to the client over the session stream, at debug-level logs, and in the authorize URL the client is asked to open), they can inject a malicious authorization code. The token exchange would then proceed with an attacker-controlled code, potentially yielding the attacker's own tokens bound to the victim's session.

Suggestions:

  • This route is not a "deeplink receiver" in the same sense as the OAuth redirect URI on the authorization server — it is a programmatic API call made by the embedder. Requiring an auth token for it is reasonable even in deployments where other API calls are also authenticated.
  • At minimum, add a comment explaining the threat model (what the registry guards against, what it does not) so operators know the risk of running without an auth token.
  • Consider rate-limiting or adding an explicit warning in the docs that running without --auth-token weakens the callback endpoint's security posture.


group.GET("/ping", func(c echo.Context) error {
return c.JSON(http.StatusOK, map[string]string{"status": "ok"})
})
Expand Down Expand Up @@ -406,6 +409,52 @@ func (s *Server) elicitation(c echo.Context) error {
return c.JSON(http.StatusOK, nil)
}

// mcpOAuthCallback is the out-of-band entry point used by embedders
// that receive an OAuth deeplink (e.g. a system-wide URL-scheme handler
// or an OS-integrated launcher) and want to forward the resulting
// {code, state} to docker-agent without going through the session-keyed
// ResumeElicitation path.
//
// The state value is opaque, high-entropy and was generated in-process by
// docker-agent's unmanaged OAuth flow (see GenerateState in
// pkg/tools/mcp). Looking it up in the pending-oauth registry IS the
// authentication: docker-agent only accepts callbacks for states it is
// currently awaiting. An unknown state returns 404 (which is the
// expected outcome for replays and any state value the agent did not
// itself generate).
//
// The handler never blocks: it hands the callback to the buffered
// channel of the waiting flow and returns immediately. The token
// exchange and storage happen inside that flow's goroutine, which then
// emits the existing authorization_event on the session SSE stream.
func (s *Server) mcpOAuthCallback(c echo.Context) error {
q := c.QueryParams()
state := q.Get("state")
if state == "" {
return echo.NewHTTPError(http.StatusBadRequest, "missing state query parameter")
}
code := q.Get("code")
errStr := q.Get("error")
errDesc := q.Get("error_description")
if code == "" && errStr == "" {
return echo.NewHTTPError(http.StatusBadRequest, "missing both code and error query parameters")
}

err := mcp.DeliverPendingOAuthCallback(state, mcp.PendingOAuthCallback{
Code: code,
Error: errStr,
ErrDesc: errDesc,
})
if errors.Is(err, mcp.ErrPendingOAuthNoWaiter) {
return echo.NewHTTPError(http.StatusNotFound, "no pending OAuth flow for the given state")
}
if err != nil {
slog.WarnContext(c.Request().Context(), "Failed to deliver pending oauth callback", "error", err)
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to deliver pending oauth callback: %v", err))
}
return c.JSON(http.StatusOK, nil)
}

func (s *Server) steerSession(c echo.Context) error {
sessionID := c.Param("id")
var req api.SteerSessionRequest
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)
}
}
Loading
Loading