diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..fc0ef62 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,37 @@ +FROM golang:1.25.1-alpine AS builder + +# Set the working directory inside the container +WORKDIR /app + +COPY go.mod go.sum ./ + +RUN go mod download + +COPY . . + +# Build the application +RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o pingone-mcp-server . + +# Use a minimal base image for the final stage +FROM alpine:latest + +WORKDIR /root/ + +# Environment variables for PingOne MCP Server configuration +ENV PINGONE_TOP_LEVEL_DOMAIN="" \ + PINGONE_REGION_CODE="" \ + PINGONE_MCP_ENVIRONMENT_ID="" \ + PINGONE_DEVICE_CODE_CLIENT_ID="" \ + PINGONE_DEVICE_CODE_SCOPES="openid" \ + PINGONE_MCP_DEBUG="false" + +# Copy the binary from the builder stage +COPY --from=builder /app/pingone-mcp-server . + +# Copy the entrypoint script +COPY docker-entrypoint.sh . + +RUN chmod +x ./pingone-mcp-server && \ + chmod +x ./docker-entrypoint.sh + +ENTRYPOINT ["./docker-entrypoint.sh"] \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..830a3d2 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,9 @@ +version: '3.8' + +services: + pingone-mcp-server: + build: + context: . + dockerfile: Dockerfile + image: pingone-mcp-server:latest + container_name: pingone-mcp-server diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh new file mode 100644 index 0000000..4a273b9 --- /dev/null +++ b/docker-entrypoint.sh @@ -0,0 +1,5 @@ +#!/bin/sh +set -e + +echo "Starting MCP server..." +exec ./pingone-mcp-server run --grant-type=device_code --store-type=file "$@" diff --git a/go.mod b/go.mod index 0cd4983..59cab3e 100644 --- a/go.mod +++ b/go.mod @@ -8,7 +8,7 @@ require ( github.com/google/go-cmp v0.7.0 github.com/google/jsonschema-go v0.3.0 github.com/google/uuid v1.6.0 - github.com/modelcontextprotocol/go-sdk v1.1.0 + github.com/modelcontextprotocol/go-sdk v1.2.0-pre.1 github.com/patrickcping/pingone-go-sdk-v2 v0.14.0 github.com/patrickcping/pingone-go-sdk-v2/management v0.60.0 github.com/pingidentity/pingone-go-client v0.4.1 diff --git a/go.sum b/go.sum index 382060b..7a47a04 100644 --- a/go.sum +++ b/go.sum @@ -206,6 +206,8 @@ github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5x github.com/gofrs/flock v0.12.1 h1:MTLVXXHf8ekldpJk3AKicLij9MdwOWkZ+a/jHHZby9E= github.com/gofrs/flock v0.12.1/go.mod h1:9zxTsyu5xtJ9DK+1tFZyibEV7y3uwDxPPfbxeeHCoD0= github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ= +github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8= +github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= github.com/golang/groupcache v0.0.0-20191227052852-215e87163ea7/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= @@ -402,8 +404,8 @@ github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= -github.com/modelcontextprotocol/go-sdk v1.1.0 h1:Qjayg53dnKC4UZ+792W21e4BpwEZBzwgRW6LrjLWSwA= -github.com/modelcontextprotocol/go-sdk v1.1.0/go.mod h1:6fM3LCm3yV7pAs8isnKLn07oKtB0MP9LHd3DfAcKw10= +github.com/modelcontextprotocol/go-sdk v1.2.0-pre.1 h1:14+JrlEIFvUmbu5+iJzWPLk8CkpvegfKr42oXyjp3O4= +github.com/modelcontextprotocol/go-sdk v1.2.0-pre.1/go.mod h1:6fM3LCm3yV7pAs8isnKLn07oKtB0MP9LHd3DfAcKw10= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= diff --git a/internal/auth/client/interface.go b/internal/auth/client/interface.go index e9fca3d..d06082a 100644 --- a/internal/auth/client/interface.go +++ b/internal/auth/client/interface.go @@ -5,11 +5,12 @@ package client import ( "context" + "github.com/modelcontextprotocol/go-sdk/mcp" "github.com/pingidentity/pingone-mcp-server/internal/auth" "golang.org/x/oauth2" ) type AuthClient interface { - TokenSource(ctx context.Context, grantType auth.GrantType) (oauth2.TokenSource, error) + TokenSource(ctx context.Context, grantType auth.GrantType, mcpServerSession *mcp.ServerSession) (oauth2.TokenSource, error) BrowserLoginAvailable(grantType auth.GrantType) bool } diff --git a/internal/auth/client/wrapper.go b/internal/auth/client/wrapper.go index a183864..49500f8 100644 --- a/internal/auth/client/wrapper.go +++ b/internal/auth/client/wrapper.go @@ -6,6 +6,8 @@ import ( "context" "fmt" + "github.com/google/uuid" + "github.com/modelcontextprotocol/go-sdk/mcp" "github.com/pingidentity/pingone-go-client/config" pingoneOauth2 "github.com/pingidentity/pingone-go-client/oauth2" "github.com/pingidentity/pingone-go-client/pingone" @@ -31,7 +33,7 @@ func NewPingOneClientAuthWrapper(serverVersion, environmentId string) *PingOneCl } } -func (p *PingOneClientAuthWrapper) TokenSource(ctx context.Context, grantType auth.GrantType) (oauth2.TokenSource, error) { +func (p *PingOneClientAuthWrapper) TokenSource(ctx context.Context, grantType auth.GrantType, mcpServerSession *mcp.ServerSession) (oauth2.TokenSource, error) { logger.FromContext(ctx).Debug("Creating token source from PingOne go client") var clientGrantType pingoneOauth2.GrantType @@ -51,7 +53,10 @@ func (p *PingOneClientAuthWrapper) TokenSource(ctx context.Context, grantType au WithStorageType(config.StorageTypeNone) // keychain storage will be managed by the mcp server // Configure custom UX handlers for headless operation - p.configureHeadlessHandlers(ctx, clientConfig, grantType) + err := p.configureHeadlessHandlers(ctx, clientConfig, grantType, mcpServerSession) + if err != nil { + return nil, err + } pingoneConfig := pingone.NewConfiguration(clientConfig) pingoneConfig.AppendUserAgent(audit.PingOneAPIUserAgent(p.serverVersion)) @@ -73,7 +78,7 @@ func (p *PingOneClientAuthWrapper) BrowserLoginAvailable(grantType auth.GrantTyp // This provides environment-aware browser handling: // - If browser is available: opens browser for both auth code and device code flows // - If no browser: auth code fails (requires browser), device code prints instructions -func (p *PingOneClientAuthWrapper) configureHeadlessHandlers(ctx context.Context, cfg *config.Configuration, grantType auth.GrantType) { +func (p *PingOneClientAuthWrapper) configureHeadlessHandlers(ctx context.Context, cfg *config.Configuration, grantType auth.GrantType, mcpServerSession *mcp.ServerSession) error { log := logger.FromContext(ctx) // Check if we're in an environment with browser support @@ -81,6 +86,11 @@ func (p *PingOneClientAuthWrapper) configureHeadlessHandlers(ctx context.Context switch grantType { case auth.GrantTypeDeviceCode: + // If mcpServerSession is nil, we cannot proceed + if mcpServerSession == nil { + return fmt.Errorf("no MCP server session found. The MCP server session is required to elicit the URL for device code flow") + } + // Initialize DeviceCode struct if it doesn't exist if cfg.Auth.DeviceCode == nil { cfg.Auth.DeviceCode = &config.DeviceCode{} @@ -116,10 +126,27 @@ func (p *PingOneClientAuthWrapper) configureHeadlessHandlers(ctx context.Context log.Info("Alternatively, open this URL to enter the code manually", "url", verificationURI) log.Info("Enter this code when prompted", "code", userCode) } else { - // Browser failed to open or not available - show manual instructions - log.Info("Please open this URL in your browser to complete authentication", "url", fullURL) - log.Info("Alternatively, open this URL to enter the code manually", "url", verificationURI) - log.Info("Enter this code when prompted", "code", userCode) + // Browser failed to open or not available - elicit with url mode elicitation + elicitID := uuid.New().String() + elicitResult, err := mcpServerSession.Elicit( + ctx, + &mcp.ElicitParams{ + Message: "Open the following URL in your browser to complete authentication", + URL: fullURL, + ElicitationID: elicitID, + }, + ) + + if err != nil { + return fmt.Errorf("failed to elicit device code URL: %w", err) + } + + switch elicitResult.Action { + case "decline": + return fmt.Errorf("device code URL elicitation was not completed. The request was declined.") + case "cancel": + return fmt.Errorf("device code URL elicitation was not completed. The request was canceled.") + } } log.Info("Waiting for authorization...") @@ -170,6 +197,8 @@ func (p *PingOneClientAuthWrapper) configureHeadlessHandlers(ctx context.Context Message: "An error occurred.", } } + + return nil } type PingOneClientAuthWrapperFactory struct { diff --git a/internal/auth/login/login.go b/internal/auth/login/login.go index 575ad78..5c47eda 100644 --- a/internal/auth/login/login.go +++ b/internal/auth/login/login.go @@ -10,6 +10,7 @@ import ( "time" "github.com/google/uuid" + "github.com/modelcontextprotocol/go-sdk/mcp" "github.com/pingidentity/pingone-mcp-server/internal/auth" "github.com/pingidentity/pingone-mcp-server/internal/auth/client" "github.com/pingidentity/pingone-mcp-server/internal/auth/logout" @@ -22,18 +23,18 @@ const authTimeout = 5 * time.Minute // Login with the given authClient for the specified grant type. The resulting auth session // will be stored in the provided tokenStore. // This method will always re-authenticate, even if a valid session already exists. -func ForceLogin(ctx context.Context, authClient client.AuthClient, tokenStore tokenstore.TokenStore, grantType auth.GrantType) (*auth.AuthSession, error) { - return login(ctx, authClient, tokenStore, grantType, true) +func ForceLogin(ctx context.Context, authClient client.AuthClient, tokenStore tokenstore.TokenStore, grantType auth.GrantType, mcpServerSession *mcp.ServerSession) (*auth.AuthSession, error) { + return login(ctx, authClient, tokenStore, grantType, true, mcpServerSession) } // Login with the given authClient for the specified grant type. The resulting auth session // will be stored in the provided tokenStore. // If a valid session already exists, it will be returned without re-authenticating. -func LoginIfNecessary(ctx context.Context, authClient client.AuthClient, tokenStore tokenstore.TokenStore, grantType auth.GrantType) (*auth.AuthSession, error) { - return login(ctx, authClient, tokenStore, grantType, false) +func LoginIfNecessary(ctx context.Context, authClient client.AuthClient, tokenStore tokenstore.TokenStore, grantType auth.GrantType, mcpServerSession *mcp.ServerSession) (*auth.AuthSession, error) { + return login(ctx, authClient, tokenStore, grantType, false, mcpServerSession) } -func login(ctx context.Context, authClient client.AuthClient, tokenStore tokenstore.TokenStore, grantType auth.GrantType, forceReAuth bool) (*auth.AuthSession, error) { +func login(ctx context.Context, authClient client.AuthClient, tokenStore tokenstore.TokenStore, grantType auth.GrantType, forceReAuth bool, mcpServerSession *mcp.ServerSession) (*auth.AuthSession, error) { hasSession, err := tokenStore.HasSession() if err != nil { return nil, err @@ -64,7 +65,7 @@ func login(ctx context.Context, authClient client.AuthClient, tokenStore tokenst authCtx, cancel := context.WithTimeout(ctx, authTimeout) defer cancel() - tokenSource, err := authClient.TokenSource(authCtx, grantType) + tokenSource, err := authClient.TokenSource(authCtx, grantType, mcpServerSession) if err != nil { if errors.Is(err, context.DeadlineExceeded) { return nil, fmt.Errorf("authentication timed out after %v", authTimeout) diff --git a/internal/auth/middleware/middleware.go b/internal/auth/middleware/middleware.go index df7f4c6..8e913e4 100644 --- a/internal/auth/middleware/middleware.go +++ b/internal/auth/middleware/middleware.go @@ -67,7 +67,7 @@ func (m *AuthMiddleware) Handler(next mcp.MethodHandler) mcp.MethodHandler { slog.String("tool", toolName)) // Initialize auth context using the same logic as individual tool handlers - initializeAuthContext := initialize.AuthContextInitializer(m.authClientFactory, m.tokenStore, m.grantType) + initializeAuthContext := initialize.AuthContextInitializer(callToolReq.Session, m.authClientFactory, m.tokenStore, m.grantType) authenticatedCtx, err := initializeAuthContext(ctx) if err != nil { logger.FromContext(ctx).Error("Authentication initialization failed", diff --git a/internal/tools/initialize/auth_context.go b/internal/tools/initialize/auth_context.go index 2d6b8f0..9dac746 100644 --- a/internal/tools/initialize/auth_context.go +++ b/internal/tools/initialize/auth_context.go @@ -7,6 +7,7 @@ import ( "fmt" "log/slog" + "github.com/modelcontextprotocol/go-sdk/mcp" "github.com/pingidentity/pingone-mcp-server/internal/audit" "github.com/pingidentity/pingone-mcp-server/internal/auth" "github.com/pingidentity/pingone-mcp-server/internal/auth/client" @@ -17,38 +18,30 @@ import ( type ContextInitializer func(ctx context.Context) (context.Context, error) -func AuthContextInitializer(authClientFactory client.AuthClientFactory, tokenStore tokenstore.TokenStore, grantType auth.GrantType) func(ctx context.Context) (context.Context, error) { +func AuthContextInitializer(mcpServerSession *mcp.ServerSession, authClientFactory client.AuthClientFactory, tokenStore tokenstore.TokenStore, grantType auth.GrantType) func(ctx context.Context) (context.Context, error) { return func(ctx context.Context) (context.Context, error) { authClient, err := authClientFactory.NewAuthClient() if err != nil { return nil, fmt.Errorf("failed to create auth client: %w", err) } - return InitializeAuthContext(ctx, authClient, tokenStore, grantType) + return InitializeAuthContext(ctx, mcpServerSession, authClient, tokenStore, grantType) } } -func InitializeAuthContext(ctx context.Context, authClient client.AuthClient, tokenStore tokenstore.TokenStore, grantType auth.GrantType) (context.Context, error) { +func InitializeAuthContext(ctx context.Context, mcpServerSession *mcp.ServerSession, authClient client.AuthClient, tokenStore tokenstore.TokenStore, grantType auth.GrantType) (context.Context, error) { var authSession *auth.AuthSession var err error - // If browser login is available, we can attempt to auto-login if no valid session exists - if authClient.BrowserLoginAvailable(grantType) { - authSession, err = login.LoginIfNecessary(ctx, authClient, tokenStore, grantType) - if err != nil { - return nil, fmt.Errorf("failed to login: %w", err) - } - } else { - hasSession, err := tokenStore.HasSession() - if err != nil { - return nil, fmt.Errorf("failed to check for auth session: %w", err) - } - if !hasSession { - return nil, fmt.Errorf("no active auth session found and a browser can't be used for login. Unable to authenticate") - } - authSession, err = tokenStore.GetSession() - if err != nil { - return nil, fmt.Errorf("failed to get auth session: %w", err) - } + + // If the browser login is not available, and the grant type is not device code, return an error + if !authClient.BrowserLoginAvailable(grantType) && grantType != auth.GrantTypeDeviceCode { + return nil, fmt.Errorf("browser login is not available in this environment and grant type %s cannot be used. Use %s grant type instead for headless auth", grantType, auth.GrantTypeDeviceCode.String()) } + + authSession, err = login.LoginIfNecessary(ctx, authClient, tokenStore, grantType, mcpServerSession) + if err != nil { + return nil, fmt.Errorf("failed to login: %w", err) + } + ctx = audit.ContextWithSessionId(ctx, authSession.SessionId) return logger.ContextWithLogger(ctx, logger.FromContext(ctx).With(slog.String("sessionId", authSession.SessionId))), nil }