Skip to content

Commit 093a412

Browse files
committed
feat: explicit OAuth configuration for remote MCP servers (#2248)
Implement support for pre-registered OAuth clients with remote MCP servers. This allows using servers that do not support Dynamic Client Registration (RFC 7591), such as Slack and GitHub MCP. Key features: - New RemoteOAuthConfig struct for client ID, secret, callback port, and scopes - Explicit OAuth priority over dynamic registration - Configurable callback port for OAuth flow - Custom scope support for authorization requests - Updated agent-schema.json for validation - Comprehensive tests and example configuration Fixes root cause of #416 Addresses #2248
1 parent d03b58e commit 093a412

File tree

11 files changed

+446
-19
lines changed

11 files changed

+446
-19
lines changed

agent-schema.json

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1205,13 +1205,48 @@
12051205
"additionalProperties": {
12061206
"type": "string"
12071207
}
1208+
},
1209+
"oauth": {
1210+
"$ref": "#/definitions/RemoteOAuthConfig",
1211+
"description": "Explicit OAuth configuration for remote MCP servers that do not support Dynamic Client Registration"
12081212
}
12091213
},
12101214
"required": [
12111215
"url"
12121216
],
12131217
"additionalProperties": false
12141218
},
1219+
"RemoteOAuthConfig": {
1220+
"type": "object",
1221+
"description": "Explicit OAuth configuration for remote MCP servers. Allows using pre-registered OAuth clients with servers that do not support Dynamic Client Registration (RFC 7591), such as the Slack MCP server.",
1222+
"properties": {
1223+
"client_id": {
1224+
"type": "string",
1225+
"description": "OAuth client ID (required for explicit OAuth)",
1226+
"examples": ["3660753192626.8903469228982"]
1227+
},
1228+
"client_secret": {
1229+
"type": "string",
1230+
"description": "OAuth client secret (optional, for confidential clients)"
1231+
},
1232+
"callback_port": {
1233+
"type": "integer",
1234+
"description": "Fixed port for the OAuth callback server (optional). When not specified, a random available port is used.",
1235+
"examples": [3118]
1236+
},
1237+
"scopes": {
1238+
"type": "array",
1239+
"description": "List of OAuth scopes to request (optional). When not specified, default scopes from the server are used.",
1240+
"items": {
1241+
"type": "string"
1242+
},
1243+
"examples": [
1244+
["search:read.public", "chat:write"]
1245+
]
1246+
}
1247+
},
1248+
"additionalProperties": false
1249+
},
12151250
"ScriptShellToolConfig": {
12161251
"type": "object",
12171252
"description": "Configuration for custom shell tool",

examples/slack-oauth-example.yaml

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
# Example Docker Agent configuration with explicit OAuth for remote MCP servers
2+
# This demonstrates how to configure OAuth for servers that do not support
3+
# Dynamic Client Registration (RFC 7591), such as the Slack MCP server.
4+
#
5+
# Related issue: #2248
6+
7+
metadata:
8+
author: "Your Name"
9+
description: "Agent with explicit OAuth configuration for Slack MCP"
10+
version: "1.0.0"
11+
12+
models:
13+
default:
14+
provider: openai
15+
model: gpt-4o
16+
17+
agents:
18+
slack-agent:
19+
model: default
20+
description: "An agent with Slack MCP integration using explicit OAuth"
21+
instruction: |
22+
You are a helpful assistant with access to Slack.
23+
You can read messages, search channels, and send messages.
24+
toolsets:
25+
# Slack MCP server with explicit OAuth configuration
26+
# This is required because Slack does not support Dynamic Client Registration
27+
- type: mcp
28+
remote:
29+
url: https://mcp.slack.com/mcp
30+
transport_type: streamable
31+
oauth:
32+
# Client ID from your Slack app registration
33+
# Get this from https://api.slack.com/apps
34+
client_id: "3660753192626.8903469228982"
35+
36+
# Client secret (optional, for confidential clients)
37+
# Leave empty for public clients
38+
client_secret: ""
39+
40+
# Fixed callback port (optional)
41+
# Some OAuth servers require a specific redirect URI port
42+
# Default: random available port
43+
callback_port: 3118
44+
45+
# Scopes to request (optional)
46+
# If not specified, server defaults are used
47+
scopes:
48+
- search:read.public
49+
- chat:write
50+
- channels:read
51+
- users:read
52+
53+
# Alternative: GitHub MCP Server with explicit OAuth
54+
# Uncomment and configure for GitHub integration
55+
#
56+
# agents:
57+
# github-agent:
58+
# model: default
59+
# description: "An agent with GitHub MCP integration"
60+
# instruction: |
61+
# You are a helpful assistant with access to GitHub.
62+
# toolsets:
63+
# - type: mcp
64+
# remote:
65+
# url: https://api.githubcopilot.com/mcp
66+
# transport_type: streamable
67+
# oauth:
68+
# client_id: "your-github-client-id"
69+
# client_secret: "your-github-client-secret"
70+
# scopes:
71+
# - read:user
72+
# - repo
73+
74+
# How to use:
75+
# 1. Register your OAuth application with the MCP server provider
76+
# - For Slack: https://api.slack.com/apps
77+
# - For GitHub: https://github.com/settings/applications/new
78+
#
79+
# 2. Get your Client ID and Client Secret from the provider
80+
#
81+
# 3. Configure the OAuth settings in your agent YAML as shown above
82+
#
83+
# 4. Run the agent:
84+
# docker-agent run ./slack-oauth-example.yaml
85+
#
86+
# 5. When the agent needs to access the MCP server, it will:
87+
# - Use your configured Client ID for the OAuth flow
88+
# - Open your browser for authorization
89+
# - Exchange the authorization code for tokens
90+
# - Store tokens for subsequent requests
91+
#
92+
# Notes:
93+
# - OAuth tokens are stored in memory for the session
94+
# - The callback server runs on localhost at the configured port (or random)
95+
# - User authorization is required for the initial OAuth flow
96+
# - Subsequent requests use the stored access token automatically

pkg/config/latest/types.go

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -624,9 +624,26 @@ func (t *Toolset) UnmarshalYAML(unmarshal func(any) error) error {
624624
}
625625

626626
type Remote struct {
627-
URL string `json:"url"`
628-
TransportType string `json:"transport_type,omitempty"`
629-
Headers map[string]string `json:"headers,omitempty"`
627+
URL string `json:"url"`
628+
TransportType string `json:"transport_type,omitempty"`
629+
Headers map[string]string `json:"headers,omitempty"`
630+
OAuth *RemoteOAuthConfig `json:"oauth,omitempty"`
631+
}
632+
633+
// RemoteOAuthConfig represents explicit OAuth configuration for remote MCP servers.
634+
// This allows using pre-registered OAuth clients with servers that do not support
635+
// Dynamic Client Registration (RFC 7591), such as the Slack MCP server.
636+
type RemoteOAuthConfig struct {
637+
// ClientID is the OAuth client ID (required for explicit OAuth)
638+
ClientID string `json:"client_id,omitempty"`
639+
// ClientSecret is the OAuth client secret (optional, for confidential clients)
640+
ClientSecret string `json:"client_secret,omitempty"`
641+
// CallbackPort is the fixed port for the OAuth callback server (optional)
642+
// When not specified, a random available port is used
643+
CallbackPort int `json:"callback_port,omitempty"`
644+
// Scopes is the list of OAuth scopes to request (optional)
645+
// When not specified, default scopes from the server are used
646+
Scopes []string `json:"scopes,omitempty"`
630647
}
631648

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

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, // scopes - use server defaults
365366
)
366367

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

pkg/teamloader/registry.go

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -250,6 +250,16 @@ func createMCPTool(ctx context.Context, toolset latest.Toolset, _ string, runCon
250250

251251
// TODO(dga): until the MCP Gateway supports oauth with docker agent, we fetch the remote url and directly connect to it.
252252
if serverSpec.Type == "remote" {
253+
// Check if explicit OAuth config is provided in the toolset
254+
if toolset.Remote.OAuth != nil {
255+
oauthConfig := &mcp.RemoteOAuthConfig{
256+
ClientID: toolset.Remote.OAuth.ClientID,
257+
ClientSecret: toolset.Remote.OAuth.ClientSecret,
258+
CallbackPort: toolset.Remote.OAuth.CallbackPort,
259+
Scopes: toolset.Remote.OAuth.Scopes,
260+
}
261+
return mcp.NewRemoteToolsetWithOAuth(toolset.Name, serverSpec.Remote.URL, serverSpec.Remote.TransportType, nil, oauthConfig), nil
262+
}
253263
return mcp.NewRemoteToolset(toolset.Name, serverSpec.Remote.URL, serverSpec.Remote.TransportType, nil), nil
254264
}
255265

@@ -291,6 +301,17 @@ func createMCPTool(ctx context.Context, toolset latest.Toolset, _ string, runCon
291301
headers := expander.ExpandMap(ctx, toolset.Remote.Headers)
292302
url := expander.Expand(ctx, toolset.Remote.URL, nil)
293303

304+
// Use explicit OAuth config if provided
305+
if toolset.Remote.OAuth != nil {
306+
oauthConfig := &mcp.RemoteOAuthConfig{
307+
ClientID: toolset.Remote.OAuth.ClientID,
308+
ClientSecret: toolset.Remote.OAuth.ClientSecret,
309+
CallbackPort: toolset.Remote.OAuth.CallbackPort,
310+
Scopes: toolset.Remote.OAuth.Scopes,
311+
}
312+
return mcp.NewRemoteToolsetWithOAuth(toolset.Name, url, toolset.Remote.TransportType, headers, oauthConfig), nil
313+
}
314+
294315
return mcp.NewRemoteToolset(toolset.Name, url, toolset.Remote.TransportType, headers), nil
295316

296317
default:

pkg/tools/mcp/mcp.go

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,22 @@ import (
2020
"github.com/docker/docker-agent/pkg/tools"
2121
)
2222

23+
// RemoteOAuthConfig represents explicit OAuth configuration for remote MCP servers.
24+
// This allows using pre-registered OAuth clients with servers that do not support
25+
// Dynamic Client Registration (RFC 7591), such as the Slack MCP server.
26+
type RemoteOAuthConfig struct {
27+
// ClientID is the OAuth client ID (required for explicit OAuth)
28+
ClientID string
29+
// ClientSecret is the OAuth client secret (optional, for confidential clients)
30+
ClientSecret string
31+
// CallbackPort is the fixed port for the OAuth callback server (optional)
32+
// When not specified, a random available port is used
33+
CallbackPort int
34+
// Scopes is the list of OAuth scopes to request (optional)
35+
// When not specified, default scopes from the server are used
36+
Scopes []string
37+
}
38+
2339
type mcpClient interface {
2440
Initialize(ctx context.Context, request *mcp.InitializeRequest) (*mcp.InitializeResult, error)
2541
ListTools(ctx context.Context, request *mcp.ListToolsParams) iter.Seq2[*mcp.Tool, error]
@@ -107,6 +123,20 @@ func NewRemoteToolset(name, urlString, transport string, headers map[string]stri
107123
}
108124
}
109125

126+
// NewRemoteToolsetWithOAuth creates a new MCP toolset from a remote MCP Server with explicit OAuth configuration.
127+
func NewRemoteToolsetWithOAuth(name, urlString, transport string, headers map[string]string, oauthConfig *RemoteOAuthConfig) *Toolset {
128+
slog.Debug("Creating Remote MCP toolset with OAuth", "url", urlString, "transport", transport, "headers", headers)
129+
130+
desc := buildRemoteDescription(urlString, transport)
131+
client := newRemoteClient(urlString, transport, headers, NewInMemoryTokenStore()).WithOAuthConfig(oauthConfig)
132+
return &Toolset{
133+
name: name,
134+
mcpClient: client,
135+
logID: urlString,
136+
description: desc,
137+
}
138+
}
139+
110140
// errServerUnavailable is returned by doStart when the MCP server could not be
111141
// reached but the error is non-fatal (e.g. EOF). The toolset is considered
112142
// "started" so the agent can proceed, but watchConnection must not be spawned

pkg/tools/mcp/oauth.go

Lines changed: 28 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -161,10 +161,11 @@ func resourceMetadataFromWWWAuth(wwwAuth string) string {
161161
type oauthTransport struct {
162162
base http.RoundTripper
163163
// 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
164+
client *remoteMCPClient
165+
tokenStore OAuthTokenStore
166+
baseURL string
167+
managed bool
168+
oauthConfig *RemoteOAuthConfig
168169
}
169170

170171
func (t *oauthTransport) RoundTrip(req *http.Request) (*http.Response, error) {
@@ -265,6 +266,11 @@ func (t *oauthTransport) handleManagedOAuthFlow(ctx context.Context, authServer,
265266
}
266267
}()
267268

269+
// Use explicit callback port if configured
270+
if t.oauthConfig != nil && t.oauthConfig.CallbackPort > 0 {
271+
callbackServer.SetPort(t.oauthConfig.CallbackPort)
272+
}
273+
268274
if err := callbackServer.Start(); err != nil {
269275
return fmt.Errorf("failed to start callback server: %w", err)
270276
}
@@ -275,17 +281,22 @@ func (t *oauthTransport) handleManagedOAuthFlow(ctx context.Context, authServer,
275281
var clientID string
276282
var clientSecret string
277283

278-
if authServerMetadata.RegistrationEndpoint != "" {
284+
// Use explicit OAuth credentials if provided, otherwise attempt dynamic registration
285+
if t.oauthConfig != nil && t.oauthConfig.ClientID != "" {
286+
slog.Debug("Using explicit OAuth client ID from configuration")
287+
clientID = t.oauthConfig.ClientID
288+
clientSecret = t.oauthConfig.ClientSecret
289+
} else if authServerMetadata.RegistrationEndpoint != "" {
279290
slog.Debug("Attempting dynamic client registration")
280-
clientID, clientSecret, err = RegisterClient(ctx, authServerMetadata, redirectURI, nil)
291+
clientID, clientSecret, err = RegisterClient(ctx, authServerMetadata, redirectURI, t.oauthConfig.Scopes)
281292
if err != nil {
282293
slog.Debug("Dynamic registration failed", "error", err)
283-
// TODO(rumpl): fall back to requesting client ID from user
284-
return err
294+
// If explicit client ID was not provided and registration failed, return error
295+
return fmt.Errorf("dynamic client registration failed and no explicit client ID provided: %w", err)
285296
}
286297
} else {
287-
// TODO(rumpl): fall back to requesting client ID from user
288-
return errors.New("authorization server does not support dynamic client registration")
298+
// No dynamic registration support and no explicit credentials
299+
return errors.New("authorization server does not support dynamic client registration and no explicit OAuth credentials provided")
289300
}
290301

291302
state, err := GenerateState()
@@ -296,13 +307,20 @@ func (t *oauthTransport) handleManagedOAuthFlow(ctx context.Context, authServer,
296307
callbackServer.SetExpectedState(state)
297308
verifier := GeneratePKCEVerifier()
298309

310+
// Use explicit scopes if provided, otherwise use server defaults
311+
scopes := authServerMetadata.ScopesSupported
312+
if t.oauthConfig != nil && len(t.oauthConfig.Scopes) > 0 {
313+
scopes = t.oauthConfig.Scopes
314+
}
315+
299316
authURL := BuildAuthorizationURL(
300317
authServerMetadata.AuthorizationEndpoint,
301318
clientID,
302319
redirectURI,
303320
state,
304321
oauth2.S256ChallengeFromVerifier(verifier),
305322
t.baseURL,
323+
scopes,
306324
)
307325

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

0 commit comments

Comments
 (0)