Skip to content

Commit 196998e

Browse files
saucowclaude
andcommitted
feat: RFC 8414 catalog schema, pre-registered OAuth, CE mode secret injection
Catalog schema: - OAuthServerMetadata (RFC 8414 field naming) + OAuthRegistration (client_id only) - client_secret never in catalogs, user provides via secrets store - HasPreRegisteredOAuth() enables OAuth for non-remote servers OAuth registration: - Read client_secret from Secrets Engine at registration time, pass to Pinata - Read scopes from server_metadata.scopes_supported - Re-register DcrClient with client_secret at authorize time (CLI path) CE mode: - readCEModeOAuthSecrets() for container secret injection without se:// URIs - docker mcp oauth register command for manual client registration Container args: - Skip secrets without env field (OAuth infrastructure secrets) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 5072c7e commit 196998e

File tree

11 files changed

+158
-33
lines changed

11 files changed

+158
-33
lines changed

cmd/docker-mcp/oauth/auth.go

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,15 @@ import (
55
"fmt"
66
"time"
77

8+
"github.com/docker/mcp-gateway/cmd/docker-mcp/secret-management/secret"
89
"github.com/docker/mcp-gateway/pkg/desktop"
10+
"github.com/docker/mcp-gateway/pkg/log"
911
pkgoauth "github.com/docker/mcp-gateway/pkg/oauth"
1012
)
1113

14+
// clientSecretSuffix is the naming convention for OAuth client secrets in the secrets store.
15+
const clientSecretSuffix = ".client_secret"
16+
1217
func Authorize(ctx context.Context, app string, scopes string) error {
1318
// Check if running in CE mode
1419
if pkgoauth.IsCEMode() {
@@ -23,6 +28,25 @@ func Authorize(ctx context.Context, app string, scopes string) error {
2328
func authorizeDesktopMode(ctx context.Context, app string, scopes string) error {
2429
client := desktop.NewAuthClient()
2530

31+
// For pre-registered OAuth clients, re-register the DCR client with the latest
32+
// client_secret from the Secrets Engine. The user may have set the secret after
33+
// the server was added to the profile.
34+
if dcrClient, err := client.GetDCRClient(ctx, app); err == nil && dcrClient.ClientID != "" {
35+
clientSecretKey := secret.GetDefaultSecretKey(app + clientSecretSuffix)
36+
if env, err := secret.GetSecret(ctx, clientSecretKey); err == nil && string(env.Value) != "" {
37+
req := desktop.RegisterDCRRequest{
38+
ProviderName: dcrClient.ProviderName,
39+
ClientID: dcrClient.ClientID,
40+
ClientSecret: string(env.Value),
41+
AuthorizationEndpoint: dcrClient.AuthorizationEndpoint,
42+
TokenEndpoint: dcrClient.TokenEndpoint,
43+
}
44+
if err := client.RegisterDCRClientPending(ctx, app, req); err != nil {
45+
log.Logf("Warning: failed to update DCR client with client_secret: %v", err)
46+
}
47+
}
48+
}
49+
2650
// Start OAuth flow - Docker Desktop handles DCR automatically if needed
2751
authResponse, err := client.PostOAuthApp(ctx, app, scopes, false)
2852
if err != nil {

cmd/docker-mcp/server/enable.go

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -60,8 +60,8 @@ func update(ctx context.Context, docker docker.Client, dockerCli command.Cli, ad
6060
Ref: "",
6161
}
6262

63-
// DCR flag enabled AND type="remote" AND oauth present
64-
if mcpOAuthDcrEnabled && server.HasExplicitOAuthProviders() {
63+
// DCR flag enabled AND (remote OAuth server OR pre-registered OAuth from catalog)
64+
if mcpOAuthDcrEnabled && (server.HasExplicitOAuthProviders() || server.HasPreRegisteredOAuth()) {
6565
// In CE mode, skip lazy setup - DCR happens during oauth authorize
6666
if pkgoauth.IsCEMode() {
6767
fmt.Printf("OAuth server %s enabled. Run 'docker mcp oauth authorize %s' to authenticate\n", serverName, serverName)
@@ -74,7 +74,7 @@ func update(ctx context.Context, docker docker.Client, dockerCli command.Cli, ad
7474
fmt.Printf("OAuth provider configured for %s - use 'docker mcp oauth authorize %s' to authenticate\n", serverName, serverName)
7575
}
7676
}
77-
} else if !mcpOAuthDcrEnabled && server.HasExplicitOAuthProviders() {
77+
} else if !mcpOAuthDcrEnabled && (server.HasExplicitOAuthProviders() || server.HasPreRegisteredOAuth()) {
7878
// Provide guidance when DCR is needed but disabled
7979
fmt.Printf("Server %s requires OAuth authentication but DCR is disabled.\n", serverName)
8080
fmt.Printf(" To enable automatic OAuth setup, run: docker mcp feature enable mcp-oauth-dcr\n")

pkg/catalog/types.go

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,18 @@ func (s *Server) HasExplicitOAuthProviders() bool {
7070
return s.Type == "remote" && s.IsOAuthServer()
7171
}
7272

73+
// HasPreRegisteredOAuth returns true if this server has pre-registered OAuth
74+
// client metadata embedded in the catalog, regardless of server type.
75+
// This supports local container servers that need OAuth tokens (e.g., Google Workspace)
76+
// where the admin pre-registers the OAuth client in the catalog.
77+
func (s *Server) HasPreRegisteredOAuth() bool {
78+
if s.OAuth == nil || len(s.OAuth.Providers) == 0 {
79+
return false
80+
}
81+
p := s.OAuth.Providers[0]
82+
return p.Registration != nil && p.Registration.ClientID != ""
83+
}
84+
7385
type Secret struct {
7486
Name string `yaml:"name" json:"name"`
7587
Env string `yaml:"env" json:"env"`
@@ -95,6 +107,34 @@ type OAuthProvider struct {
95107
Provider string `yaml:"provider" json:"provider"`
96108
Secret string `json:"secret,omitempty" yaml:"secret,omitempty"`
97109
Env string `json:"env,omitempty" yaml:"env,omitempty"`
110+
// ServerMetadata holds OAuth authorization server metadata following RFC 8414 field naming.
111+
// Omit if the server supports /.well-known/oauth-authorization-server discovery.
112+
// Required for local servers or providers that don't publish metadata endpoints.
113+
ServerMetadata *OAuthServerMetadata `json:"server_metadata,omitempty" yaml:"server_metadata,omitempty"`
114+
// Registration holds out-of-band client credentials from manual OAuth app registration.
115+
// Used when the provider does not support Dynamic Client Registration (DCR).
116+
Registration *OAuthRegistration `json:"registration,omitempty" yaml:"registration,omitempty"`
117+
}
118+
119+
// OAuthServerMetadata follows RFC 8414 (OAuth 2.0 Authorization Server Metadata) field naming.
120+
// https://datatracker.ietf.org/doc/html/rfc8414
121+
type OAuthServerMetadata struct {
122+
Issuer string `json:"issuer,omitempty" yaml:"issuer,omitempty"`
123+
AuthorizationEndpoint string `json:"authorization_endpoint" yaml:"authorization_endpoint"`
124+
TokenEndpoint string `json:"token_endpoint" yaml:"token_endpoint"`
125+
ScopesSupported []string `json:"scopes_supported,omitempty" yaml:"scopes_supported,omitempty"`
126+
ResponseTypesSupported []string `json:"response_types_supported,omitempty" yaml:"response_types_supported,omitempty"`
127+
GrantTypesSupported []string `json:"grant_types_supported,omitempty" yaml:"grant_types_supported,omitempty"`
128+
CodeChallengeMethodsSupported []string `json:"code_challenge_methods_supported,omitempty" yaml:"code_challenge_methods_supported,omitempty"`
129+
TokenEndpointAuthMethodsSupported []string `json:"token_endpoint_auth_methods_supported,omitempty" yaml:"token_endpoint_auth_methods_supported,omitempty"`
130+
}
131+
132+
// OAuthRegistration holds the client_id from an out-of-band OAuth app registration.
133+
// The client_secret is NOT included here -- it is provided by the user via the
134+
// Docker secrets store (e.g., docker mcp secret set {server}.client_secret).
135+
// This ensures secrets are never distributed in catalogs.
136+
type OAuthRegistration struct {
137+
ClientID string `json:"client_id" yaml:"client_id"`
98138
}
99139

100140
// POCI tools

pkg/desktop/auth.go

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -86,13 +86,15 @@ func (c *Tools) PostOAuthApp(ctx context.Context, app, scopes string, disableAut
8686
// DCR (Dynamic Client Registration) Methods
8787

8888
type RegisterDCRRequest struct {
89-
ClientID string `json:"clientId"`
90-
ProviderName string `json:"providerName"`
91-
ClientName string `json:"clientName,omitempty"`
92-
AuthorizationServer string `json:"authorizationServer,omitempty"`
93-
AuthorizationEndpoint string `json:"authorizationEndpoint,omitempty"`
94-
TokenEndpoint string `json:"tokenEndpoint,omitempty"`
95-
ResourceURL string `json:"resourceUrl,omitempty"`
89+
ClientID string `json:"clientId"`
90+
ClientSecret string `json:"clientSecret,omitempty"`
91+
ProviderName string `json:"providerName"`
92+
ClientName string `json:"clientName,omitempty"`
93+
AuthorizationServer string `json:"authorizationServer,omitempty"`
94+
AuthorizationEndpoint string `json:"authorizationEndpoint,omitempty"`
95+
TokenEndpoint string `json:"tokenEndpoint,omitempty"`
96+
ResourceURL string `json:"resourceUrl,omitempty"`
97+
Scopes []string `json:"scopes,omitempty"`
9698
}
9799

98100
type DCRClient struct {

pkg/gateway/clientpool.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -328,6 +328,11 @@ func (cp *clientPool) argsAndEnv(serverConfig *catalog.ServerConfig, targetConfi
328328

329329
// Secrets
330330
for _, s := range serverConfig.Spec.Secrets {
331+
// Skip secrets without an env field - they are OAuth infrastructure
332+
// (e.g., client_secret) and are not injected into containers.
333+
if s.Env == "" {
334+
continue
335+
}
331336
args = append(args, "-e", s.Env)
332337

333338
secretValue, ok := serverConfig.Secrets[s.Name]

pkg/gateway/configuration.go

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -581,9 +581,9 @@ func (c *FileBasedConfiguration) readOnce(ctx context.Context) (Configuration, e
581581
}
582582
}
583583

584-
// CE mode: supplement with OAuth tokens from credential helper.
585-
// Docker Desktop's secret mechanisms (jcat, se://) aren't available in CE mode,
586-
// but OAuth tokens are stored in the credential helper by `docker mcp oauth authorize`.
584+
// CE mode: resolve OAuth tokens for container secret injection.
585+
// In CE mode there is no se:// URI resolution, so tokens must be read from
586+
// the credential helper and injected as raw env var values.
587587
if ceSecrets := readCEModeOAuthSecrets(ctx, servers, serverNames); len(ceSecrets) > 0 {
588588
if secrets == nil {
589589
secrets = make(map[string]string)

pkg/gateway/configuration_workingset.go

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -137,11 +137,13 @@ func (c *WorkingSetConfiguration) readOnce(ctx context.Context, dao db.DAO) (Con
137137
// }
138138
}
139139

140-
// CE mode: supplement with OAuth tokens from credential helper.
140+
// CE mode: resolve OAuth tokens for container secret injection.
141+
// In CE mode there is no se:// URI resolution, so tokens must be read from
142+
// the credential helper and injected as raw env var values.
141143
if ceSecrets := readCEModeOAuthSecrets(ctx, servers, serverNames); len(ceSecrets) > 0 {
142144
for k, v := range ceSecrets {
143-
if _, exists := flattenedSecrets[k]; !exists {
144-
flattenedSecrets[k] = v
145+
if _, exists := secrets[k]; !exists {
146+
secrets[k] = v
145147
}
146148
}
147149
}

pkg/gateway/run.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -362,7 +362,7 @@ func (g *Gateway) Run(ctx context.Context) error {
362362
continue
363363
}
364364

365-
if serverConfig.Spec.HasExplicitOAuthProviders() {
365+
if serverConfig.Spec.HasExplicitOAuthProviders() || serverConfig.Spec.HasPreRegisteredOAuth() {
366366
g.startProvider(ctx, serverName)
367367
} else if serverConfig.IsRemote() {
368368
// Community servers: start provider if they have a stored OAuth token

pkg/gateway/secrets_ce.go

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -8,11 +8,16 @@ import (
88
"github.com/docker/mcp-gateway/pkg/oauth"
99
)
1010

11-
// readCEModeOAuthSecrets reads OAuth tokens from the credential helper in CE mode.
12-
// In CE mode, Docker Desktop's secret mechanisms (jcat, Secrets Engine, se:// URIs)
13-
// are not available. Instead, OAuth tokens are stored in the system credential helper
14-
// (e.g., macOS Keychain) by `docker mcp oauth authorize`. This function reads those
15-
// tokens and maps them to the secret names expected by server definitions.
11+
// readCEModeOAuthSecrets resolves OAuth tokens for container secret injection in CE mode.
12+
//
13+
// In Desktop mode, secrets are injected into containers via se:// URIs that Docker
14+
// Desktop resolves at container start time. In CE mode, the standard Docker CLI has
15+
// no se:// URI resolution. Instead, OAuth tokens stored in the system credential
16+
// helper (e.g., macOS Keychain) by `docker mcp oauth authorize` must be read directly
17+
// and injected as raw environment variable values.
18+
//
19+
// This function reads those tokens and maps them to the secret names expected by
20+
// server definitions, so that clientpool.go can set them as container env vars.
1621
func readCEModeOAuthSecrets(ctx context.Context, servers map[string]catalog.Server, serverNames []string) map[string]string {
1722
secrets := make(map[string]string)
1823

pkg/oauth/dcr_registration.go

Lines changed: 50 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,15 @@ import (
66

77
oauthhelpers "github.com/docker/mcp-gateway-oauth-helpers"
88

9+
"github.com/docker/mcp-gateway/cmd/docker-mcp/secret-management/secret"
910
"github.com/docker/mcp-gateway/pkg/catalog"
1011
"github.com/docker/mcp-gateway/pkg/desktop"
1112
"github.com/docker/mcp-gateway/pkg/log"
1213
)
1314

15+
// clientSecretSuffix is the naming convention for OAuth client secrets in the secrets store.
16+
const clientSecretSuffix = ".client_secret"
17+
1418
// dcrRegistrationClient is the subset of desktop.Tools used for DCR registration.
1519
// Extracted as an interface to enable testing.
1620
type dcrRegistrationClient interface {
@@ -53,16 +57,36 @@ func RegisterProviderForLazySetup(ctx context.Context, serverName string) error
5357
return fmt.Errorf("server %s not found in catalog", serverName)
5458
}
5559

56-
// Verify this is a remote OAuth server (Type="remote" && OAuth providers exist)
57-
if !server.HasExplicitOAuthProviders() {
58-
return fmt.Errorf("server %s is not a remote OAuth server", serverName)
60+
// Verify this server has OAuth providers (remote with explicit providers, or pre-registered)
61+
if !server.HasExplicitOAuthProviders() && !server.HasPreRegisteredOAuth() {
62+
return fmt.Errorf("server %s does not have OAuth providers configured", serverName)
5963
}
6064

61-
providerName := server.OAuth.Providers[0].Provider
65+
provider := server.OAuth.Providers[0]
6266

63-
// Register with DD (pending DCR state)
67+
// Build DCR request with pre-registered metadata if available.
68+
// When registration + server_metadata are provided (from a catalog with
69+
// embedded OAuth metadata), Pinata skips DCR discovery and uses them directly.
6470
dcrRequest := desktop.RegisterDCRRequest{
65-
ProviderName: providerName,
71+
ProviderName: provider.Provider,
72+
}
73+
if provider.Registration != nil {
74+
dcrRequest.ClientID = provider.Registration.ClientID
75+
76+
// Look up client_secret from the Secrets Engine (user sets it via docker mcp secret set).
77+
// The catalog only contains client_id; the secret is never distributed in catalogs.
78+
clientSecretKey := secret.GetDefaultSecretKey(serverName + clientSecretSuffix)
79+
if env, err := secret.GetSecret(ctx, clientSecretKey); err == nil && string(env.Value) != "" {
80+
dcrRequest.ClientSecret = string(env.Value)
81+
log.Logf("- Registering pre-configured OAuth client for %s with client_secret", serverName)
82+
} else {
83+
log.Logf("- Registering pre-configured OAuth client for %s without client_secret (not yet set)", serverName)
84+
}
85+
}
86+
if provider.ServerMetadata != nil {
87+
dcrRequest.AuthorizationEndpoint = provider.ServerMetadata.AuthorizationEndpoint
88+
dcrRequest.TokenEndpoint = provider.ServerMetadata.TokenEndpoint
89+
dcrRequest.Scopes = provider.ServerMetadata.ScopesSupported
6690
}
6791

6892
return client.RegisterDCRClientPending(ctx, serverName, dcrRequest)
@@ -103,7 +127,7 @@ func registerProviderForDynamicDiscovery(ctx context.Context, serverName, server
103127
// RegisterProviderWithSnapshot registers a DCR provider using OAuth metadata from the server snapshot
104128
// This avoids querying the catalog since the snapshot already contains all necessary OAuth information
105129
// Idempotent - safe to call multiple times for the same server
106-
func RegisterProviderWithSnapshot(ctx context.Context, serverName, providerName string) error {
130+
func RegisterProviderWithSnapshot(ctx context.Context, serverName string, provider catalog.OAuthProvider, scopes []string) error {
107131
client := desktop.NewAuthClient()
108132

109133
// Idempotent check - already registered?
@@ -113,8 +137,26 @@ func RegisterProviderWithSnapshot(ctx context.Context, serverName, providerName
113137
}
114138

115139
// Register with Docker Desktop (pending DCR state)
140+
// Include pre-registered client metadata if available from catalog
116141
dcrRequest := desktop.RegisterDCRRequest{
117-
ProviderName: providerName,
142+
ProviderName: provider.Provider,
143+
Scopes: scopes,
144+
}
145+
if provider.Registration != nil {
146+
dcrRequest.ClientID = provider.Registration.ClientID
147+
148+
// Look up client_secret from Secrets Engine (not distributed in catalogs)
149+
clientSecretKey := secret.GetDefaultSecretKey(serverName + clientSecretSuffix)
150+
if env, err := secret.GetSecret(ctx, clientSecretKey); err == nil && string(env.Value) != "" {
151+
dcrRequest.ClientSecret = string(env.Value)
152+
}
153+
}
154+
if provider.ServerMetadata != nil {
155+
dcrRequest.AuthorizationEndpoint = provider.ServerMetadata.AuthorizationEndpoint
156+
dcrRequest.TokenEndpoint = provider.ServerMetadata.TokenEndpoint
157+
if len(dcrRequest.Scopes) == 0 {
158+
dcrRequest.Scopes = provider.ServerMetadata.ScopesSupported
159+
}
118160
}
119161

120162
return client.RegisterDCRClientPending(ctx, serverName, dcrRequest)

0 commit comments

Comments
 (0)