Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
91 changes: 91 additions & 0 deletions cmd/docker-mcp/oauth/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,24 @@ package oauth
import (
"context"
"fmt"
"time"

"github.com/docker/mcp-gateway/pkg/desktop"
pkgoauth "github.com/docker/mcp-gateway/pkg/oauth"
)

func Authorize(ctx context.Context, app string, scopes string) error {
// Check if running in CE mode
if pkgoauth.IsCEMode() {
return authorizeCEMode(ctx, app, scopes)
}

// Desktop mode - existing implementation
return authorizeDesktopMode(ctx, app, scopes)
}

// authorizeDesktopMode handles OAuth via Docker Desktop (existing behavior)
func authorizeDesktopMode(ctx context.Context, app string, scopes string) error {
client := desktop.NewAuthClient()

// Start OAuth flow - Docker Desktop handles DCR automatically if needed
Expand All @@ -24,3 +37,81 @@ func Authorize(ctx context.Context, app string, scopes string) error {
fmt.Printf("Opening your browser for authentication. If it doesn't open automatically, please visit: %s\n", authResponse.BrowserURL)
return nil
}

// authorizeCEMode handles OAuth in standalone CE mode
func authorizeCEMode(ctx context.Context, serverName string, scopes string) error {
fmt.Printf("Starting OAuth authorization for %s...\n", serverName)

// Create OAuth manager with read-write credential helper
credHelper := pkgoauth.NewReadWriteCredentialHelper()
manager := pkgoauth.NewManager(credHelper)

// Step 1: Ensure DCR client is registered
fmt.Printf("Checking DCR registration...\n")
if err := manager.EnsureDCRClient(ctx, serverName, scopes); err != nil {
return fmt.Errorf("DCR registration failed: %w", err)
}

// Step 2: Create callback server
callbackServer, err := pkgoauth.NewCallbackServer()
if err != nil {
return fmt.Errorf("failed to create callback server: %w", err)
}

// Start callback server in background
go func() {
if err := callbackServer.Start(); err != nil {
fmt.Printf("Callback server error: %v\n", err)
}
}()
defer func() {
shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := callbackServer.Shutdown(shutdownCtx); err != nil {
fmt.Printf("Warning: failed to shutdown callback server: %v\n", err)
}
}()

// Step 3: Build authorization URL with callback URL in state
fmt.Printf("Generating authorization URL...\n")

scopesList := []string{}
if scopes != "" {
scopesList = []string{scopes}
}

// Pass callback URL - will be embedded in state for mcp-oauth proxy routing
callbackURL := callbackServer.URL()
authURL, baseState, _, err := manager.BuildAuthorizationURL(ctx, serverName, scopesList, callbackURL)
if err != nil {
return fmt.Errorf("failed to generate authorization URL: %w", err)
}

// Store base state for later validation
_ = baseState // We'll validate using the state from callback

// Step 4: Display authorization URL
fmt.Printf("Please visit this URL to authorize:\n\n %s\n\n", authURL)

// Step 5: Wait for callback
fmt.Printf("Waiting for authorization callback on http://localhost:%d/callback...\n", callbackServer.Port())

timeoutCtx, cancel := context.WithTimeout(ctx, 5*time.Minute)
defer cancel()

code, callbackState, err := callbackServer.Wait(timeoutCtx)
if err != nil {
return fmt.Errorf("failed to receive callback: %w", err)
}

// Step 6: Exchange code for token
fmt.Printf("Exchanging authorization code for access token...\n")
if err := manager.ExchangeCode(ctx, code, callbackState); err != nil {
return fmt.Errorf("token exchange failed: %w", err)
}

fmt.Printf("Authorization successful! Token stored securely.\n")
fmt.Printf("You can now use: docker mcp server start %s\n", serverName)

return nil
}
38 changes: 36 additions & 2 deletions cmd/docker-mcp/oauth/revoke.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,23 @@ import (

"github.com/docker/mcp-gateway/pkg/catalog"
"github.com/docker/mcp-gateway/pkg/desktop"
pkgoauth "github.com/docker/mcp-gateway/pkg/oauth"
)

func Revoke(ctx context.Context, app string) error {
fmt.Printf("Revoking OAuth access for %s...\n", app)

// Check if CE mode
if pkgoauth.IsCEMode() {
return revokeCEMode(ctx, app)
}

// Desktop mode - existing implementation
return revokeDesktopMode(ctx, app)
}

// revokeDesktopMode handles revoke via Docker Desktop (existing behavior)
func revokeDesktopMode(ctx context.Context, app string) error {
client := desktop.NewAuthClient()

// Get catalog to check if this is a remote OAuth server
Expand All @@ -20,8 +34,6 @@ func Revoke(ctx context.Context, app string) error {
server, found := catalogData.Servers[app]
isRemoteOAuth := found && server.IsRemoteOAuthServer()

fmt.Printf("Revoking OAuth access for %s...\n", app)

// Revoke tokens
if err := client.DeleteOAuthApp(ctx, app); err != nil {
return fmt.Errorf("failed to revoke OAuth access: %w", err)
Expand All @@ -34,5 +46,27 @@ func Revoke(ctx context.Context, app string) error {
}
}

fmt.Printf("OAuth access revoked for %s\n", app)
return nil
}

// revokeCEMode handles revoke in standalone CE mode
// Matches Desktop behavior: deletes both token and DCR client
func revokeCEMode(ctx context.Context, app string) error {
credHelper := pkgoauth.NewReadWriteCredentialHelper()
manager := pkgoauth.NewManager(credHelper)

// Delete OAuth token
if err := manager.RevokeToken(ctx, app); err != nil {
// Token might not exist, continue to DCR deletion
fmt.Printf("Note: %v\n", err)
}

// Delete DCR client (matches Desktop behavior)
if err := manager.DeleteDCRClient(app); err != nil {
return fmt.Errorf("failed to delete DCR client: %w", err)
}

fmt.Printf("OAuth access revoked for %s\n", app)
return nil
}
18 changes: 12 additions & 6 deletions cmd/docker-mcp/server/enable.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import (
"github.com/docker/mcp-gateway/pkg/catalog"
"github.com/docker/mcp-gateway/pkg/config"
"github.com/docker/mcp-gateway/pkg/docker"
"github.com/docker/mcp-gateway/pkg/oauth"
pkgoauth "github.com/docker/mcp-gateway/pkg/oauth"
)

func Disable(ctx context.Context, docker docker.Client, dockerCli command.Cli, serverNames []string, mcpOAuthDcrEnabled bool) error {
Expand Down Expand Up @@ -60,13 +60,19 @@ func update(ctx context.Context, docker docker.Client, dockerCli command.Cli, ad
Ref: "",
}

// Three-condition check: DCR flag enabled AND type="remote" AND oauth present
// DCR flag enabled AND type="remote" AND oauth present
if mcpOAuthDcrEnabled && server.IsRemoteOAuthServer() {
if err := oauth.RegisterProviderForLazySetup(ctx, serverName); err != nil {
fmt.Printf("Warning: Failed to register OAuth provider for %s: %v\n", serverName, err)
fmt.Printf(" You can run 'docker mcp oauth authorize %s' later to set up authentication.\n", serverName)
// In CE mode, skip lazy setup - DCR happens during oauth authorize
if pkgoauth.IsCEMode() {
fmt.Printf("OAuth server %s enabled. Run 'docker mcp oauth authorize %s' to authenticate\n", serverName, serverName)
} else {
fmt.Printf("OAuth provider configured for %s - use 'docker mcp oauth authorize %s' to authenticate\n", serverName, serverName)
// Desktop mode - register provider for lazy setup
if err := pkgoauth.RegisterProviderForLazySetup(ctx, serverName); err != nil {
fmt.Printf("Warning: Failed to register OAuth provider for %s: %v\n", serverName, err)
fmt.Printf(" You can run 'docker mcp oauth authorize %s' later to set up authentication.\n", serverName)
} else {
fmt.Printf("OAuth provider configured for %s - use 'docker mcp oauth authorize %s' to authenticate\n", serverName, serverName)
}
}
} else if !mcpOAuthDcrEnabled && server.IsRemoteOAuthServer() {
// Provide guidance when DCR is needed but disabled
Expand Down
100 changes: 100 additions & 0 deletions docs/oauth-ce-mode.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
# OAuth DCR with Docker CE

Complete guide for using MCP Gateway OAuth with Docker Engine (CE Mode).

## Prerequisites

### 1. Credential Helper Installed

MCP Gateway requires [Docker credential helper](https://github.com/docker/docker-credential-helpers) configured to securely store OAuth tokens.


Download from releases:
https://github.com/docker/docker-credential-helpers/releases

### 2. Configure Docker to Use Credential Helper

Edit or create `~/.docker/config.json`:

**macOS:**
```json
{
"credsStore": "osxkeychain"
}
```

**Linux (Desktop):**
```json
{
"credsStore": "secretservice"
}
```

**Linux (Headless):**
```json
{
"credsStore": "pass"
}
```

**Windows:**
```json
{
"credsStore": "wincred"
}
```

**Verify configuration:**
```bash
# Helper should be in your PATH
which docker-credential-osxkeychain # macOS
which docker-credential-secretservice # Linux Desktop
which docker-credential-pass # Linux Headless
where docker-credential-wincred.exe # Windows
```

For detailed installation instructions, see:
https://github.com/docker/docker-credential-helpers

## Configuration

### Enable CE Mode

Set the environment variable to enable standalone OAuth:

```bash
export DOCKER_MCP_USE_CE=true
```
### Optional: Customize OAuth Callback Port

By default, MCP Gateway listens on `localhost:5000` for OAuth callbacks.

If port 5000 is already in use, set a custom port:

```bash
export MCP_GATEWAY_OAUTH_PORT=5001
```

Valid range: 1024-65535

**Error handling:**
- If port is in use, you'll see a clear error message with solutions
- Invalid port values (< 1024 or > 65535) fall back to default with warning

## Usage

### Step-by-Step: Authorizing an OAuth Server

This example uses Notion, but works with any OAuth-enabled MCP server.

#### Step 1: Enable the Server

```bash
docker mcp server enable notion-remote
```

#### Step 2: Authorize

```bash
docker mcp oauth authorize notion-remote
```
6 changes: 4 additions & 2 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ require (
github.com/docker/cli-docs-tool v0.10.0
github.com/docker/docker v28.3.3+incompatible
github.com/docker/docker-credential-helpers v0.9.3
github.com/docker/mcp-gateway-oauth-helpers v0.0.3
github.com/dop251/goja v0.0.0-20251008123653-cf18d89f3cf6
github.com/fsnotify/fsnotify v1.9.0
github.com/go-playground/validator/v10 v10.28.0
Expand All @@ -34,6 +35,7 @@ require (
go.opentelemetry.io/otel/sdk v1.36.0
go.opentelemetry.io/otel/sdk/metric v1.36.0
go.opentelemetry.io/otel/trace v1.37.0
golang.org/x/oauth2 v0.32.0
golang.org/x/sync v0.17.0
gopkg.in/op/go-logging.v1 v1.0.0-20160211212156-b2cb9fa56473
gopkg.in/yaml.v3 v3.0.1
Expand Down Expand Up @@ -90,7 +92,7 @@ require (
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/elliotchance/orderedmap v1.8.0 // indirect
github.com/emicklei/go-restful/v3 v3.12.2 // indirect
github.com/fatih/color v1.18.0 // indirect
github.com/fatih/color v1.18.0
github.com/felixge/httpsnoop v1.0.4 // indirect
github.com/fvbommel/sortorder v1.1.0 // indirect
github.com/go-chi/chi v4.1.2+incompatible // indirect
Expand All @@ -114,7 +116,7 @@ require (
github.com/gogo/protobuf v1.3.2 // indirect
github.com/golang/snappy v1.0.0 // indirect
github.com/google/certificate-transparency-go v1.3.2 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/google/uuid v1.6.0
github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.3 // indirect
github.com/in-toto/attestation v1.1.2 // indirect
github.com/in-toto/in-toto-golang v0.9.0 // indirect
Expand Down
6 changes: 4 additions & 2 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -244,6 +244,8 @@ github.com/docker/go-metrics v0.0.1/go.mod h1:cG1hvH2utMXtqgqqYE9plW6lDxS3/5ayHz
github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
github.com/docker/libtrust v0.0.0-20160708172513-aabc10ec26b7/go.mod h1:cyGadeNEkKy96OOhEzfZl+yxihPEzKnqJwvfuSUqbZE=
github.com/docker/mcp-gateway-oauth-helpers v0.0.3 h1:TgXk/DFaXFYpDhduN3l7LCjdjo6doTHXoEg2P43NZT0=
github.com/docker/mcp-gateway-oauth-helpers v0.0.3/go.mod h1:IK0jk+ZNYW/yy5yotveLFcU9Jgk3pMpNasX4X1hheZM=
github.com/dop251/goja v0.0.0-20251008123653-cf18d89f3cf6 h1:6dE1TmjqkY6tehR4A67gDNhvDtuZ54ocu7ab4K9o540=
github.com/dop251/goja v0.0.0-20251008123653-cf18d89f3cf6/go.mod h1:MxLav0peU43GgvwVgNbLAj1s/bSGboKkhuULvq/7hx4=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
Expand Down Expand Up @@ -834,8 +836,8 @@ golang.org/x/net v0.0.0-20220225172249-27dd8689420f/go.mod h1:CfG3xpIq0wQ8r1q4Su
golang.org/x/net v0.0.0-20220607020251-c690dde0001d/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE=
golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg=
golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI=
golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU=
golang.org/x/oauth2 v0.32.0 h1:jsCblLleRMDrxMN29H3z/k1KliIvpLgCkE6R8FXXNgY=
golang.org/x/oauth2 v0.32.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
Expand Down
15 changes: 9 additions & 6 deletions pkg/gateway/run.go
Original file line number Diff line number Diff line change
Expand Up @@ -274,13 +274,16 @@ func (g *Gateway) Run(ctx context.Context) error {

if g.McpOAuthDcrEnabled && !inContainer {
// Start OAuth notification monitor to receive OAuth related events from Docker Desktop
log.Log("- Starting OAuth notification monitor")
monitor := oauth.NewNotificationMonitor()
monitor.OnOAuthEvent = func(event oauth.Event) {
// Route event to specific provider
g.routeEventToProvider(event)
// Skip in CE mode (no Desktop to connect to)
if !oauth.IsCEMode() {
log.Log("- Starting OAuth notification monitor")
monitor := oauth.NewNotificationMonitor()
monitor.OnOAuthEvent = func(event oauth.Event) {
// Route event to specific provider
g.routeEventToProvider(event)
}
monitor.Start(ctx)
}
monitor.Start(ctx)

// Start OAuth provider for each OAuth server
// Each provider runs in its own goroutine with dynamic timing based on token expiry
Expand Down
Loading
Loading