Skip to content

Commit 0c46839

Browse files
committed
Support explicit OAuth credentials for remote MCP servers
Add support for configuring explicit OAuth client credentials (clientId, clientSecret, callbackPort, scopes) on remote MCP server toolsets. This fixes connections to MCP servers that do not support Dynamic Client Registration (RFC 7591), such as Slack and GitHub. Key changes: - Add RemoteOAuthConfig to config types with clientId, clientSecret, callbackPort, and scopes fields - Stop fabricating registration_endpoint when the server doesn't advertise one in metadata discovery - Use explicit credentials in the managed OAuth flow when configured, falling back to dynamic registration when available - Support fixed callback port for OAuth redirect URI - Add scopes parameter to BuildAuthorizationURL - Validate callbackPort range (1-65535) and oauth on non-mcp types - Update agent-schema.json with RemoteOAuthConfig definition - Add example config (examples/remote_mcp_oauth.yaml) Fixes #2248 Co-Authored-By: nicholasgasior <nicholasgasior@users.noreply.github.com> Co-Authored-By: rumpl <rumpl@users.noreply.github.com> Assisted-By: docker-agent
1 parent 26e5ed6 commit 0c46839

15 files changed

Lines changed: 146 additions & 38 deletions

agent-schema.json

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1253,6 +1253,10 @@
12531253
"additionalProperties": {
12541254
"type": "string"
12551255
}
1256+
},
1257+
"oauth": {
1258+
"$ref": "#/definitions/RemoteOAuthConfig",
1259+
"description": "Explicit OAuth credentials for servers that do not support Dynamic Client Registration"
12561260
}
12571261
},
12581262
"required": [
@@ -1686,6 +1690,36 @@
16861690
"strategies"
16871691
],
16881692
"additionalProperties": false
1693+
},
1694+
"RemoteOAuthConfig": {
1695+
"type": "object",
1696+
"description": "OAuth configuration for remote MCP servers that do not support Dynamic Client Registration (RFC 7591)",
1697+
"properties": {
1698+
"clientId": {
1699+
"type": "string",
1700+
"description": "OAuth client ID"
1701+
},
1702+
"clientSecret": {
1703+
"type": "string",
1704+
"description": "OAuth client secret"
1705+
},
1706+
"callbackPort": {
1707+
"type": "integer",
1708+
"description": "Fixed port for the OAuth callback server (default: random available port)",
1709+
"minimum": 1,
1710+
"maximum": 65535
1711+
},
1712+
"scopes": {
1713+
"type": "array",
1714+
"description": "OAuth scopes to request",
1715+
"items": {
1716+
"type": "string"
1717+
}
1718+
}
1719+
},
1720+
"required": [
1721+
"clientId"
1722+
]
16891723
}
16901724
}
16911725
}

examples/remote_mcp_oauth.yaml

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
#!/usr/bin/env docker agent run
2+
3+
# Example: Remote MCP server with explicit OAuth credentials.
4+
# Use this when connecting to MCP servers that do NOT support
5+
# Dynamic Client Registration (RFC 7591), such as Slack or GitHub.
6+
7+
agents:
8+
root:
9+
model: openai/gpt-4.1-mini
10+
description: Assistant with remote MCP tools using explicit OAuth credentials
11+
instruction: You are a helpful assistant with access to remote tools.
12+
toolsets:
13+
- type: mcp
14+
remote:
15+
url: "https://mcp.example.com/sse"
16+
transport_type: sse
17+
oauth:
18+
clientId: "your-client-id"
19+
clientSecret: "your-client-secret"
20+
callbackPort: 8080
21+
scopes:
22+
- "read"
23+
- "write"

pkg/config/latest/types.go

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -741,9 +741,19 @@ func (t *Toolset) UnmarshalYAML(unmarshal func(any) error) error {
741741
}
742742

743743
type Remote struct {
744-
URL string `json:"url"`
745-
TransportType string `json:"transport_type,omitempty"`
746-
Headers map[string]string `json:"headers,omitempty"`
744+
URL string `json:"url"`
745+
TransportType string `json:"transport_type,omitempty"`
746+
Headers map[string]string `json:"headers,omitempty"`
747+
OAuth *RemoteOAuthConfig `json:"oauth,omitempty"`
748+
}
749+
750+
// RemoteOAuthConfig holds explicit OAuth credentials for remote MCP servers
751+
// that do not support Dynamic Client Registration (RFC 7591).
752+
type RemoteOAuthConfig struct {
753+
ClientID string `json:"clientId"`
754+
ClientSecret string `json:"clientSecret,omitempty"`
755+
CallbackPort int `json:"callbackPort,omitempty"`
756+
Scopes []string `json:"scopes,omitempty"`
747757
}
748758

749759
// DeferConfig represents the deferred loading configuration for a toolset.

pkg/config/latest/validate.go

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -93,7 +93,7 @@ func (t *Toolset) validate() error {
9393
if t.Ref != "" && t.Type != "mcp" && t.Type != "rag" {
9494
return errors.New("ref can only be used with type 'mcp' or 'rag'")
9595
}
96-
if (t.Remote.URL != "" || t.Remote.TransportType != "") && t.Type != "mcp" {
96+
if (t.Remote.URL != "" || t.Remote.TransportType != "" || t.Remote.OAuth != nil) && t.Type != "mcp" {
9797
return errors.New("remote can only be used with type 'mcp'")
9898
}
9999
if (len(t.Remote.Headers) > 0) && (t.Type != "mcp" && t.Type != "a2a") {
@@ -139,6 +139,17 @@ func (t *Toolset) validate() error {
139139
if count > 1 {
140140
return errors.New("either command, remote or ref must be set, but only one of those")
141141
}
142+
if t.Remote.OAuth != nil {
143+
if t.Remote.URL == "" {
144+
return errors.New("oauth requires remote url to be set")
145+
}
146+
if t.Remote.OAuth.ClientID == "" {
147+
return errors.New("oauth requires clientId to be set")
148+
}
149+
if t.Remote.OAuth.CallbackPort != 0 && (t.Remote.OAuth.CallbackPort < 1 || t.Remote.OAuth.CallbackPort > 65535) {
150+
return errors.New("oauth callbackPort must be between 1 and 65535")
151+
}
152+
}
142153
case "a2a":
143154
if t.URL == "" {
144155
return errors.New("a2a toolset requires a url to be set")

pkg/runtime/remote_runtime.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -362,6 +362,7 @@ func (r *RemoteRuntime) handleOAuthElicitation(ctx context.Context, req *Elicita
362362
state,
363363
oauth2.S256ChallengeFromVerifier(verifier),
364364
serverURL,
365+
nil,
365366
)
366367

367368
slog.Debug("Authorization URL built", "url", authURL)

pkg/teamloader/registry.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -251,7 +251,7 @@ func createMCPTool(ctx context.Context, toolset latest.Toolset, _ string, runCon
251251

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

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

295-
return mcp.NewRemoteToolset(toolset.Name, url, toolset.Remote.TransportType, headers), nil
295+
return mcp.NewRemoteToolset(toolset.Name, url, toolset.Remote.TransportType, headers, toolset.Remote.OAuth), nil
296296

297297
default:
298298
return nil, errors.New("mcp toolset requires either ref, command, or remote configuration")

pkg/tools/mcp/describe_test.go

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -24,21 +24,21 @@ func TestToolsetDescribe_StdioNoArgs(t *testing.T) {
2424
func TestToolsetDescribe_RemoteHostAndPort(t *testing.T) {
2525
t.Parallel()
2626

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

3131
func TestToolsetDescribe_RemoteDefaultPort(t *testing.T) {
3232
t.Parallel()
3333

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

3838
func TestToolsetDescribe_RemoteInvalidURL(t *testing.T) {
3939
t.Parallel()
4040

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

pkg/tools/mcp/mcp.go

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import (
1818

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

21+
"github.com/docker/docker-agent/pkg/config/latest"
2122
"github.com/docker/docker-agent/pkg/tools"
2223
)
2324

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

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

111112
desc := buildRemoteDescription(urlString, transport)
112113
return &Toolset{
113114
name: name,
114-
mcpClient: newRemoteClient(urlString, transport, headers, NewKeyringTokenStore()),
115+
mcpClient: newRemoteClient(urlString, transport, headers, NewKeyringTokenStore(), oauthConfig),
115116
logID: urlString,
116117
description: desc,
117118
}

pkg/tools/mcp/oauth.go

Lines changed: 26 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import (
1717
mcpsdk "github.com/modelcontextprotocol/go-sdk/mcp"
1818
"golang.org/x/oauth2"
1919

20+
"github.com/docker/docker-agent/pkg/config/latest"
2021
"github.com/docker/docker-agent/pkg/tools"
2122
)
2223

@@ -135,7 +136,8 @@ func validateAndFillDefaults(metadata *AuthorizationServerMetadata, authServerUR
135136

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

140142
return metadata
141143
}
@@ -146,7 +148,6 @@ func createDefaultMetadata(authServerURL string) *AuthorizationServerMetadata {
146148
Issuer: authServerURL,
147149
AuthorizationEndpoint: authServerURL + "/authorize",
148150
TokenEndpoint: authServerURL + "/token",
149-
RegistrationEndpoint: authServerURL + "/register",
150151
ResponseTypesSupported: []string{"code"},
151152
ResponseModesSupported: []string{"query", "fragment"},
152153
GrantTypesSupported: []string{"authorization_code"},
@@ -168,10 +169,11 @@ func resourceMetadataFromWWWAuth(wwwAuth string) string {
168169
type oauthTransport struct {
169170
base http.RoundTripper
170171
// TODO(rumpl): remove client reference, we need to find a better way to send elicitation requests
171-
client *remoteMCPClient
172-
tokenStore OAuthTokenStore
173-
baseURL string
174-
managed bool
172+
client *remoteMCPClient
173+
tokenStore OAuthTokenStore
174+
baseURL string
175+
managed bool
176+
oauthConfig *latest.RemoteOAuthConfig
175177

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

333335
slog.Debug("Creating OAuth callback server")
334-
callbackServer, err := NewCallbackServer()
336+
var callbackPort int
337+
if t.oauthConfig != nil {
338+
callbackPort = t.oauthConfig.CallbackPort
339+
}
340+
callbackServer, err := NewCallbackServerOnPort(callbackPort)
335341
if err != nil {
336342
return fmt.Errorf("failed to create callback server: %w", err)
337343
}
@@ -352,18 +358,26 @@ func (t *oauthTransport) handleManagedOAuthFlow(ctx context.Context, authServer,
352358

353359
var clientID string
354360
var clientSecret string
355-
356-
if authServerMetadata.RegistrationEndpoint != "" {
361+
var scopes []string
362+
363+
switch {
364+
case t.oauthConfig != nil && t.oauthConfig.ClientID != "":
365+
// Use explicit credentials from config
366+
slog.Debug("Using explicit OAuth credentials from config")
367+
clientID = t.oauthConfig.ClientID
368+
clientSecret = t.oauthConfig.ClientSecret
369+
scopes = t.oauthConfig.Scopes
370+
case authServerMetadata.RegistrationEndpoint != "":
357371
slog.Debug("Attempting dynamic client registration")
358372
clientID, clientSecret, err = RegisterClient(ctx, authServerMetadata, redirectURI, nil)
359373
if err != nil {
360374
slog.Debug("Dynamic registration failed", "error", err)
361375
// TODO(rumpl): fall back to requesting client ID from user
362376
return err
363377
}
364-
} else {
378+
default:
365379
// TODO(rumpl): fall back to requesting client ID from user
366-
return errors.New("authorization server does not support dynamic client registration")
380+
return errors.New("authorization server does not support dynamic client registration and no explicit OAuth credentials configured")
367381
}
368382

369383
state, err := GenerateState()
@@ -381,6 +395,7 @@ func (t *oauthTransport) handleManagedOAuthFlow(ctx context.Context, authServer,
381395
state,
382396
oauth2.S256ChallengeFromVerifier(verifier),
383397
t.baseURL,
398+
scopes,
384399
)
385400

386401
result, err := t.client.requestElicitation(ctx, &mcpsdk.ElicitParams{

pkg/tools/mcp/oauth_helpers.go

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ func GenerateState() (string, error) {
2828
}
2929

3030
// BuildAuthorizationURL builds the OAuth authorization URL with PKCE
31-
func BuildAuthorizationURL(authEndpoint, clientID, redirectURI, state, codeChallenge, resourceURL string) string {
31+
func BuildAuthorizationURL(authEndpoint, clientID, redirectURI, state, codeChallenge, resourceURL string, scopes []string) string {
3232
params := url.Values{}
3333
params.Set("response_type", "code")
3434
params.Set("client_id", clientID)
@@ -37,6 +37,9 @@ func BuildAuthorizationURL(authEndpoint, clientID, redirectURI, state, codeChall
3737
params.Set("code_challenge", codeChallenge)
3838
params.Set("code_challenge_method", "S256")
3939
params.Set("resource", resourceURL) // RFC 8707: Resource Indicators
40+
if len(scopes) > 0 {
41+
params.Set("scope", strings.Join(scopes, " "))
42+
}
4043
return authEndpoint + "?" + params.Encode()
4144
}
4245

0 commit comments

Comments
 (0)