Skip to content

Commit 44fbeb6

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 7c6204b commit 44fbeb6

File tree

15 files changed

+146
-38
lines changed

15 files changed

+146
-38
lines changed

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

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

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

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

299299
default:
300300
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
@@ -16,6 +16,7 @@ import (
1616
mcpsdk "github.com/modelcontextprotocol/go-sdk/mcp"
1717
"golang.org/x/oauth2"
1818

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

@@ -128,7 +129,8 @@ func validateAndFillDefaults(metadata *AuthorizationServerMetadata, authServerUR
128129

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

133135
return metadata
134136
}
@@ -139,7 +141,6 @@ func createDefaultMetadata(authServerURL string) *AuthorizationServerMetadata {
139141
Issuer: authServerURL,
140142
AuthorizationEndpoint: authServerURL + "/authorize",
141143
TokenEndpoint: authServerURL + "/token",
142-
RegistrationEndpoint: authServerURL + "/register",
143144
ResponseTypesSupported: []string{"code"},
144145
ResponseModesSupported: []string{"query", "fragment"},
145146
GrantTypesSupported: []string{"authorization_code"},
@@ -161,10 +162,11 @@ func resourceMetadataFromWWWAuth(wwwAuth string) string {
161162
type oauthTransport struct {
162163
base http.RoundTripper
163164
// TODO(rumpl): remove client reference, we need to find a better way to send elicitation requests
164-
client *remoteMCPClient
165-
tokenStore OAuthTokenStore
166-
baseURL string
167-
managed bool
165+
client *remoteMCPClient
166+
tokenStore OAuthTokenStore
167+
baseURL string
168+
managed bool
169+
oauthConfig *latest.RemoteOAuthConfig
168170
}
169171

170172
func (t *oauthTransport) RoundTrip(req *http.Request) (*http.Response, error) {
@@ -294,7 +296,11 @@ func (t *oauthTransport) handleManagedOAuthFlow(ctx context.Context, authServer,
294296
}
295297

296298
slog.Debug("Creating OAuth callback server")
297-
callbackServer, err := NewCallbackServer()
299+
var callbackPort int
300+
if t.oauthConfig != nil {
301+
callbackPort = t.oauthConfig.CallbackPort
302+
}
303+
callbackServer, err := NewCallbackServerOnPort(callbackPort)
298304
if err != nil {
299305
return fmt.Errorf("failed to create callback server: %w", err)
300306
}
@@ -315,18 +321,26 @@ func (t *oauthTransport) handleManagedOAuthFlow(ctx context.Context, authServer,
315321

316322
var clientID string
317323
var clientSecret string
318-
319-
if authServerMetadata.RegistrationEndpoint != "" {
324+
var scopes []string
325+
326+
switch {
327+
case t.oauthConfig != nil && t.oauthConfig.ClientID != "":
328+
// Use explicit credentials from config
329+
slog.Debug("Using explicit OAuth credentials from config")
330+
clientID = t.oauthConfig.ClientID
331+
clientSecret = t.oauthConfig.ClientSecret
332+
scopes = t.oauthConfig.Scopes
333+
case authServerMetadata.RegistrationEndpoint != "":
320334
slog.Debug("Attempting dynamic client registration")
321335
clientID, clientSecret, err = RegisterClient(ctx, authServerMetadata, redirectURI, nil)
322336
if err != nil {
323337
slog.Debug("Dynamic registration failed", "error", err)
324338
// TODO(rumpl): fall back to requesting client ID from user
325339
return err
326340
}
327-
} else {
341+
default:
328342
// TODO(rumpl): fall back to requesting client ID from user
329-
return errors.New("authorization server does not support dynamic client registration")
343+
return errors.New("authorization server does not support dynamic client registration and no explicit OAuth credentials configured")
330344
}
331345

332346
state, err := GenerateState()
@@ -344,6 +358,7 @@ func (t *oauthTransport) handleManagedOAuthFlow(ctx context.Context, authServer,
344358
state,
345359
oauth2.S256ChallengeFromVerifier(verifier),
346360
t.baseURL,
361+
scopes,
347362
)
348363

349364
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)