Skip to content

Commit 2b758ad

Browse files
kgprsclaude
andcommitted
Add Dynamic Client Registration (DCR) OAuth support for MCP servers
This commit implements PR #129 changes with adaptations for the latest main branch: * Add mcp-oauth-dcr feature flag support to commands and gateway configuration * Implement OAuth 2.0 Dynamic Client Registration (RFC 7591) for public clients * Add OAuth 2.0 Authorization Server Discovery (RFC 8414) and Protected Resource Metadata (RFC 9728) * Support token event handling for OAuth client invalidation on token refresh * Add secure OAuth credential helper using docker-credential-desktop * Update MCP remote client to automatically add OAuth Bearer tokens * Add DCR client management methods to desktop auth client * Update server enable/disable commands to support DCR feature flag * Add comprehensive WWW-Authenticate header parsing (RFC 6750) * Add InvalidateOAuthClients method to gateway client pool * Include OAuth configuration in catalog server types Key features: - Automatic OAuth server discovery from MCP server 401 responses - Public client registration using PKCE for enhanced security - Secure token storage via system credential store - Automatic token refresh handling with client pool invalidation - Full compliance with OAuth 2.0/2.1 and MCP Authorization specifications All tests pass and build succeeds. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
1 parent 85e79ab commit 2b758ad

File tree

20 files changed

+1235
-18
lines changed

20 files changed

+1235
-18
lines changed

cmd/docker-mcp/commands/feature.go

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -38,14 +38,15 @@ func featureEnableCommand(dockerCli command.Cli) *cobra.Command {
3838
3939
Available features:
4040
oauth-interceptor Enable GitHub OAuth flow interception for automatic authentication
41+
mcp-oauth-dcr Enable Dynamic Client Registration (DCR) for automatic OAuth client setup
4142
dynamic-tools Enable internal MCP management tools (mcp-find, mcp-add, mcp-remove)`,
4243
Args: cobra.ExactArgs(1),
4344
RunE: func(_ *cobra.Command, args []string) error {
4445
featureName := args[0]
4546

4647
// Validate feature name
4748
if !isKnownFeature(featureName) {
48-
return fmt.Errorf("unknown feature: %s\n\nAvailable features:\n oauth-interceptor Enable GitHub OAuth flow interception\n dynamic-tools Enable internal MCP management tools", featureName)
49+
return fmt.Errorf("unknown feature: %s\n\nAvailable features:\n oauth-interceptor Enable GitHub OAuth flow interception\n mcp-oauth-dcr Enable Dynamic Client Registration for automatic OAuth setup\n dynamic-tools Enable internal MCP management tools", featureName)
4950
}
5051

5152
// Enable the feature
@@ -68,6 +69,13 @@ Available features:
6869
fmt.Println("\nThis feature enables automatic GitHub OAuth interception when 401 errors occur.")
6970
fmt.Println("When enabled, the gateway will automatically provide OAuth URLs for authentication.")
7071
fmt.Println("\nNo additional flags are needed - this applies to all gateway runs.")
72+
case "mcp-oauth-dcr":
73+
fmt.Println("\nThis feature enables Dynamic Client Registration (DCR) for MCP servers.")
74+
fmt.Println("When enabled, remote servers with OAuth configuration will automatically:")
75+
fmt.Println(" - Discover OAuth authorization servers")
76+
fmt.Println(" - Register public OAuth clients using PKCE")
77+
fmt.Println(" - Provide seamless OAuth authentication flows")
78+
fmt.Println("\nOnly affects remote servers with OAuth configuration - traditional OAuth flows are unchanged.")
7179
case "dynamic-tools":
7280
fmt.Println("\nThis feature enables dynamic tool discovery and execution capabilities.")
7381
fmt.Println("When enabled, the gateway provides internal tools for managing MCP servers:")
@@ -129,7 +137,7 @@ func featureListCommand(dockerCli command.Cli) *cobra.Command {
129137
fmt.Println()
130138

131139
// Show all known features
132-
knownFeatures := []string{"oauth-interceptor", "dynamic-tools"}
140+
knownFeatures := []string{"oauth-interceptor", "mcp-oauth-dcr", "dynamic-tools"}
133141
for _, feature := range knownFeatures {
134142
status := "disabled"
135143
if isFeatureEnabledFromCli(dockerCli, feature) {
@@ -142,6 +150,8 @@ func featureListCommand(dockerCli command.Cli) *cobra.Command {
142150
switch feature {
143151
case "oauth-interceptor":
144152
fmt.Printf(" %-20s %s\n", "", "Enable GitHub OAuth flow interception for automatic authentication")
153+
case "mcp-oauth-dcr":
154+
fmt.Printf(" %-20s %s\n", "", "Enable Dynamic Client Registration (DCR) for automatic OAuth client setup")
145155
case "dynamic-tools":
146156
fmt.Printf(" %-20s %s\n", "", "Enable internal MCP management tools (mcp-find, mcp-add, mcp-remove)")
147157
}
@@ -205,6 +215,7 @@ func isFeatureEnabledFromConfig(configFile *configfile.ConfigFile, feature strin
205215
func isKnownFeature(feature string) bool {
206216
knownFeatures := []string{
207217
"oauth-interceptor",
218+
"mcp-oauth-dcr",
208219
"dynamic-tools",
209220
}
210221

cmd/docker-mcp/commands/gateway.go

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,9 @@ func gatewayCommand(docker docker.Client, dockerCli command.Cli) *cobra.Command
7171
// Check if OAuth interceptor feature is enabled
7272
options.OAuthInterceptorEnabled = isOAuthInterceptorFeatureEnabled(dockerCli)
7373

74+
// Check if MCP OAuth DCR feature is enabled
75+
options.McpOAuthDcrEnabled = isMcpOAuthDcrFeatureEnabled(dockerCli)
76+
7477
// Check if dynamic tools feature is enabled
7578
options.DynamicTools = isDynamicToolsFeatureEnabled(dockerCli)
7679

@@ -270,6 +273,21 @@ func isOAuthInterceptorFeatureEnabled(dockerCli command.Cli) bool {
270273
return value == "enabled"
271274
}
272275

276+
// isMcpOAuthDcrFeatureEnabled checks if the mcp-oauth-dcr feature is enabled
277+
func isMcpOAuthDcrFeatureEnabled(dockerCli command.Cli) bool {
278+
configFile := dockerCli.ConfigFile()
279+
if configFile == nil || configFile.Features == nil {
280+
return false
281+
}
282+
283+
value, exists := configFile.Features["mcp-oauth-dcr"]
284+
if !exists {
285+
return false
286+
}
287+
288+
return value == "enabled"
289+
}
290+
273291
// isDynamicToolsFeatureEnabled checks if the dynamic-tools feature is enabled
274292
func isDynamicToolsFeatureEnabled(dockerCli command.Cli) bool {
275293
configFile := dockerCli.ConfigFile()

cmd/docker-mcp/commands/root.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,7 @@ func Root(ctx context.Context, cwd string, dockerCli command.Cli) *cobra.Command
7979
cmd.AddCommand(policyCommand())
8080
cmd.AddCommand(registryCommand())
8181
cmd.AddCommand(secretCommand(dockerClient))
82-
cmd.AddCommand(serverCommand(dockerClient))
82+
cmd.AddCommand(serverCommand(dockerClient, dockerCli))
8383
cmd.AddCommand(toolsCommand(dockerClient))
8484
cmd.AddCommand(versionCommand())
8585

cmd/docker-mcp/commands/server.go

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import (
55
"fmt"
66
"strings"
77

8+
"github.com/docker/cli/cli/command"
89
"github.com/spf13/cobra"
910

1011
"github.com/docker/mcp-gateway/cmd/docker-mcp/server"
@@ -13,7 +14,7 @@ import (
1314
"github.com/docker/mcp-gateway/pkg/oci"
1415
)
1516

16-
func serverCommand(docker docker.Client) *cobra.Command {
17+
func serverCommand(docker docker.Client, dockerCli command.Cli) *cobra.Command {
1718
cmd := &cobra.Command{
1819
Use: "server",
1920
Short: "Manage servers",
@@ -55,7 +56,8 @@ func serverCommand(docker docker.Client) *cobra.Command {
5556
Short: "Enable a server or multiple servers",
5657
Args: cobra.MinimumNArgs(1),
5758
RunE: func(cmd *cobra.Command, args []string) error {
58-
return server.Enable(cmd.Context(), docker, args)
59+
mcpOAuthDcrEnabled := isMcpOAuthDcrFeatureEnabled(dockerCli)
60+
return server.Enable(cmd.Context(), docker, args, mcpOAuthDcrEnabled)
5961
},
6062
})
6163

@@ -65,7 +67,8 @@ func serverCommand(docker docker.Client) *cobra.Command {
6567
Short: "Disable a server or multiple servers",
6668
Args: cobra.MinimumNArgs(1),
6769
RunE: func(cmd *cobra.Command, args []string) error {
68-
return server.Disable(cmd.Context(), docker, args)
70+
mcpOAuthDcrEnabled := isMcpOAuthDcrFeatureEnabled(dockerCli)
71+
return server.Disable(cmd.Context(), docker, args, mcpOAuthDcrEnabled)
6972
},
7073
})
7174

cmd/docker-mcp/server/enable.go

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -12,15 +12,15 @@ import (
1212
"github.com/docker/mcp-gateway/pkg/docker"
1313
)
1414

15-
func Disable(ctx context.Context, docker docker.Client, serverNames []string) error {
16-
return update(ctx, docker, nil, serverNames)
15+
func Disable(ctx context.Context, docker docker.Client, serverNames []string, mcpOAuthDcrEnabled bool) error {
16+
return update(ctx, docker, nil, serverNames, mcpOAuthDcrEnabled)
1717
}
1818

19-
func Enable(ctx context.Context, docker docker.Client, serverNames []string) error {
20-
return update(ctx, docker, serverNames, nil)
19+
func Enable(ctx context.Context, docker docker.Client, serverNames []string, mcpOAuthDcrEnabled bool) error {
20+
return update(ctx, docker, serverNames, nil, mcpOAuthDcrEnabled)
2121
}
2222

23-
func update(ctx context.Context, docker docker.Client, add []string, remove []string) error {
23+
func update(ctx context.Context, docker docker.Client, add []string, remove []string, mcpOAuthDcrEnabled bool) error {
2424
// Read registry.yaml that contains which servers are enabled.
2525
registryYAML, err := config.ReadRegistry(ctx, docker)
2626
if err != nil {

cmd/docker-mcp/server/server_test.go

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -68,14 +68,14 @@ func TestList(t *testing.T) {
6868
func TestEnableNotFound(t *testing.T) {
6969
ctx, _, docker := setup(t, withEmptyRegistryYaml(), withEmptyCatalog())
7070

71-
err := Enable(ctx, docker, []string{"duckduckgo"})
71+
err := Enable(ctx, docker, []string{"duckduckgo"}, false)
7272
require.ErrorContains(t, err, "server duckduckgo not found in catalog")
7373
}
7474

7575
func TestEnable(t *testing.T) {
7676
ctx, _, docker := setup(t, withEmptyRegistryYaml(), withCatalog("registry:\n duckduckgo:\n"))
7777

78-
err := Enable(ctx, docker, []string{"duckduckgo"})
78+
err := Enable(ctx, docker, []string{"duckduckgo"}, false)
7979
require.NoError(t, err)
8080

8181
enabled, err := List(ctx, docker)
@@ -86,7 +86,7 @@ func TestEnable(t *testing.T) {
8686
func TestDisable(t *testing.T) {
8787
ctx, _, docker := setup(t, withRegistryYaml("registry:\n duckduckgo:\n ref: \"\"\n git:\n ref: \"\""), withCatalog("registry:\n git:\n duckduckgo:\n"))
8888

89-
err := Disable(ctx, docker, []string{"duckduckgo"})
89+
err := Disable(ctx, docker, []string{"duckduckgo"}, false)
9090
require.NoError(t, err)
9191

9292
enabled, err := List(ctx, docker)
@@ -97,7 +97,7 @@ func TestDisable(t *testing.T) {
9797
func TestDisableUnknown(t *testing.T) {
9898
ctx, _, docker := setup(t, withRegistryYaml("registry:\n duckduckgo:\n ref: \"\""), withCatalog("registry:\n duckduckgo:\n"))
9999

100-
err := Disable(ctx, docker, []string{"unknown"})
100+
err := Disable(ctx, docker, []string{"unknown"}, false)
101101
require.NoError(t, err)
102102

103103
enabled, err := List(ctx, docker)
@@ -108,7 +108,7 @@ func TestDisableUnknown(t *testing.T) {
108108
func TestRemoveOutdatedServerOnEnable(t *testing.T) {
109109
ctx, _, docker := setup(t, withRegistryYaml("registry:\n outdated:\n ref: \"\""), withCatalog("registry:\n git:\n"))
110110

111-
err := Enable(ctx, docker, []string{"git"})
111+
err := Enable(ctx, docker, []string{"git"}, false)
112112
require.NoError(t, err)
113113

114114
enabled, err := List(ctx, docker)
@@ -119,7 +119,7 @@ func TestRemoveOutdatedServerOnEnable(t *testing.T) {
119119
func TestRemoveOutdatedServerOnDisable(t *testing.T) {
120120
ctx, _, docker := setup(t, withRegistryYaml("registry:\n outdated:\n ref: \"\""), withEmptyCatalog())
121121

122-
err := Disable(ctx, docker, []string{"git"})
122+
err := Disable(ctx, docker, []string{"git"}, false)
123123
require.NoError(t, err)
124124

125125
enabled, err := List(ctx, docker)

pkg/catalog/types.go

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,11 +14,13 @@ type topLevel struct {
1414

1515
type Server struct {
1616
Name string `yaml:"name,omitempty" json:"name,omitempty"`
17+
Type string `yaml:"type" json:"type"`
1718
Image string `yaml:"image" json:"image"`
1819
Description string `yaml:"description,omitempty" json:"description,omitempty"`
1920
LongLived bool `yaml:"longLived,omitempty" json:"longLived,omitempty"`
2021
Remote Remote `yaml:"remote,omitempty" json:"remote,omitempty"`
2122
SSEEndpoint string `yaml:"sseEndpoint,omitempty" json:"sseEndpoint,omitempty"` // Deprecated: Use Remote instead
23+
OAuth *OAuth `yaml:"oauth,omitempty" json:"oauth,omitempty"`
2224
Secrets []Secret `yaml:"secrets,omitempty" json:"secrets,omitempty"`
2325
Env []Env `yaml:"env,omitempty" json:"env,omitempty"`
2426
Command []string `yaml:"command,omitempty" json:"command,omitempty"`
@@ -46,6 +48,15 @@ type Remote struct {
4648
Headers map[string]string `yaml:"headers,omitempty" json:"headers,omitempty"`
4749
}
4850

51+
type OAuth struct {
52+
Providers []OAuthProvider `yaml:"providers,omitempty" json:"providers,omitempty"`
53+
Scopes []string `yaml:"scopes,omitempty" json:"scopes,omitempty"`
54+
}
55+
56+
type OAuthProvider struct {
57+
Provider string `yaml:"provider" json:"provider"`
58+
}
59+
4960
// POCI tools
5061

5162
type Items struct {
@@ -126,3 +137,7 @@ type ServerConfig struct {
126137
Config map[string]any
127138
Secrets map[string]string
128139
}
140+
141+
func (s *Server) IsRemoteOAuthServer() bool {
142+
return s.Type == "remote" && s.OAuth != nil && len(s.OAuth.Providers) > 0
143+
}

pkg/desktop/auth.go

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,61 @@ func (c *Tools) PostOAuthApp(ctx context.Context, app, scopes string, disableAut
7272
return result, err
7373
}
7474

75+
// DCR (Dynamic Client Registration) Methods
76+
77+
type RegisterDCRRequest struct {
78+
ClientID string `json:"clientId"`
79+
ProviderName string `json:"providerName"`
80+
ClientName string `json:"clientName,omitempty"`
81+
AuthorizationServer string `json:"authorizationServer,omitempty"`
82+
AuthorizationEndpoint string `json:"authorizationEndpoint,omitempty"`
83+
TokenEndpoint string `json:"tokenEndpoint,omitempty"`
84+
}
85+
86+
type DCRClient struct {
87+
State string `json:"state"`
88+
ServerName string `json:"serverName"`
89+
ProviderName string `json:"providerName"`
90+
ClientID string `json:"clientId"`
91+
ClientName string `json:"clientName,omitempty"`
92+
RegisteredAt string `json:"registeredAt"` // ISO timestamp
93+
AuthorizationServer string `json:"authorizationServer,omitempty"`
94+
AuthorizationEndpoint string `json:"authorizationEndpoint,omitempty"`
95+
TokenEndpoint string `json:"tokenEndpoint,omitempty"`
96+
}
97+
98+
func (c *Tools) RegisterDCRClient(ctx context.Context, app string, req RegisterDCRRequest) error {
99+
AvoidResourceSaverMode(ctx)
100+
101+
var result map[string]string
102+
return c.rawClient.Post(ctx, fmt.Sprintf("/apps/%s/dcr", app), req, &result)
103+
}
104+
105+
// RegisterDCRClientPending registers a provider for lazy DCR setup using state=unregistered
106+
func (c *Tools) RegisterDCRClientPending(ctx context.Context, app string, req RegisterDCRRequest) error {
107+
AvoidResourceSaverMode(ctx)
108+
109+
var result map[string]string
110+
return c.rawClient.Post(ctx, fmt.Sprintf("/apps/%s/dcr?state=unregistered", app), req, &result)
111+
}
112+
113+
func (c *Tools) GetDCRClient(ctx context.Context, app string) (*DCRClient, error) {
114+
AvoidResourceSaverMode(ctx)
115+
116+
var result DCRClient
117+
err := c.rawClient.Get(ctx, fmt.Sprintf("/apps/%s/dcr", app), &result)
118+
if err != nil {
119+
return nil, err
120+
}
121+
return &result, nil
122+
}
123+
124+
func (c *Tools) DeleteDCRClient(ctx context.Context, app string) error {
125+
AvoidResourceSaverMode(ctx)
126+
127+
return c.rawClient.Delete(ctx, fmt.Sprintf("/apps/%s/dcr", app))
128+
}
129+
75130
func addQueryParam[T any](q, name string, value T, required bool) string {
76131
if !required && reflect.DeepEqual(value, reflect.Zero(reflect.TypeOf(value)).Interface()) {
77132
return ""

pkg/desktop/raw_client.go

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import (
44
"bytes"
55
"context"
66
"encoding/json"
7+
"fmt"
78
"io"
89
"net"
910
"net/http"
@@ -56,6 +57,18 @@ func (c *RawClient) Get(ctx context.Context, endpoint string, v any) error {
5657
return err
5758
}
5859

60+
// Check HTTP status code - return error for non-2xx responses
61+
if response.StatusCode < 200 || response.StatusCode >= 300 {
62+
// Try to parse error message from response
63+
var errorMsg struct {
64+
Message string `json:"message"`
65+
}
66+
if json.Unmarshal(buf, &errorMsg) == nil && errorMsg.Message != "" {
67+
return fmt.Errorf("HTTP %d: %s", response.StatusCode, errorMsg.Message)
68+
}
69+
return fmt.Errorf("HTTP %d: %s", response.StatusCode, string(buf))
70+
}
71+
5972
if err := json.Unmarshal(buf, &v); err != nil {
6073
return err
6174
}
@@ -100,6 +113,18 @@ func (c *RawClient) Post(ctx context.Context, endpoint string, v any, result any
100113
return err
101114
}
102115

116+
// Check HTTP status code - return error for non-2xx responses
117+
if response.StatusCode < 200 || response.StatusCode >= 300 {
118+
// Try to parse error message from response
119+
var errorMsg struct {
120+
Message string `json:"message"`
121+
}
122+
if json.Unmarshal(buf, &errorMsg) == nil && errorMsg.Message != "" {
123+
return fmt.Errorf("HTTP %d: %s", response.StatusCode, errorMsg.Message)
124+
}
125+
return fmt.Errorf("HTTP %d: %s", response.StatusCode, string(buf))
126+
}
127+
103128
if err := json.Unmarshal(buf, &result); err != nil {
104129
return err
105130
}

0 commit comments

Comments
 (0)