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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions agent-schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -1253,6 +1253,10 @@
"additionalProperties": {
"type": "string"
}
},
"oauth": {
"$ref": "#/definitions/RemoteOAuthConfig",
"description": "Explicit OAuth credentials for servers that do not support Dynamic Client Registration"
}
},
"required": [
Expand Down Expand Up @@ -1686,6 +1690,36 @@
"strategies"
],
"additionalProperties": false
},
"RemoteOAuthConfig": {
"type": "object",
"description": "OAuth configuration for remote MCP servers that do not support Dynamic Client Registration (RFC 7591)",
"properties": {
"clientId": {
"type": "string",
"description": "OAuth client ID"
},
"clientSecret": {
"type": "string",
"description": "OAuth client secret"
},
"callbackPort": {
"type": "integer",
"description": "Fixed port for the OAuth callback server (default: random available port)",
"minimum": 1,
"maximum": 65535
},
"scopes": {
"type": "array",
"description": "OAuth scopes to request",
"items": {
"type": "string"
}
}
},
"required": [
"clientId"
]
}
}
}
23 changes: 23 additions & 0 deletions examples/remote_mcp_oauth.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
#!/usr/bin/env docker agent run

# Example: Remote MCP server with explicit OAuth credentials.
# Use this when connecting to MCP servers that do NOT support
# Dynamic Client Registration (RFC 7591), such as Slack or GitHub.

agents:
root:
model: openai/gpt-4.1-mini
description: Assistant with remote MCP tools using explicit OAuth credentials
instruction: You are a helpful assistant with access to remote tools.
toolsets:
- type: mcp
remote:
url: "https://mcp.example.com/sse"
transport_type: sse
oauth:
clientId: "your-client-id"
clientSecret: "your-client-secret"
callbackPort: 8080
scopes:
- "read"
- "write"
16 changes: 13 additions & 3 deletions pkg/config/latest/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -741,9 +741,19 @@ func (t *Toolset) UnmarshalYAML(unmarshal func(any) error) error {
}

type Remote struct {
URL string `json:"url"`
TransportType string `json:"transport_type,omitempty"`
Headers map[string]string `json:"headers,omitempty"`
URL string `json:"url"`
TransportType string `json:"transport_type,omitempty"`
Headers map[string]string `json:"headers,omitempty"`
OAuth *RemoteOAuthConfig `json:"oauth,omitempty"`
}

// RemoteOAuthConfig holds explicit OAuth credentials for remote MCP servers
// that do not support Dynamic Client Registration (RFC 7591).
type RemoteOAuthConfig struct {
ClientID string `json:"clientId"`
ClientSecret string `json:"clientSecret,omitempty"`
CallbackPort int `json:"callbackPort,omitempty"`
Scopes []string `json:"scopes,omitempty"`
}

// DeferConfig represents the deferred loading configuration for a toolset.
Expand Down
13 changes: 12 additions & 1 deletion pkg/config/latest/validate.go
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,7 @@ func (t *Toolset) validate() error {
if t.Ref != "" && t.Type != "mcp" && t.Type != "rag" {
return errors.New("ref can only be used with type 'mcp' or 'rag'")
}
if (t.Remote.URL != "" || t.Remote.TransportType != "") && t.Type != "mcp" {
if (t.Remote.URL != "" || t.Remote.TransportType != "" || t.Remote.OAuth != nil) && t.Type != "mcp" {
return errors.New("remote can only be used with type 'mcp'")
}
if (len(t.Remote.Headers) > 0) && (t.Type != "mcp" && t.Type != "a2a") {
Expand Down Expand Up @@ -139,6 +139,17 @@ func (t *Toolset) validate() error {
if count > 1 {
return errors.New("either command, remote or ref must be set, but only one of those")
}
if t.Remote.OAuth != nil {
if t.Remote.URL == "" {
return errors.New("oauth requires remote url to be set")
}
if t.Remote.OAuth.ClientID == "" {
return errors.New("oauth requires clientId to be set")
}
if t.Remote.OAuth.CallbackPort != 0 && (t.Remote.OAuth.CallbackPort < 1 || t.Remote.OAuth.CallbackPort > 65535) {
return errors.New("oauth callbackPort must be between 1 and 65535")
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

i don't think we should go all the way to port 1, that'd required root permissions

}
}
case "a2a":
if t.URL == "" {
return errors.New("a2a toolset requires a url to be set")
Expand Down
1 change: 1 addition & 0 deletions pkg/runtime/remote_runtime.go
Original file line number Diff line number Diff line change
Expand Up @@ -362,6 +362,7 @@ func (r *RemoteRuntime) handleOAuthElicitation(ctx context.Context, req *Elicita
state,
oauth2.S256ChallengeFromVerifier(verifier),
serverURL,
nil,
)

slog.Debug("Authorization URL built", "url", authURL)
Expand Down
4 changes: 2 additions & 2 deletions pkg/teamloader/registry.go
Original file line number Diff line number Diff line change
Expand Up @@ -251,7 +251,7 @@ func createMCPTool(ctx context.Context, toolset latest.Toolset, _ string, runCon

// TODO(dga): until the MCP Gateway supports oauth with docker agent, we fetch the remote url and directly connect to it.
if serverSpec.Type == "remote" {
return mcp.NewRemoteToolset(toolset.Name, serverSpec.Remote.URL, serverSpec.Remote.TransportType, nil), nil
return mcp.NewRemoteToolset(toolset.Name, serverSpec.Remote.URL, serverSpec.Remote.TransportType, nil, nil), nil
}

env, err := environment.ExpandAll(ctx, environment.ToValues(toolset.Env), envProvider)
Expand Down Expand Up @@ -292,7 +292,7 @@ func createMCPTool(ctx context.Context, toolset latest.Toolset, _ string, runCon
headers := expander.ExpandMap(ctx, toolset.Remote.Headers)
url := expander.Expand(ctx, toolset.Remote.URL, nil)

return mcp.NewRemoteToolset(toolset.Name, url, toolset.Remote.TransportType, headers), nil
return mcp.NewRemoteToolset(toolset.Name, url, toolset.Remote.TransportType, headers, toolset.Remote.OAuth), nil

default:
return nil, errors.New("mcp toolset requires either ref, command, or remote configuration")
Expand Down
6 changes: 3 additions & 3 deletions pkg/tools/mcp/describe_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,21 +24,21 @@ func TestToolsetDescribe_StdioNoArgs(t *testing.T) {
func TestToolsetDescribe_RemoteHostAndPort(t *testing.T) {
t.Parallel()

ts := NewRemoteToolset("", "http://example.com:8443/mcp/v1?key=secret", "sse", nil)
ts := NewRemoteToolset("", "http://example.com:8443/mcp/v1?key=secret", "sse", nil, nil)
assert.Check(t, is.Equal(ts.Describe(), "mcp(remote host=example.com:8443 transport=sse)"))
}

func TestToolsetDescribe_RemoteDefaultPort(t *testing.T) {
t.Parallel()

ts := NewRemoteToolset("", "https://api.example.com/mcp", "streamable", nil)
ts := NewRemoteToolset("", "https://api.example.com/mcp", "streamable", nil, nil)
assert.Check(t, is.Equal(ts.Describe(), "mcp(remote host=api.example.com transport=streamable)"))
}

func TestToolsetDescribe_RemoteInvalidURL(t *testing.T) {
t.Parallel()

ts := NewRemoteToolset("", "://bad-url", "sse", nil)
ts := NewRemoteToolset("", "://bad-url", "sse", nil, nil)
assert.Check(t, is.Equal(ts.Describe(), "mcp(remote transport=sse)"))
}

Expand Down
5 changes: 3 additions & 2 deletions pkg/tools/mcp/mcp.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import (

"github.com/modelcontextprotocol/go-sdk/mcp"

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

Expand Down Expand Up @@ -105,13 +106,13 @@ func NewToolsetCommand(name, command string, args, env []string, cwd string) *To
}

// NewRemoteToolset creates a new MCP toolset from a remote MCP Server.
func NewRemoteToolset(name, urlString, transport string, headers map[string]string) *Toolset {
func NewRemoteToolset(name, urlString, transport string, headers map[string]string, oauthConfig *latest.RemoteOAuthConfig) *Toolset {
slog.Debug("Creating Remote MCP toolset", "url", urlString, "transport", transport, "headers", headers)

desc := buildRemoteDescription(urlString, transport)
return &Toolset{
name: name,
mcpClient: newRemoteClient(urlString, transport, headers, NewKeyringTokenStore()),
mcpClient: newRemoteClient(urlString, transport, headers, NewKeyringTokenStore(), oauthConfig),
logID: urlString,
description: desc,
}
Expand Down
37 changes: 26 additions & 11 deletions pkg/tools/mcp/oauth.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import (
mcpsdk "github.com/modelcontextprotocol/go-sdk/mcp"
"golang.org/x/oauth2"

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

Expand Down Expand Up @@ -135,7 +136,8 @@ func validateAndFillDefaults(metadata *AuthorizationServerMetadata, authServerUR

metadata.AuthorizationEndpoint = cmp.Or(metadata.AuthorizationEndpoint, authServerURL+"/authorize")
metadata.TokenEndpoint = cmp.Or(metadata.TokenEndpoint, authServerURL+"/token")
metadata.RegistrationEndpoint = cmp.Or(metadata.RegistrationEndpoint, authServerURL+"/register")
// Do NOT fabricate a registration_endpoint — if the server doesn't
// advertise one, dynamic client registration is not supported.

return metadata
}
Expand All @@ -146,7 +148,6 @@ func createDefaultMetadata(authServerURL string) *AuthorizationServerMetadata {
Issuer: authServerURL,
AuthorizationEndpoint: authServerURL + "/authorize",
TokenEndpoint: authServerURL + "/token",
RegistrationEndpoint: authServerURL + "/register",
ResponseTypesSupported: []string{"code"},
ResponseModesSupported: []string{"query", "fragment"},
GrantTypesSupported: []string{"authorization_code"},
Expand All @@ -168,10 +169,11 @@ func resourceMetadataFromWWWAuth(wwwAuth string) 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
client *remoteMCPClient
tokenStore OAuthTokenStore
baseURL string
managed bool
oauthConfig *latest.RemoteOAuthConfig

// mu protects refreshFailedAt from concurrent access.
mu sync.Mutex
Expand Down Expand Up @@ -331,7 +333,11 @@ func (t *oauthTransport) handleManagedOAuthFlow(ctx context.Context, authServer,
}

slog.Debug("Creating OAuth callback server")
callbackServer, err := NewCallbackServer()
var callbackPort int
if t.oauthConfig != nil {
callbackPort = t.oauthConfig.CallbackPort
}
callbackServer, err := NewCallbackServerOnPort(callbackPort)
if err != nil {
return fmt.Errorf("failed to create callback server: %w", err)
}
Expand All @@ -352,18 +358,26 @@ func (t *oauthTransport) handleManagedOAuthFlow(ctx context.Context, authServer,

var clientID string
var clientSecret string

if authServerMetadata.RegistrationEndpoint != "" {
var scopes []string

switch {
case t.oauthConfig != nil && t.oauthConfig.ClientID != "":
// Use explicit credentials from config
slog.Debug("Using explicit OAuth credentials from config")
clientID = t.oauthConfig.ClientID
clientSecret = t.oauthConfig.ClientSecret
scopes = t.oauthConfig.Scopes
case authServerMetadata.RegistrationEndpoint != "":
slog.Debug("Attempting dynamic client registration")
clientID, clientSecret, err = RegisterClient(ctx, authServerMetadata, redirectURI, nil)
if err != nil {
slog.Debug("Dynamic registration failed", "error", err)
// TODO(rumpl): fall back to requesting client ID from user
return err
}
} else {
default:
// TODO(rumpl): fall back to requesting client ID from user
return errors.New("authorization server does not support dynamic client registration")
return errors.New("authorization server does not support dynamic client registration and no explicit OAuth credentials configured")
}

state, err := GenerateState()
Expand All @@ -381,6 +395,7 @@ func (t *oauthTransport) handleManagedOAuthFlow(ctx context.Context, authServer,
state,
oauth2.S256ChallengeFromVerifier(verifier),
t.baseURL,
scopes,
)

result, err := t.client.requestElicitation(ctx, &mcpsdk.ElicitParams{
Expand Down
5 changes: 4 additions & 1 deletion pkg/tools/mcp/oauth_helpers.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ func GenerateState() (string, error) {
}

// BuildAuthorizationURL builds the OAuth authorization URL with PKCE
func BuildAuthorizationURL(authEndpoint, clientID, redirectURI, state, codeChallenge, resourceURL string) string {
func BuildAuthorizationURL(authEndpoint, clientID, redirectURI, state, codeChallenge, resourceURL string, scopes []string) string {
params := url.Values{}
params.Set("response_type", "code")
params.Set("client_id", clientID)
Expand All @@ -37,6 +37,9 @@ func BuildAuthorizationURL(authEndpoint, clientID, redirectURI, state, codeChall
params.Set("code_challenge", codeChallenge)
params.Set("code_challenge_method", "S256")
params.Set("resource", resourceURL) // RFC 8707: Resource Indicators
if len(scopes) > 0 {
params.Set("scope", strings.Join(scopes, " "))
}
return authEndpoint + "?" + params.Encode()
}

Expand Down
1 change: 1 addition & 0 deletions pkg/tools/mcp/oauth_login.go
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,7 @@ func PerformOAuthLogin(ctx context.Context, serverURL string) error {
state,
oauth2.S256ChallengeFromVerifier(verifier),
serverURL,
nil,
)

// Open the browser and wait for the callback.
Expand Down
11 changes: 8 additions & 3 deletions pkg/tools/mcp/oauth_server.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,10 +27,15 @@ type CallbackServer struct {
expectedState string
}

// NewCallbackServer creates a new OAuth callback server
// NewCallbackServer creates a new OAuth callback server on a random available port
func NewCallbackServer() (*CallbackServer, error) {
// Find an available port
listener, err := net.Listen("tcp", "127.0.0.1:0")
return NewCallbackServerOnPort(0)
}

// NewCallbackServerOnPort creates a new OAuth callback server on a specific port.
// Use port 0 to let the OS pick a random available port.
func NewCallbackServerOnPort(port int) (*CallbackServer, error) {
listener, err := net.Listen("tcp", fmt.Sprintf("127.0.0.1:%d", port))
if err != nil {
return nil, fmt.Errorf("failed to find available port: %w", err)
}
Expand Down
4 changes: 2 additions & 2 deletions pkg/tools/mcp/reconnect_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -121,7 +121,7 @@ func TestRemoteReconnectAfterServerRestart(t *testing.T) {
// --- Step 1–2: Start first server, connect toolset ---
shutdown1 := startServer(t)

ts := NewRemoteToolset("test", fmt.Sprintf("http://%s/mcp", addr), "streamable-http", nil)
ts := NewRemoteToolset("test", fmt.Sprintf("http://%s/mcp", addr), "streamable-http", nil, nil)
require.NoError(t, ts.Start(t.Context()))

toolList, err := ts.Tools(t.Context())
Expand Down Expand Up @@ -184,7 +184,7 @@ func TestRemoteReconnectRefreshesTools(t *testing.T) {
// --- Start server v1 with tools "alpha" + "shared" ---
shutdown1 := startMCPServer(t, addr, alphaTool, sharedTool)

ts := NewRemoteToolset("ns", fmt.Sprintf("http://%s/mcp", addr), "streamable-http", nil)
ts := NewRemoteToolset("ns", fmt.Sprintf("http://%s/mcp", addr), "streamable-http", nil, nil)

// Track toolsChangedHandler invocations.
toolsChangedCh := make(chan struct{}, 1)
Expand Down
Loading
Loading