Skip to content

Commit 3f51652

Browse files
authored
Merge pull request #2394 from dgageot/board/docker-agent-pr-fix-for-referenced-issue-05746a9e
Support explicit OAuth credentials for remote MCP servers
2 parents 4d1d10f + 0c46839 commit 3f51652

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
@@ -383,6 +383,7 @@ func (r *RemoteRuntime) handleOAuthElicitation(ctx context.Context, req *Elicita
383383
state,
384384
oauth2.S256ChallengeFromVerifier(verifier),
385385
serverURL,
386+
nil,
386387
)
387388

388389
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
@@ -252,7 +252,7 @@ func createMCPTool(ctx context.Context, toolset latest.Toolset, _ string, runCon
252252

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

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

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

303303
default:
304304
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
@@ -20,6 +20,7 @@ import (
2020

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

23+
"github.com/docker/docker-agent/pkg/config/latest"
2324
"github.com/docker/docker-agent/pkg/tools"
2425
)
2526

@@ -107,13 +108,13 @@ func NewToolsetCommand(name, command string, args, env []string, cwd string) *To
107108
}
108109

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

113114
desc := buildRemoteDescription(urlString, transport)
114115
return &Toolset{
115116
name: name,
116-
mcpClient: newRemoteClient(urlString, transport, headers, NewKeyringTokenStore()),
117+
mcpClient: newRemoteClient(urlString, transport, headers, NewKeyringTokenStore(), oauthConfig),
117118
logID: urlString,
118119
description: desc,
119120
}

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)