diff --git a/Dockerfile.local b/Dockerfile.local new file mode 100644 index 000000000..a35560c40 --- /dev/null +++ b/Dockerfile.local @@ -0,0 +1,15 @@ +# Used by scripts/build-mcp-image.sh — assumes the mcp-grafana binary has +# already been cross-compiled into the build context. Mirrors the altinity-mcp +# pattern so the same per-arch + manifest workflow applies. Upstream's +# Dockerfile (multi-stage with in-container `go build`) is unchanged. + +FROM alpine:latest + +RUN apk --no-cache add ca-certificates curl bash + +WORKDIR /bin/ +COPY mcp-grafana . + +EXPOSE 8000 + +ENTRYPOINT ["/bin/mcp-grafana"] diff --git a/cmd/mcp-grafana/main.go b/cmd/mcp-grafana/main.go index e7b8704cb..d0891abd7 100644 --- a/cmd/mcp-grafana/main.go +++ b/cmd/mcp-grafana/main.go @@ -19,6 +19,7 @@ import ( mcpgrafana "github.com/grafana/mcp-grafana" "github.com/grafana/mcp-grafana/observability" + mcpgrafanaoauth "github.com/grafana/mcp-grafana/pkg/oauth" "github.com/grafana/mcp-grafana/tools" "go.opentelemetry.io/contrib/bridges/otelslog" "go.opentelemetry.io/otel/semconv/v1.40.0/mcpconv" @@ -331,6 +332,136 @@ func (tc *tlsConfig) addFlags() { flag.StringVar(&tc.keyFile, "server.tls-key-file", "", "Path to TLS private key file for server HTTPS (required for TLS)") } +// oauthFlags holds the OAuth command-line surface. Secret-bearing fields +// (client secret, signing secret) are read from files / env only — never +// accepted on the command line — so they don't leak into `ps eww`, shell +// history, or container metadata. +type oauthFlags struct { + enabled bool + issuer string + jwksURL string + audience string + clientID string + clientSecretFile string + authURL string + tokenURL string + signingSecretFile string + scopes string + requiredScopes string + allowedEmailDomains string + allowedHostedDomains string + publicResourceURL string + publicAuthServerURL string + upstreamOfflineAccess bool +} + +func (o *oauthFlags) addFlags() { + flag.BoolVar(&o.enabled, "oauth-enabled", envBool("MCP_OAUTH_ENABLED", false), + "Enable forward-mode OAuth broker (validates inbound bearers, mounts /oauth/* + discovery, forwards JWT to Grafana as X-JWT-Assertion).") + flag.StringVar(&o.issuer, "oauth-issuer", os.Getenv("MCP_OAUTH_ISSUER"), + "Upstream IdP issuer URL. JWKS, /authorize, /token are discovered from this unless overridden.") + flag.StringVar(&o.jwksURL, "oauth-jwks-url", os.Getenv("MCP_OAUTH_JWKS_URL"), + "Override for the upstream JWKS URL when discovery from --oauth-issuer is unavailable.") + flag.StringVar(&o.audience, "oauth-audience", os.Getenv("MCP_OAUTH_AUDIENCE"), + "Expected `aud` claim in inbound bearers (RFC 8707). Set to the canonical external URL of this mcp-grafana deployment.") + flag.StringVar(&o.clientID, "oauth-client-id", os.Getenv("MCP_OAUTH_CLIENT_ID"), + "OAuth client_id used by the broker against the upstream IdP.") + flag.StringVar(&o.clientSecretFile, "oauth-client-secret-file", os.Getenv("MCP_OAUTH_CLIENT_SECRET_FILE"), + "Path to a file containing the upstream OAuth client secret. Required when --oauth-enabled.") + flag.StringVar(&o.authURL, "oauth-auth-url", os.Getenv("MCP_OAUTH_AUTH_URL"), + "Override for the upstream /authorize endpoint.") + flag.StringVar(&o.tokenURL, "oauth-token-url", os.Getenv("MCP_OAUTH_TOKEN_URL"), + "Override for the upstream /token endpoint.") + flag.StringVar(&o.signingSecretFile, "oauth-signing-secret-file", os.Getenv("MCP_OAUTH_SIGNING_SECRET_FILE"), + "Path to a file containing the HKDF master secret used to derive JWE keys for stateless auth-code/pending-auth artifacts. Required when --oauth-enabled; >=32 bytes.") + flag.StringVar(&o.scopes, "oauth-scopes", envOr("MCP_OAUTH_SCOPES", "openid,email,profile"), + "Comma-separated scopes requested from the upstream IdP at /authorize.") + flag.StringVar(&o.requiredScopes, "oauth-required-scopes", os.Getenv("MCP_OAUTH_REQUIRED_SCOPES"), + "Comma-separated scopes the inbound bearer must carry to pass validation. Empty disables the check.") + flag.StringVar(&o.allowedEmailDomains, "oauth-allowed-email-domains", os.Getenv("MCP_OAUTH_ALLOWED_EMAIL_DOMAINS"), + "Comma-separated allowlist of email domains. Empty disables the check.") + flag.StringVar(&o.allowedHostedDomains, "oauth-allowed-hosted-domains", os.Getenv("MCP_OAUTH_ALLOWED_HOSTED_DOMAINS"), + "Comma-separated allowlist of Google-style `hd` workspace domains. Empty disables the check.") + flag.StringVar(&o.publicResourceURL, "oauth-public-resource-url", os.Getenv("MCP_OAUTH_PUBLIC_RESOURCE_URL"), + "External base URL advertised in /.well-known/oauth-protected-resource. Set when this server runs behind a path-prefixing ingress.") + flag.StringVar(&o.publicAuthServerURL, "oauth-public-auth-server-url", os.Getenv("MCP_OAUTH_PUBLIC_AUTH_SERVER_URL"), + "External base URL advertised in /.well-known/oauth-authorization-server. Set when this server runs behind a path-prefixing ingress.") + flag.BoolVar(&o.upstreamOfflineAccess, "oauth-upstream-offline-access", envBool("MCP_OAUTH_UPSTREAM_OFFLINE_ACCESS", false), + "Request offline_access / access_type=offline from the upstream IdP so the broker can refresh near-expired id_tokens at /token.") +} + +func envOr(key, fallback string) string { + if v := os.Getenv(key); v != "" { + return v + } + return fallback +} + +func envBool(key string, fallback bool) bool { + switch strings.ToLower(os.Getenv(key)) { + case "1", "true", "yes", "on": + return true + case "0", "false", "no", "off": + return false + } + return fallback +} + +// toSettings reads secret files and assembles the adapter Settings. +// Returns Settings{Enabled:false} when the broker is disabled. +func (o *oauthFlags) toSettings() (mcpgrafanaoauth.Settings, error) { + if !o.enabled { + return mcpgrafanaoauth.Settings{}, nil + } + if o.clientSecretFile == "" { + return mcpgrafanaoauth.Settings{}, fmt.Errorf("--oauth-client-secret-file is required when --oauth-enabled") + } + if o.signingSecretFile == "" { + return mcpgrafanaoauth.Settings{}, fmt.Errorf("--oauth-signing-secret-file is required when --oauth-enabled") + } + secretBytes, err := os.ReadFile(o.clientSecretFile) + if err != nil { + return mcpgrafanaoauth.Settings{}, fmt.Errorf("read --oauth-client-secret-file: %w", err) + } + signingBytes, err := os.ReadFile(o.signingSecretFile) + if err != nil { + return mcpgrafanaoauth.Settings{}, fmt.Errorf("read --oauth-signing-secret-file: %w", err) + } + return mcpgrafanaoauth.Settings{ + Enabled: true, + Issuer: o.issuer, + JWKSURL: o.jwksURL, + AuthURL: o.authURL, + TokenURL: o.tokenURL, + Audience: o.audience, + ClientID: o.clientID, + ClientSecret: strings.TrimSpace(string(secretBytes)), + SigningSecret: []byte(strings.TrimSpace(string(signingBytes))), + Scopes: splitCSV(o.scopes), + RequiredScopes: splitCSV(o.requiredScopes), + AllowedEmailDomains: splitCSV(o.allowedEmailDomains), + AllowedHostedDomains: splitCSV(o.allowedHostedDomains), + PublicResourceURL: o.publicResourceURL, + PublicAuthServerURL: o.publicAuthServerURL, + UpstreamOfflineAccess: o.upstreamOfflineAccess, + }, nil +} + +func splitCSV(s string) []string { + if strings.TrimSpace(s) == "" { + return nil + } + parts := strings.Split(s, ",") + out := make([]string, 0, len(parts)) + for _, p := range parts { + p = strings.TrimSpace(p) + if p != "" { + out = append(out, p) + } + } + return out +} + // httpServer represents a server with Start and Shutdown methods type httpServer interface { Start(addr string) error @@ -394,7 +525,7 @@ func runMetricsServer(addr string, o *observability.Observability) { } } -func run(transport, addr, basePath, endpointPath string, logLevel slog.Level, dt disabledTools, gc mcpgrafana.GrafanaConfig, tls tlsConfig, obs observability.Config, sessionIdleTimeoutMinutes int) error { +func run(transport, addr, basePath, endpointPath string, logLevel slog.Level, dt disabledTools, gc mcpgrafana.GrafanaConfig, tls tlsConfig, oauthSettings mcpgrafanaoauth.Settings, obs observability.Config, sessionIdleTimeoutMinutes int) error { stderrHandler := slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: logLevel}) slog.SetDefault(slog.New(stderrHandler)) @@ -432,6 +563,18 @@ func run(transport, addr, basePath, endpointPath string, logLevel slog.Level, dt s, tm, sm := newServer(transport, dt, o, sessionIdleTimeoutMinutes) defer sm.Close() + // Build the OAuth broker once and reuse it across SSE/streamable-http + // branches. Returns nil when oauthSettings.Enabled is false, in which + // case all OAuth wiring downstream is skipped. + broker, err := mcpgrafanaoauth.NewBroker(oauthSettings) + if err != nil { + return fmt.Errorf("oauth: %w", err) + } + if broker != nil && transport == "stdio" { + slog.Warn("--oauth-enabled has no effect with stdio transport (no HTTP surface); ignoring") + broker = nil + } + // Create a context that will be cancelled on shutdown ctx, cancel := context.WithCancel(context.Background()) defer cancel() @@ -478,8 +621,15 @@ func run(transport, addr, basePath, endpointPath string, logLevel slog.Level, dt case "sse": httpSrv := &http.Server{Addr: addr} + sseCtxFunc := mcpgrafana.ComposedSSEContextFunc(gc, clientCache) + if broker != nil { + base := sseCtxFunc + sseCtxFunc = func(ctx context.Context, req *http.Request) context.Context { + return mcpgrafanaoauth.PropagateIdentity(base(ctx, req), req) + } + } srv := server.NewSSEServer(s, - server.WithSSEContextFunc(mcpgrafana.ComposedSSEContextFunc(gc, clientCache)), + server.WithSSEContextFunc(sseCtxFunc), server.WithStaticBasePath(basePath), server.WithHTTPServer(httpSrv), ) @@ -487,10 +637,12 @@ func run(transport, addr, basePath, endpointPath string, logLevel slog.Level, dt if basePath == "" { basePath = "/" } - mux.Handle(basePath, observability.WrapHandler( - mcpgrafana.ValidateGrafanaURLMiddleware(srv), - basePath, - )) + var inner http.Handler = mcpgrafana.ValidateGrafanaURLMiddleware(srv) + if broker != nil { + inner = broker.Middleware(inner) + broker.RegisterRoutes(mux) + } + mux.Handle(basePath, observability.WrapHandler(inner, basePath)) mux.HandleFunc("/healthz", handleHealthz) if obs.MetricsEnabled { if obs.MetricsAddress == "" { @@ -505,8 +657,15 @@ func run(transport, addr, basePath, endpointPath string, logLevel slog.Level, dt return runHTTPServer(ctx, srv, addr, "SSE") case "streamable-http": httpSrv := &http.Server{Addr: addr} + httpCtxFunc := mcpgrafana.ComposedHTTPContextFunc(gc, clientCache) + if broker != nil { + base := httpCtxFunc + httpCtxFunc = func(ctx context.Context, req *http.Request) context.Context { + return mcpgrafanaoauth.PropagateIdentity(base(ctx, req), req) + } + } opts := []server.StreamableHTTPOption{ - server.WithHTTPContextFunc(mcpgrafana.ComposedHTTPContextFunc(gc, clientCache)), + server.WithHTTPContextFunc(httpCtxFunc), server.WithStateLess(dt.proxied), // Stateful when proxied tools enabled (requires sessions) server.WithEndpointPath(endpointPath), server.WithStreamableHTTPServer(httpSrv), @@ -516,10 +675,12 @@ func run(transport, addr, basePath, endpointPath string, logLevel slog.Level, dt } srv := server.NewStreamableHTTPServer(s, opts...) mux := http.NewServeMux() - mux.Handle(endpointPath, observability.WrapHandler( - mcpgrafana.ValidateGrafanaURLMiddleware(srv), - endpointPath, - )) + var inner http.Handler = mcpgrafana.ValidateGrafanaURLMiddleware(srv) + if broker != nil { + inner = broker.Middleware(inner) + broker.RegisterRoutes(mux) + } + mux.Handle(endpointPath, observability.WrapHandler(inner, endpointPath)) mux.HandleFunc("/healthz", handleHealthz) if obs.MetricsEnabled { if obs.MetricsAddress == "" { @@ -558,6 +719,8 @@ func main() { gc.addFlags() var tls tlsConfig tls.addFlags() + var oauth oauthFlags + oauth.addFlags() var obs observability.Config flag.BoolVar(&obs.MetricsEnabled, "metrics", false, "Enable Prometheus metrics endpoint") flag.StringVar(&obs.MetricsAddress, "metrics-address", "", "Separate address for metrics server (e.g., :9090). If empty, metrics are served on the main server at /metrics") @@ -608,7 +771,13 @@ func main() { obs.NetworkTransport = mcpconv.NetworkTransportTCP } - if err := run(transport, *addr, *basePath, *endpointPath, parseLevel(*logLevel), dt, grafanaConfig, tls, obs, *sessionIdleTimeoutMinutes); err != nil { + oauthSettings, err := oauth.toSettings() + if err != nil { + fmt.Fprintf(os.Stderr, "oauth: %v\n", err) + os.Exit(2) + } + + if err := run(transport, *addr, *basePath, *endpointPath, parseLevel(*logLevel), dt, grafanaConfig, tls, oauthSettings, obs, *sessionIdleTimeoutMinutes); err != nil { panic(err) } } diff --git a/go.mod b/go.mod index fa5e930c8..35f206404 100644 --- a/go.mod +++ b/go.mod @@ -4,6 +4,7 @@ go 1.26.1 require ( connectrpc.com/connect v1.19.1 + github.com/BorisTyshkevich/go-mcp-oauth v0.0.0-00010101000000-000000000000 github.com/PaesslerAG/gval v1.2.4 github.com/PaesslerAG/jsonpath v0.1.1 github.com/go-openapi/runtime v0.29.3 @@ -15,6 +16,7 @@ require ( github.com/grafana/incident-go v0.0.0-20251003115753-d71681611ddd github.com/grafana/pyroscope/api v1.3.2 github.com/invopop/jsonschema v0.13.0 + github.com/itchyny/gojq v0.12.19 github.com/mark3labs/mcp-go v0.46.0 github.com/prometheus/alertmanager v0.31.1 github.com/prometheus/client_golang v1.23.2 @@ -33,7 +35,7 @@ require ( go.opentelemetry.io/otel/sdk/metric v1.43.0 go.opentelemetry.io/otel/trace v1.43.0 golang.org/x/sync v0.20.0 - golang.org/x/tools v0.43.0 + golang.org/x/tools v0.44.0 gopkg.in/yaml.v3 v3.0.1 ) @@ -66,6 +68,7 @@ require ( github.com/dennwc/varint v1.0.0 // indirect github.com/fatih/color v1.18.0 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect + github.com/go-jose/go-jose/v4 v4.1.4 // indirect github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/go-openapi/analysis v0.24.3 // indirect @@ -98,7 +101,7 @@ require ( github.com/google/gnostic-models v0.7.0 // indirect github.com/google/go-cmp v0.7.0 // indirect github.com/google/go-querystring v1.2.0 // indirect - github.com/google/jsonschema-go v0.4.2 // indirect + github.com/google/jsonschema-go v0.4.3 // indirect github.com/gorilla/mux v1.8.1 // indirect github.com/grafana/otel-profiling-go v0.5.1 // indirect github.com/grafana/pyroscope-go/godeltaprof v0.1.9 // indirect @@ -111,7 +114,6 @@ require ( github.com/hashicorp/go-plugin v1.7.0 // indirect github.com/hashicorp/go-retryablehttp v0.7.8 // indirect github.com/hashicorp/yamux v0.1.2 // indirect - github.com/itchyny/gojq v0.12.19 // indirect github.com/itchyny/timefmt-go v0.1.8 // indirect github.com/jaegertracing/jaeger-idl v0.6.0 // indirect github.com/jpillora/backoff v1.0.0 // indirect @@ -124,6 +126,7 @@ require ( github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-runewidth v0.0.19 // indirect + github.com/modelcontextprotocol/go-sdk v1.6.0 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect @@ -141,6 +144,8 @@ require ( github.com/prometheus/otlptranslator v1.0.0 // indirect github.com/prometheus/procfs v0.19.2 // indirect github.com/prometheus/sigv4 v0.4.1 // indirect + github.com/segmentio/asm v1.1.3 // indirect + github.com/segmentio/encoding v0.5.4 // indirect github.com/shopspring/decimal v1.4.0 // indirect github.com/spf13/cast v1.10.0 // indirect github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect @@ -157,13 +162,14 @@ require ( go.uber.org/atomic v1.11.0 // indirect go.yaml.in/yaml/v2 v2.4.4 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect + golang.org/x/crypto v0.51.0 // indirect golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa // indirect - golang.org/x/mod v0.34.0 // indirect - golang.org/x/net v0.53.0 // indirect + golang.org/x/mod v0.35.0 // indirect + golang.org/x/net v0.54.0 // indirect golang.org/x/oauth2 v0.36.0 // indirect - golang.org/x/sys v0.43.0 // indirect - golang.org/x/telemetry v0.0.0-20260311193753-579e4da9a98c // indirect - golang.org/x/text v0.36.0 // indirect + golang.org/x/sys v0.44.0 // indirect + golang.org/x/telemetry v0.0.0-20260409153401-be6f6cb8b1fa // indirect + golang.org/x/text v0.37.0 // indirect golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da // indirect google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 // indirect @@ -171,3 +177,7 @@ require ( google.golang.org/protobuf v1.36.11 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect ) + +// Local checkout while mcp-oauth is iterated alongside grafana-mcp. +// Drop once the library has a tagged release. +replace github.com/BorisTyshkevich/go-mcp-oauth => ../mcp-oauth diff --git a/go.sum b/go.sum index 852734df6..cc53d13f3 100644 --- a/go.sum +++ b/go.sum @@ -92,6 +92,8 @@ github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2 github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= +github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA= +github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= github.com/go-logr/logr v1.3.0/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= @@ -174,6 +176,8 @@ github.com/google/go-querystring v1.2.0/go.mod h1:8IFJqpSRITyJ8QhQ13bmbeMBDfmeEJ github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/jsonschema-go v0.4.2 h1:tmrUohrwoLZZS/P3x7ex0WAVknEkBZM46iALbcqoRA8= github.com/google/jsonschema-go v0.4.2/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE= +github.com/google/jsonschema-go v0.4.3 h1:/DBOLZTfDow7pe2GmaJNhltueGTtDKICi8V8p+DQPd0= +github.com/google/jsonschema-go v0.4.3/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE= github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0= github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= @@ -270,6 +274,8 @@ github.com/minio/c2goasm v0.0.0-20190812172519-36a3d3bbc4f3 h1:+n/aFZefKZp7spd8D github.com/minio/c2goasm v0.0.0-20190812172519-36a3d3bbc4f3/go.mod h1:RagcQ7I8IeTMnF8JTXieKnO4Z6JCsikNEzj0DwauVzE= github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ= github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw= +github.com/modelcontextprotocol/go-sdk v1.6.0 h1:PPLS3kn7WtOEnR+Af4X5H96SG0qSab8R/ZQT/HkhPkY= +github.com/modelcontextprotocol/go-sdk v1.6.0/go.mod h1:kzm3kzFL1/+AziGOE0nUs3gvPoNxMCvkxokMkuFapXQ= 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= @@ -322,6 +328,10 @@ github.com/prometheus/sigv4 v0.4.1 h1:EIc3j+8NBea9u1iV6O5ZAN8uvPq2xOIUPcqCTivHuX github.com/prometheus/sigv4 v0.4.1/go.mod h1:eu+ZbRvsc5TPiHwqh77OWuCnWK73IdkETYY46P4dXOU= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/segmentio/asm v1.1.3 h1:WM03sfUOENvvKexOLp+pCqgb/WDjsi7EK8gIsICtzhc= +github.com/segmentio/asm v1.1.3/go.mod h1:Ld3L4ZXGNcSLRg4JBsZ3//1+f/TjYl0Mzen/DQy1EJg= +github.com/segmentio/encoding v0.5.4 h1:OW1VRern8Nw6ITAtwSZ7Idrl3MXCFwXHPgqESYfvNt0= +github.com/segmentio/encoding v0.5.4/go.mod h1:HS1ZKa3kSN32ZHVZ7ZLPLXWvOVIiZtyJnO1gPH1sKt0= github.com/shopspring/decimal v1.3.1/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFRcu2hWCYk4o= github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k= github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME= @@ -404,6 +414,8 @@ golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8U golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4= golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA= +golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI= +golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8= golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa h1:Zt3DZoOFFYkKhDT3v7Lm9FDMEV06GpzjG2jrqW+QTE0= golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa/go.mod h1:K79w1Vqn7PoiZn+TkNpx3BUWUQksGO3JcVX6qIjytmA= golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= @@ -412,6 +424,7 @@ golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8= golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w= golang.org/x/mod v0.34.0 h1:xIHgNUUnW6sYkcM5Jleh05DvLOtwc6RitGHbDk4akRI= golang.org/x/mod v0.34.0/go.mod h1:ykgH52iCZe79kzLLMhyCUzhMci+nQj+0XkbXpNYtVjY= +golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= @@ -420,6 +433,8 @@ golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0= golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw= golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA= golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs= +golang.org/x/net v0.54.0 h1:2zJIZAxAHV/OHCDTCOHAYehQzLfSXuf/5SoL/Dv6w/w= +golang.org/x/net v0.54.0/go.mod h1:Sj4oj8jK6XmHpBZU/zWHw3BV3abl4Kvi+Ut7cQcY+cQ= golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -441,16 +456,21 @@ golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI= golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ= +golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/telemetry v0.0.0-20260209163413-e7419c687ee4 h1:bTLqdHv7xrGlFbvf5/TXNxy/iUwwdkjhqQTJDjW7aj0= golang.org/x/telemetry v0.0.0-20260209163413-e7419c687ee4/go.mod h1:g5NllXBEermZrmR51cJDQxmJUHUOfRAaNyWBM+R+548= golang.org/x/telemetry v0.0.0-20260311193753-579e4da9a98c h1:6a8FdnNk6bTXBjR4AGKFgUKuo+7GnR3FX5L7CbveeZc= golang.org/x/telemetry v0.0.0-20260311193753-579e4da9a98c/go.mod h1:TpUTTEp9frx7rTdLpC9gFG9kdI7zVLFTFFlqaH2Cncw= +golang.org/x/telemetry v0.0.0-20260409153401-be6f6cb8b1fa/go.mod h1:kHjTxDEnAu6/Nl9lDkzjWpR+bmKfxeiRuSDlsMb70gE= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8= golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA= golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg= golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164= +golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= +golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= @@ -461,6 +481,7 @@ 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/tools v0.43.0 h1:12BdW9CeB3Z+J/I/wj34VMl8X+fEXBxVR90JeMX5E7s= golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0= +golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI= 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= diff --git a/mcpgrafana.go b/mcpgrafana.go index d3d3193b7..e1d7f8d19 100644 --- a/mcpgrafana.go +++ b/mcpgrafana.go @@ -241,6 +241,13 @@ type GrafanaConfig struct { // It is used for on-behalf-of auth in Grafana Cloud. IDToken string + // JWTAssertion is the raw inbound OAuth bearer (validated by the + // mcp-oauth broker middleware) that should be forwarded to Grafana as + // the X-JWT-Assertion header. Grafana then validates it independently + // via [auth.jwt] and maps it to a user. Empty when OAuth is disabled + // or the request arrived without a bearer. + JWTAssertion string + // TLSConfig holds TLS configuration for all Grafana clients. TLSConfig *TLSConfig @@ -540,12 +547,47 @@ func NewAuthRoundTripper(rt http.RoundTripper, accessToken, idToken, apiKey stri } } +// JWTAssertionRoundTripper sets the X-JWT-Assertion header from the raw +// inbound OAuth bearer when one is present on the request context's +// GrafanaConfig. It is purely additive — it does not touch Authorization +// or any other auth header, so the existing service-account-token bearer +// continues to authorise the call and Grafana's [auth.jwt] block can +// independently validate the assertion to derive the user identity. +type JWTAssertionRoundTripper struct { + assertion string + underlying http.RoundTripper +} + +func (rt *JWTAssertionRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) { + assertion := rt.assertion + if cfg := GrafanaConfigFromContext(req.Context()); cfg.JWTAssertion != "" { + assertion = cfg.JWTAssertion + } + if assertion == "" { + return rt.underlying.RoundTrip(req) + } + clonedReq := req.Clone(req.Context()) + clonedReq.Header.Set("X-JWT-Assertion", assertion) + return rt.underlying.RoundTrip(clonedReq) +} + +func NewJWTAssertionRoundTripper(rt http.RoundTripper, assertion string) *JWTAssertionRoundTripper { + if rt == nil { + rt = http.DefaultTransport + } + return &JWTAssertionRoundTripper{ + assertion: assertion, + underlying: rt, + } +} + // transportOptions controls which middleware layers BuildTransport includes. type transportOptions struct { - withoutAuth bool - withoutOrgID bool - withoutOtel bool - withoutUserAgent bool + withoutAuth bool + withoutOrgID bool + withoutOtel bool + withoutUserAgent bool + withoutJWTAssertion bool } // TransportOption configures optional behaviour of BuildTransport. @@ -572,6 +614,14 @@ func WithoutUserAgent() TransportOption { return func(o *transportOptions) { o.withoutUserAgent = true } } +// WithoutJWTAssertion skips the X-JWT-Assertion header layer. +// Use this for clients that talk to backends that don't honour the +// assertion (e.g., the incident / on-call HTTP clients that already +// pass WithoutAuth). +func WithoutJWTAssertion() TransportOption { + return func(o *transportOptions) { o.withoutJWTAssertion = true } +} + // BuildTransport constructs an http.RoundTripper with the standard middleware // chain derived from cfg. The default chain (innermost to outermost) is: // @@ -610,6 +660,13 @@ func BuildTransport(cfg *GrafanaConfig, base http.RoundTripper, opts ...Transpor transport = NewAuthRoundTripper(transport, cfg.AccessToken, cfg.IDToken, cfg.APIKey, cfg.BasicAuth) } + // X-JWT-Assertion (additive — does not interfere with Authorization + // set by AuthRoundTripper; lets Grafana's [auth.jwt] independently + // authenticate the user while the SA token remains the API credential). + if !options.withoutJWTAssertion { + transport = NewJWTAssertionRoundTripper(transport, cfg.JWTAssertion) + } + // Extra headers (always included so per-request context overrides work) transport = NewExtraHeadersRoundTripper(transport, cfg.ExtraHeaders) diff --git a/mcpgrafana_test.go b/mcpgrafana_test.go index e8b1bbcbd..581d3913b 100644 --- a/mcpgrafana_test.go +++ b/mcpgrafana_test.go @@ -935,6 +935,73 @@ func TestAuthRoundTripper(t *testing.T) { }) } +func TestJWTAssertionRoundTripper(t *testing.T) { + t.Run("sets X-JWT-Assertion when assertion configured", func(t *testing.T) { + var capturedReq *http.Request + mock := &capturingMockRT{fn: func(req *http.Request) (*http.Response, error) { + capturedReq = req + return &http.Response{StatusCode: 200}, nil + }} + + rt := NewJWTAssertionRoundTripper(mock, "eyJhbGciOiJSUzI1NiJ9.payload.sig") + req, _ := http.NewRequest("GET", "http://example.com", nil) + _, err := rt.RoundTrip(req) + require.NoError(t, err) + + assert.Equal(t, "eyJhbGciOiJSUzI1NiJ9.payload.sig", capturedReq.Header.Get("X-JWT-Assertion")) + assert.Empty(t, capturedReq.Header.Get("Authorization"), "should not touch Authorization") + }) + + t.Run("per-request context overrides configured assertion", func(t *testing.T) { + var capturedReq *http.Request + mock := &capturingMockRT{fn: func(req *http.Request) (*http.Response, error) { + capturedReq = req + return &http.Response{StatusCode: 200}, nil + }} + + rt := NewJWTAssertionRoundTripper(mock, "boot-time-assertion") + ctx := WithGrafanaConfig(context.Background(), GrafanaConfig{JWTAssertion: "per-request-assertion"}) + req, _ := http.NewRequestWithContext(ctx, "GET", "http://example.com", nil) + _, err := rt.RoundTrip(req) + require.NoError(t, err) + + assert.Equal(t, "per-request-assertion", capturedReq.Header.Get("X-JWT-Assertion")) + }) + + t.Run("no header set when assertion empty", func(t *testing.T) { + var capturedReq *http.Request + mock := &capturingMockRT{fn: func(req *http.Request) (*http.Response, error) { + capturedReq = req + return &http.Response{StatusCode: 200}, nil + }} + + rt := NewJWTAssertionRoundTripper(mock, "") + req, _ := http.NewRequest("GET", "http://example.com", nil) + _, err := rt.RoundTrip(req) + require.NoError(t, err) + + assert.Empty(t, capturedReq.Header.Get("X-JWT-Assertion")) + }) + + t.Run("does not modify original request", func(t *testing.T) { + mock := &capturingMockRT{fn: func(req *http.Request) (*http.Response, error) { + return &http.Response{StatusCode: 200}, nil + }} + + rt := NewJWTAssertionRoundTripper(mock, "an-assertion") + req, _ := http.NewRequest("GET", "http://example.com", nil) + _, err := rt.RoundTrip(req) + require.NoError(t, err) + + assert.Empty(t, req.Header.Get("X-JWT-Assertion")) + }) + + t.Run("nil transport uses default", func(t *testing.T) { + rt := NewJWTAssertionRoundTripper(nil, "") + assert.NotNil(t, rt.underlying) + }) +} + func TestBuildTransport(t *testing.T) { t.Run("default chain sets all headers", func(t *testing.T) { var capturedReq *http.Request diff --git a/pkg/oauth/wiring.go b/pkg/oauth/wiring.go new file mode 100644 index 000000000..75270803a --- /dev/null +++ b/pkg/oauth/wiring.go @@ -0,0 +1,130 @@ +// Package oauth is the grafana-mcp adapter around +// github.com/BorisTyshkevich/go-mcp-oauth. It translates grafana-mcp's +// CLI/env configuration into mcp-oauth's Config, hands back the broker +// the caller mounts on its HTTP mux, and exposes the context-func that +// propagates a validated bearer through to the outbound Grafana request. +// +// We support forward mode only (matching altinity-mcp's antalya +// deployment shape). CIMD is the sole client-registration mechanism; +// /oauth/register returns HTTP 410 Gone via the broker. See the plan at +// .claude/plans/cimd-no-dcr-option-quirky-swing.md for context. +package oauth + +import ( + "context" + "fmt" + "net/http" + "strings" + + mcpoauth "github.com/BorisTyshkevich/go-mcp-oauth" + mcpgrafana "github.com/grafana/mcp-grafana" +) + +// Settings is the operator-facing OAuth config surface. It mirrors the +// subset of mcpoauth.Config needed for forward-mode broker operation; +// fields not exposed here are either gating-mode specific or rely on the +// library's defaults. +type Settings struct { + // Enabled toggles the entire OAuth stack. When false, NewBroker + // returns (nil, nil) and the rest of grafana-mcp behaves as before. + Enabled bool + + // Issuer is the upstream IdP issuer URL. JWKSURL, AuthURL and TokenURL + // are discovered from it via /.well-known/openid-configuration unless + // overridden explicitly. + Issuer string + + // JWKSURL, AuthURL, TokenURL override the corresponding endpoints + // when discovery cannot reach them (e.g., split-DNS deployments). + JWKSURL string + AuthURL string + TokenURL string + + // Audience is the expected `aud` claim in inbound bearers. Set to the + // canonical external URL the broker advertises in + // /.well-known/oauth-protected-resource (RFC 8707 byte-equality). + Audience string + + // ClientID and ClientSecret authenticate the broker to the upstream + // IdP during /token redemption. Required in forward mode. + ClientID string + ClientSecret string + + // SigningSecret is the HKDF master used to derive keys for the + // stateless JWE artifacts the broker mints (pending-auth, auth-code). + // Required in forward mode; must be >=32 bytes. Read from a file or + // env var — never the command line. + SigningSecret []byte + + // Scopes lists upstream scopes requested at /authorize. + Scopes []string + + // RequiredScopes lists scopes the inbound bearer must carry to pass + // validation. Empty means no scope gating. + RequiredScopes []string + + // AllowedEmailDomains / AllowedHostedDomains restrict accepted + // principals to specific domains. Empty means no domain restriction. + AllowedEmailDomains []string + AllowedHostedDomains []string + + // PublicResourceURL and PublicAuthServerURL override the URLs the + // broker advertises in its discovery metadata. Set these explicitly + // when grafana-mcp runs behind a path-prefixing ingress. + PublicResourceURL string + PublicAuthServerURL string + + // UpstreamOfflineAccess asks the upstream IdP for a refresh_token so + // the broker can extend near-expired id_tokens at /token. Required + // for sessions longer than the upstream id_token TTL. + UpstreamOfflineAccess bool +} + +// NewBroker constructs and validates an mcp-oauth broker from Settings. +// When s.Enabled is false it returns (nil, nil); callers must check the +// nil broker before mounting routes or wrapping middleware. +func NewBroker(s Settings) (*mcpoauth.Broker, error) { + if !s.Enabled { + return nil, nil + } + cfg := mcpoauth.Config{ + Mode: mcpoauth.ModeForward, + Issuer: strings.TrimSpace(s.Issuer), + JWKSURL: strings.TrimSpace(s.JWKSURL), + AuthURL: strings.TrimSpace(s.AuthURL), + TokenURL: strings.TrimSpace(s.TokenURL), + Audience: strings.TrimSpace(s.Audience), + ClientID: strings.TrimSpace(s.ClientID), + ClientSecret: s.ClientSecret, + SigningSecret: s.SigningSecret, + Scopes: s.Scopes, + RequiredScopes: s.RequiredScopes, + AllowedEmailDomains: s.AllowedEmailDomains, + AllowedHostedDomains: s.AllowedHostedDomains, + PublicResourceURL: strings.TrimSpace(s.PublicResourceURL), + PublicAuthServerURL: strings.TrimSpace(s.PublicAuthServerURL), + UpstreamOfflineAccess: s.UpstreamOfflineAccess, + } + broker, err := mcpoauth.New(cfg) + if err != nil { + return nil, fmt.Errorf("oauth: %w", err) + } + return broker, nil +} + +// PropagateIdentity copies the validated bearer from the request context +// (where mcp-oauth's Broker.Middleware put it) into the GrafanaConfig on +// the returned context, so that JWTAssertionRoundTripper picks it up +// when assembling the outbound Grafana request. +// +// This is an mcp-go HTTPContextFunc / SSEContextFunc — append it to the +// composed chain in cmd/mcp-grafana/main.go when OAuth is enabled. +func PropagateIdentity(ctx context.Context, r *http.Request) context.Context { + raw, ok := mcpoauth.RawTokenFromContext(r.Context()) + if !ok || raw == "" { + return ctx + } + cfg := mcpgrafana.GrafanaConfigFromContext(ctx) + cfg.JWTAssertion = raw + return mcpgrafana.WithGrafanaConfig(ctx, cfg) +} diff --git a/pkg/oauth/wiring_test.go b/pkg/oauth/wiring_test.go new file mode 100644 index 000000000..2c550f97e --- /dev/null +++ b/pkg/oauth/wiring_test.go @@ -0,0 +1,81 @@ +package oauth + +import ( + "context" + "net/http/httptest" + "testing" + + mcpgrafana "github.com/grafana/mcp-grafana" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestNewBroker_Disabled(t *testing.T) { + broker, err := NewBroker(Settings{Enabled: false}) + require.NoError(t, err) + assert.Nil(t, broker, "disabled settings must return nil broker so main.go skips wiring") +} + +func TestNewBroker_RejectsMissingRequiredFields(t *testing.T) { + cases := []struct { + name string + settings Settings + }{ + { + name: "missing client_id", + settings: Settings{Enabled: true, Issuer: "https://idp.example/", SigningSecret: bytes32()}, + }, + { + name: "missing issuer and auth/token urls", + settings: Settings{Enabled: true, ClientID: "abc", SigningSecret: bytes32()}, + }, + { + name: "missing signing_secret", + settings: Settings{Enabled: true, Issuer: "https://idp.example/", ClientID: "abc"}, + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + broker, err := NewBroker(tc.settings) + assert.Nil(t, broker) + assert.Error(t, err) + }) + } +} + +func TestNewBroker_ValidForwardMode(t *testing.T) { + broker, err := NewBroker(Settings{ + Enabled: true, + Issuer: "https://accounts.google.com", + ClientID: "client-id.apps.googleusercontent.com", + ClientSecret: "secret", + Audience: "https://mcp.example.com", + SigningSecret: bytes32(), + Scopes: []string{"openid", "email"}, + }) + require.NoError(t, err) + require.NotNil(t, broker) +} + +// TestPropagateIdentity_NoToken verifies the no-op path: when the request +// context has no validated bearer (e.g., OAuth disabled or middleware +// didn't run), PropagateIdentity must return the context unchanged and +// leave GrafanaConfig.JWTAssertion empty so the SA-token-only request +// path stays the default. +func TestPropagateIdentity_NoToken(t *testing.T) { + req := httptest.NewRequest("GET", "https://mcp.example.com/mcp", nil) + in := mcpgrafana.WithGrafanaConfig(context.Background(), mcpgrafana.GrafanaConfig{ + URL: "http://grafana.local", + APIKey: "sa-token", + }) + + out := PropagateIdentity(in, req) + + cfg := mcpgrafana.GrafanaConfigFromContext(out) + assert.Empty(t, cfg.JWTAssertion) + assert.Equal(t, "sa-token", cfg.APIKey, "SA token must remain untouched on the no-token path") +} + +func bytes32() []byte { + return []byte("0123456789abcdef0123456789abcdef") +} diff --git a/scripts/build-mcp-image.sh b/scripts/build-mcp-image.sh new file mode 100755 index 000000000..622ba29ca --- /dev/null +++ b/scripts/build-mcp-image.sh @@ -0,0 +1,118 @@ +#!/usr/bin/env bash +# build-mcp-image.sh — build & push a multi-arch grafana-mcp image from a +# local checkout. Mirrors altinity-mcp/scripts/build-mcp-image.sh so the +# same per-arch + manifest workflow applies; useful when iterating on a +# feature branch (e.g. the OAuth fork) without waiting on upstream CI. +# +# Why this isn't `docker buildx build --platform=linux/amd64,linux/arm64`: +# the sandbox docker proxy (/var/run/isolator-docker/altinity.sock) blocks +# the privileged container that buildkit boots. We fall back to legacy +# `docker build` per arch + `docker manifest create` to assemble the +# multi-arch manifest. The manifest API doesn't need a privileged builder. +# +# Usage (from anywhere): +# /path/to/grafana-mcp/scripts/build-mcp-image.sh [tag-prefix] +# tag-prefix defaults to the current git branch (slashes → dashes). +# Final image tag becomes -, e.g. +# oauth-7ffdced. Per-arch tags get -amd64 / -arm64 suffix. +# +# Env overrides: +# REPO=/path/to/grafana-mcp (default: auto-detected from script path) +# REGISTRY=ghcr.io +# IMAGE=altinity/mcp-grafana +# ARCHES="amd64 arm64" (set ARCHES=arm64 for arm64-only) +# +# Prerequisites: +# - ghcr.io auth: if `docker push` 401s, re-auth with the env token — +# echo "$GITHUB_TOKEN" | docker login ghcr.io -u altinity --password-stdin +# Never run plain `docker login ghcr.io` interactively. +# - Go toolchain (go.mod pins 1.26+). + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO="${REPO:-$(cd "$SCRIPT_DIR/.." && pwd)}" +REGISTRY="${REGISTRY:-ghcr.io}" +IMAGE="${IMAGE:-altinity/mcp-grafana}" +ARCHES="${ARCHES:-amd64 arm64}" + +if [[ ! -d "$REPO" ]]; then + echo "REPO not found: $REPO" >&2 + exit 1 +fi +if [[ ! -f "$REPO/Dockerfile.local" ]]; then + echo "Dockerfile.local not found at $REPO/Dockerfile.local — REPO does not look like a grafana-mcp checkout with the local-build adapter" >&2 + exit 1 +fi + +cd "$REPO" + +DEFAULT_PREFIX=$(git rev-parse --abbrev-ref HEAD | tr '/' '-') +TAG_PREFIX="${1:-$DEFAULT_PREFIX}" + +SHA=$(git rev-parse --short=7 HEAD) +COMMIT=$(git rev-parse HEAD) +DATE=$(date -u +%Y-%m-%dT%H:%M:%SZ) +TAG="${TAG_PREFIX}-${SHA}" +FULL="${REGISTRY}/${IMAGE}" + +cleanup() { rm -f "$REPO/mcp-grafana"; } +trap cleanup EXIT + +build_one() { + local arch=$1 + echo + echo "==> $arch" + + # 1. Cross-compile a statically-linked Go binary into the build context. + CGO_ENABLED=0 GOOS=linux GOARCH="$arch" go build \ + -ldflags="-s -w -X main.version=${TAG} -X main.commit=${COMMIT} -X main.date=${DATE}" \ + -o mcp-grafana ./cmd/mcp-grafana + + # 2. Pre-pull the alpine base for the target arch. Legacy `docker build` + # does NOT honour --platform on FROM (only metadata), so without this + # you'd get the host-arch alpine even when targeting a foreign arch. + docker pull --platform "linux/$arch" alpine:latest >/dev/null + + # 3. Legacy build (DOCKER_BUILDKIT=0) — buildkit needs privileged. + DOCKER_BUILDKIT=0 docker build --platform "linux/$arch" \ + -t "${FULL}:${TAG}-${arch}" -f Dockerfile.local . >/dev/null + + # 4. Sanity-check arch end-to-end. + local got + got=$(docker image inspect "${FULL}:${TAG}-${arch}" --format '{{.Architecture}}') + if [[ "$got" != "$arch" ]]; then + echo "ARCH MISMATCH for ${TAG}-${arch}: image says ${got}" >&2 + exit 1 + fi + + # 5. Push per-arch tag. + docker push "${FULL}:${TAG}-${arch}" +} + +set -- $ARCHES +for arch in "$@"; do + build_one "$arch" +done + +# Multi-arch manifest at the unsuffixed tag. Idempotent: amend if exists. +if [[ "$#" -gt 1 ]]; then + echo + echo "==> manifest ${TAG}" + docker manifest rm "${FULL}:${TAG}" 2>/dev/null || true + manifest_args=() + for arch in "$@"; do + manifest_args+=("${FULL}:${TAG}-${arch}") + done + docker manifest create "${FULL}:${TAG}" "${manifest_args[@]}" + docker manifest push "${FULL}:${TAG}" +fi + +echo +echo "✓ pushed:" +for arch in "$@"; do + echo " ${FULL}:${TAG}-${arch}" +done +if [[ "$#" -gt 1 ]]; then + echo " ${FULL}:${TAG} (multi-arch manifest)" +fi