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
6 changes: 3 additions & 3 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ require (
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510
github.com/jmoiron/sqlx v1.4.0
github.com/mikefarah/yq/v4 v4.45.4
github.com/modelcontextprotocol/go-sdk v1.4.1
github.com/modelcontextprotocol/go-sdk v1.5.0
github.com/modelcontextprotocol/registry v0.0.0-00010101000000-000000000000
github.com/opencontainers/go-digest v1.0.0
github.com/opencontainers/image-spec v1.1.1
Expand All @@ -37,7 +37,7 @@ require (
go.opentelemetry.io/otel/sdk v1.38.0
go.opentelemetry.io/otel/sdk/metric v1.38.0
go.opentelemetry.io/otel/trace v1.38.0
golang.org/x/oauth2 v0.34.0
golang.org/x/oauth2 v0.35.0
golang.org/x/sync v0.19.0
gopkg.in/op/go-logging.v1 v1.0.0-20160211212156-b2cb9fa56473
gopkg.in/yaml.v3 v3.0.1
Expand Down Expand Up @@ -200,7 +200,7 @@ require (
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect
golang.org/x/mod v0.30.0 // indirect
golang.org/x/net v0.47.0 // indirect
golang.org/x/sys v0.40.0 // indirect
golang.org/x/sys v0.41.0 // indirect
golang.org/x/term v0.38.0 // indirect
golang.org/x/text v0.32.0 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20250818200422-3122310a409c // indirect
Expand Down
20 changes: 10 additions & 10 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -374,8 +374,8 @@ github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
github.com/golang-jwt/jwt/v4 v4.5.2 h1:YtQM7lnr8iZ+j5q71MGKkNw9Mn7AjHM68uc9g5fXeUI=
github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo=
github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/golang-migrate/migrate/v4 v4.19.0 h1:RcjOnCGz3Or6HQYEJ/EEVLfWnmw9KnoigPSjzhCuaSE=
github.com/golang-migrate/migrate/v4 v4.19.0/go.mod h1:9dyEcu+hO+G9hPSw8AIg50yg622pXJsoHItQnDGZkI0=
github.com/golang-sql/civil v0.0.0-20190719163853-cb61b32ac6fe/go.mod h1:8vg3r2VgvsThLBIFL93Qb5yWzgyZWhEmBwUJWevAkK0=
Expand Down Expand Up @@ -564,8 +564,8 @@ github.com/moby/sys/sequential v0.6.0 h1:qrx7XFUd/5DxtqcoH1h438hF5TmOvzC/lspjy7z
github.com/moby/sys/sequential v0.6.0/go.mod h1:uyv8EUTrca5PnDsdMGXhZe6CCe8U/UiTWd+lL+7b/Ko=
github.com/moby/term v0.5.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ=
github.com/moby/term v0.5.2/go.mod h1:d3djjFCrjnB+fl8NJux+EJzu0msscUP+f8it8hPkFLc=
github.com/modelcontextprotocol/go-sdk v1.4.1 h1:M4x9GyIPj+HoIlHNGpK2hq5o3BFhC+78PkEaldQRphc=
github.com/modelcontextprotocol/go-sdk v1.4.1/go.mod h1:Bo/mS87hPQqHSRkMv4dQq1XCu6zv4INdXnFZabkNU6s=
github.com/modelcontextprotocol/go-sdk v1.5.0 h1:CHU0FIX9kpueNkxuYtfYQn1Z0slhFzBZuq+x6IiblIU=
github.com/modelcontextprotocol/go-sdk v1.5.0/go.mod h1:gggDIhoemhWs3BGkGwd1umzEXCEMMvAnhTrnbXJKKKA=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
Expand Down Expand Up @@ -869,8 +869,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.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=
golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU=
golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw=
golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ=
golang.org/x/oauth2 v0.35.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 Expand Up @@ -902,8 +902,8 @@ golang.org/x/sys v0.0.0-20220412211240-33da011f77ad/go.mod h1:oPkhp1MJrh7nUepCBc
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
Expand All @@ -922,8 +922,8 @@ golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtn
golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
golang.org/x/tools v0.0.0-20201224043029-2b0845dc783e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc=
golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg=
golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
Expand Down
Binary file added npm-catalog-check
Binary file not shown.
Binary file added npm-catalog-count
Binary file not shown.
69 changes: 44 additions & 25 deletions pkg/mcp/remote.go
Original file line number Diff line number Diff line change
Expand Up @@ -81,37 +81,51 @@ func (c *remoteMCPClient) Initialize(ctx context.Context, _ *mcp.InitializeParam
headers[k] = expandEnv(v, env)
}

// Add OAuth token if remote server has OAuth configuration
if c.config.Spec.OAuth != nil && len(c.config.Spec.OAuth.Providers) > 0 {
if verbose {
log.Logf(" - Using OAuth token for: %s", c.config.Name)
}
credHelper := oauth.NewOAuthCredentialHelper()
token, err := credHelper.GetOAuthToken(ctx, c.config.Name)
if err != nil {
log.Logf("Failed to get OAuth token for %s: %v", c.config.Name, err)
} else if token != "" {
headers["Authorization"] = "Bearer " + token
}
} else if c.config.Spec.Remote.URL != "" {
// Community servers may have OAuth tokens via dynamic discovery (DCR)
// without explicit OAuth metadata in the catalog. Try to get a stored token.
// Use per-server mode so community servers read from docker pass.
mode := oauth.DetermineMode(ctx, c.config.Spec.IsCommunity())
credHelper := oauth.NewOAuthCredentialHelperWithMode(mode)
token, err := credHelper.GetOAuthToken(ctx, c.config.Name)
if err == nil && token != "" {
// Determine OAuth mode for this server.
oauthMode := oauth.DetermineMode(ctx, c.config.Spec.IsCommunity())

// For SSE transport, inject OAuth token into headers (SSE lacks OAuthHandler support).
// For streamable transport, the SDK's OAuthHandler handles token injection and 401 retry.
isStreamable := false
switch strings.ToLower(transport) {
case "http", "streamable", "streaming", "streamable-http":
isStreamable = true
}

if isStreamable {
// Streamable path: the SDK's OAuthHandler manages the Authorization header.
// Remove any user-configured Authorization to avoid conflicts.
delete(headers, "Authorization")
} else {
// SSE path: inject OAuth token directly into headers.
if c.config.Spec.OAuth != nil && len(c.config.Spec.OAuth.Providers) > 0 {
if verbose {
log.Logf(" - Using dynamic OAuth token for: %s", c.config.Name)
log.Logf(" - Using OAuth token for: %s", c.config.Name)
}
credHelper := oauth.NewOAuthCredentialHelper()
token, err := credHelper.GetOAuthToken(ctx, c.config.Name)
if err != nil {
log.Logf("Failed to get OAuth token for %s: %v", c.config.Name, err)
} else if token != "" {
headers["Authorization"] = "Bearer " + token
}
} else if c.config.Spec.Remote.URL != "" {
credHelper := oauth.NewOAuthCredentialHelperWithMode(oauthMode)
token, err := credHelper.GetOAuthToken(ctx, c.config.Name)
if err == nil && token != "" {
if verbose {
log.Logf(" - Using dynamic OAuth token for: %s", c.config.Name)
}
headers["Authorization"] = "Bearer " + token
}
headers["Authorization"] = "Bearer " + token
}
}

var mcpTransport mcp.Transport
var err error

// Create HTTP client with custom headers
// Create HTTP client with custom headers (non-OAuth headers for streamable,
// all headers including OAuth for SSE).
httpClient := &http.Client{
Transport: &headerRoundTripper{
base: desktop.ProxyTransport(),
Expand All @@ -126,9 +140,14 @@ func (c *remoteMCPClient) Initialize(ctx context.Context, _ *mcp.InitializeParam
HTTPClient: httpClient,
}
case "http", "streamable", "streaming", "streamable-http":
sdkOAuthHandler := oauth.NewSDKHandler(c.config.Name, oauthMode)
if verbose {
log.Logf(" - Using SDK OAuthHandler for: %s (mode=%s)", c.config.Name, oauthMode)
}
mcpTransport = &mcp.StreamableClientTransport{
Endpoint: url,
HTTPClient: httpClient,
Endpoint: url,
HTTPClient: httpClient,
OAuthHandler: sdkOAuthHandler,
}
default:
return fmt.Errorf("unsupported remote transport: %s", transport)
Expand Down
88 changes: 88 additions & 0 deletions pkg/oauth/sdk_handler.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
package oauth

import (
"context"
"fmt"
"io"
"net/http"

"golang.org/x/oauth2"

"github.com/docker/mcp-gateway/pkg/log"
)

// SDKHandler implements auth.OAuthHandler from the go-sdk, bridging the
// gateway's existing credential storage (CE/Desktop/Community modes) to the
// SDK's transport-level OAuth. This enables automatic Bearer token injection
// and 401/403 retry on StreamableClientTransport.
type SDKHandler struct {
serverName string
mode Mode
credHelper *CredentialHelper
}

// NewSDKHandler creates a handler for the given server using the specified
// credential storage mode.
func NewSDKHandler(serverName string, mode Mode) *SDKHandler {
return &SDKHandler{
serverName: serverName,
mode: mode,
credHelper: NewOAuthCredentialHelperWithMode(mode),
}
}

// TokenSource returns a token source that reads from the gateway's credential
// store on each Token() call. Returns nil (not an error) when no token exists,
// which tells the transport to skip the Authorization header.
func (h *SDKHandler) TokenSource(ctx context.Context) (oauth2.TokenSource, error) {
token, err := h.credHelper.GetOAuthToken(ctx, h.serverName)
if err != nil || token == "" {
return nil, nil //nolint:nilnil // nil token source means "no auth header"
}
return &gatewayTokenSource{
ctx: ctx,
serverName: h.serverName,
credHelper: h.credHelper,
}, nil
}

// Authorize is called by the SDK transport on 401/403 responses. The gateway
// runs as a background daemon and cannot interactively open a browser, so this
// method returns an error instructing the user to authorize manually.
// The response body is consumed and closed as required by the interface contract.
func (h *SDKHandler) Authorize(_ context.Context, _ *http.Request, resp *http.Response) error {
statusCode := 0
if resp != nil {
statusCode = resp.StatusCode
if resp.Body != nil {
_, _ = io.Copy(io.Discard, resp.Body)
resp.Body.Close()
}
}
log.Logf("! OAuth authorization required for %s (received %d)", h.serverName, statusCode)
return fmt.Errorf("OAuth authorization required for %s: run 'docker mcp oauth authorize %s' to authenticate", h.serverName, h.serverName)
}

// gatewayTokenSource implements oauth2.TokenSource by reading the current
// access token from the gateway's credential store. Each call to Token()
// fetches the latest token, so background refreshes are picked up automatically.
type gatewayTokenSource struct {
ctx context.Context //nolint:containedctx // oauth2.TokenSource.Token() has no context param; storing ctx is the only option
serverName string
credHelper *CredentialHelper
}

func (ts *gatewayTokenSource) Token() (*oauth2.Token, error) {
accessToken, err := ts.credHelper.GetOAuthToken(ts.ctx, ts.serverName)
if err != nil {
return nil, fmt.Errorf("failed to get OAuth token for %s: %w", ts.serverName, err)
}
// Expiry is intentionally unset: the SDK calls TokenSource() on every
// request (no caching), so each call re-reads from the credential store.
// Background refresh is handled by the gateway's Provider refresh loop,
// not by the oauth2 library's built-in expiry logic.
return &oauth2.Token{
AccessToken: accessToken,
TokenType: "Bearer",
}, nil
}
Loading
Loading