Skip to content
Draft
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
37 changes: 37 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -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"]
9 changes: 9 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
@@ -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
5 changes: 5 additions & 0 deletions docker-entrypoint.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
#!/bin/sh
set -e

echo "Starting MCP server..."
exec ./pingone-mcp-server run --grant-type=device_code --store-type=file "$@"
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 4 additions & 2 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand Down Expand Up @@ -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=
Expand Down
3 changes: 2 additions & 1 deletion internal/auth/client/interface.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
43 changes: 36 additions & 7 deletions internal/auth/client/wrapper.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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
Expand All @@ -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))
Expand All @@ -73,14 +78,19 @@ 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
canOpenBrowser := browser.CanOpen()

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{}
Expand Down Expand Up @@ -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...")
Expand Down Expand Up @@ -170,6 +197,8 @@ func (p *PingOneClientAuthWrapper) configureHeadlessHandlers(ctx context.Context
Message: "An error occurred.",
}
}

return nil
}

type PingOneClientAuthWrapperFactory struct {
Expand Down
13 changes: 7 additions & 6 deletions internal/auth/login/login.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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
Expand Down Expand Up @@ -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)
Expand Down
2 changes: 1 addition & 1 deletion internal/auth/middleware/middleware.go
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
35 changes: 14 additions & 21 deletions internal/tools/initialize/auth_context.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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
}
Loading