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
50 changes: 50 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -524,6 +524,56 @@ volumes:

Surrounding whitespace (including a trailing newline) is trimmed from the file contents. If both `GRAFANA_SERVICE_ACCOUNT_TOKEN` and `GRAFANA_SERVICE_ACCOUNT_TOKEN_FILE` are set, the inline token takes precedence.

### OAuth2 login (Device Authorization Grant)

Instead of storing a long-lived service account token on your machine, the server can log you in through your identity provider (Authentik, Keycloak, Okta, Auth0, Azure AD, an OIDC-enabled reverse proxy, etc.) using the OAuth2 [Device Authorization Grant](https://oauth.net/2/device-flow/) (RFC 8628). This keeps only **short-lived, per-user, revocable** tokens on the machine running the server — no static shared secret.

The device flow has no browser redirect or loopback callback, so it works even when the server runs somewhere without a browser: a container, a remote host, or over SSH. On the first Grafana request the server surfaces a verification URL and a short user code — the tool call returns an error like `sign in to Grafana — open <url> ... (user code ABCD-1234), then retry your request`. Open that URL in any browser, approve, and retry: the server polls the token endpoint in the background and, once approved, sends the access token to Grafana as `Authorization: Bearer <token>`.

The access token — and a refresh token when the provider issues one — are cached under your user config dir (`~/.config/mcp-grafana/` on Linux, `~/Library/Application Support/mcp-grafana/` on macOS) with `0600` permissions. Expired access tokens are refreshed automatically; the device login is only requested again when there is no valid or refreshable token.

This is a **public client** flow: no client secret is required (`GRAFANA_OAUTH_CLIENT_SECRET` is optional and only used for confidential clients).

Set the following environment variables to enable it:

| Variable | Required | Description |
| --- | --- | --- |
| `GRAFANA_OAUTH_CLIENT_ID` | yes | OAuth2 public client ID. |
| `GRAFANA_OAUTH_DEVICE_AUTH_URL` | yes | Device authorization endpoint (RFC 8628). |
| `GRAFANA_OAUTH_TOKEN_URL` | yes | Token endpoint. |
| `GRAFANA_OAUTH_CLIENT_SECRET` | no | Only for confidential clients; omit for a public client. |
| `GRAFANA_OAUTH_SCOPES` | no | Space- or comma-separated scopes. Default `openid profile email offline_access` (`offline_access` requests a refresh token). |
| `GRAFANA_OAUTH_AUDIENCE` | no | `audience` parameter sent to the device endpoint (required by some providers, e.g. Auth0). |
| `GRAFANA_OAUTH_TOKEN_CACHE` | no | Override the on-disk token cache path. |
| `GRAFANA_OAUTH_AUTH_TIMEOUT` | no | How long a pending device login may wait for approval (Go duration, default `10m`). |

**Example:**

```json
{
"mcpServers": {
"grafana": {
"command": "mcp-grafana",
"args": [],
"env": {
"GRAFANA_URL": "https://grafana.example.com",
"GRAFANA_OAUTH_CLIENT_ID": "<your public client id>",
"GRAFANA_OAUTH_DEVICE_AUTH_URL": "https://auth.example.com/application/o/device/",
"GRAFANA_OAUTH_TOKEN_URL": "https://auth.example.com/application/o/token/"
}
}
}
}
```

Notes:

- `GRAFANA_OAUTH_CLIENT_ID`, `GRAFANA_OAUTH_DEVICE_AUTH_URL` and `GRAFANA_OAUTH_TOKEN_URL` are all required; an incomplete configuration is logged and ignored.
- The provider must have the device authorization (device code) grant enabled for this client.
- OAuth takes precedence over a static `GRAFANA_SERVICE_ACCOUNT_TOKEN` if both are set (a warning is logged). It does **not** override on-behalf-of Grafana Cloud auth (`X-Access-Token`/`X-Grafana-Id`), which still wins when present.
- `GRAFANA_URL` may point either directly at a Grafana that accepts the provider's tokens (`[auth.jwt]` / `[auth.generic_oauth]`) or at a reverse proxy in front of Grafana that validates the bearer token (e.g. oauth2-proxy with `--skip-jwt-bearer-tokens`).
- OAuth currently authenticates the core Grafana API and datasource-proxy tools. The Incident/IRM and OnCall clients still use the static service account token, so keep one configured if you rely on those tools.

### Multi-Organization Support

You can specify which organization to interact with using either:
Expand Down
7 changes: 5 additions & 2 deletions cmd/mcp-grafana/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -524,9 +524,12 @@ func run(transport, addr, basePath, endpointPath string, logLevel slog.Level, dt
cf := mcpgrafana.ComposedStdioContextFunc(gc)
srv.SetContextFunc(cf)

// For stdio (single-tenant), initialize proxied tools on the server directly
// For stdio (single-tenant), initialize proxied tools on the server directly.
// This discovery makes authenticated Grafana calls; mark the context so it
// never triggers an interactive OAuth browser login at startup. When OAuth
// is in use the login instead happens lazily on the first real tool request.
if !dt.proxied {
stdioCtx := cf(ctx)
stdioCtx := mcpgrafana.WithoutInteractiveOAuth(cf(ctx))
if err := tm.InitializeAndRegisterServerTools(stdioCtx); err != nil {
slog.Error("failed to initialize proxied tools for stdio", "error", err)
}
Expand Down
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ require (
go.opentelemetry.io/otel/sdk/log v0.19.0
go.opentelemetry.io/otel/sdk/metric v1.43.0
go.opentelemetry.io/otel/trace v1.43.0
golang.org/x/oauth2 v0.36.0
golang.org/x/sync v0.20.0
golang.org/x/tools v0.44.0
gopkg.in/yaml.v3 v3.0.1
Expand Down Expand Up @@ -161,7 +162,6 @@ require (
golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa // indirect
golang.org/x/mod v0.35.0 // indirect
golang.org/x/net v0.55.0 // indirect
golang.org/x/oauth2 v0.36.0 // indirect
golang.org/x/sys v0.45.0 // indirect
golang.org/x/telemetry v0.0.0-20260409153401-be6f6cb8b1fa // indirect
golang.org/x/text v0.37.0 // indirect
Expand Down
66 changes: 56 additions & 10 deletions mcpgrafana.go
Original file line number Diff line number Diff line change
Expand Up @@ -255,6 +255,15 @@ type GrafanaConfig struct {
// It is used for on-behalf-of auth in Grafana Cloud.
IDToken string

// OAuth holds optional OAuth2 Authorization-Code + PKCE configuration. When
// set and enabled, the server logs the user in interactively via the browser
// and uses the resulting short-lived bearer token to authenticate to Grafana
// instead of a static service account token. It is populated from the
// GRAFANA_OAUTH_* environment variables. It is a pointer so the cached token
// (and its refresh state) is shared across the copies of GrafanaConfig that
// flow through the context.
OAuth *OAuthConfig
Comment on lines +258 to +265

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

주석의 인증 방식 설명이 실제 구현과 불일치합니다.

주석은 "OAuth2 Authorization-Code + PKCE ... 브라우저 리다이렉트/콜백"을 전제로 설명하지만, oauth.go의 실제 구현은 리다이렉트/콜백이 없는 OAuth2 Device Authorization Grant(RFC 8628)입니다(oauth.go Line 50-59 참조). Line 569-571의 AuthRoundTripper 주석(“OAuth2 (browser PKCE)”)에도 동일한 불일치가 있습니다. 향후 유지보수 혼란을 줄이도록 device flow로 통일해 주세요.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@mcpgrafana.go` around lines 258 - 265, Update the comments on OAuthConfig and
AuthRoundTripper to match the actual authentication flow implemented in
oauth.go: this is OAuth2 Device Authorization Grant (RFC 8628), not
Authorization-Code + PKCE or browser redirect/callback. Keep the wording
consistent wherever these symbols are documented so the comments describe device
flow behavior and token acquisition accurately.


// TLSConfig holds TLS configuration for all Grafana clients.
TLSConfig *TLSConfig

Expand Down Expand Up @@ -557,20 +566,23 @@ func NewExtraHeadersRoundTripper(rt http.RoundTripper, headers map[string]string
}

// AuthRoundTripper wraps an http.RoundTripper to add authentication headers.
// It supports on-behalf-of (OBO) auth via access/ID tokens, API key bearer
// auth, and HTTP basic auth, in that priority order.
// It supports on-behalf-of (OBO) auth via access/ID tokens, OAuth2 (browser
// PKCE) bearer auth, API key bearer auth, and HTTP basic auth, in that priority
// order.
type AuthRoundTripper struct {
accessToken string
idToken string
apiKey string
basicAuth *url.Userinfo
oauth *OAuthConfig
underlying http.RoundTripper
}

func (rt *AuthRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
clonedReq := req.Clone(req.Context())

accessToken, idToken, apiKey, basicAuth := rt.accessToken, rt.idToken, rt.apiKey, rt.basicAuth
oauth := rt.oauth
cfg := GrafanaConfigFromContext(req.Context())
if cfg.AccessToken != "" {
accessToken = cfg.AccessToken
Expand All @@ -584,13 +596,28 @@ func (rt *AuthRoundTripper) RoundTrip(req *http.Request) (*http.Response, error)
if cfg.BasicAuth != nil {
basicAuth = cfg.BasicAuth
}
if cfg.OAuth.Enabled() {
oauth = cfg.OAuth
}

if accessToken != "" && idToken != "" {
switch {
case accessToken != "" && idToken != "":
clonedReq.Header.Set("X-Access-Token", accessToken)
clonedReq.Header.Set("X-Grafana-Id", idToken)
} else if apiKey != "" {
case oauth.Enabled():
// Fetch (or reuse a cached) OAuth2 access token and send it as a bearer
// token. On a real tool request this may trigger an interactive browser
// login; on startup/background work (marked via WithoutInteractiveOAuth)
// it never opens a browser. This takes precedence over a static API key
// so that enabling OAuth is an explicit, unambiguous opt-in.
token, err := oauth.Token(oauthInteractiveAllowed(req.Context()))
if err != nil {
return nil, fmt.Errorf("failed to obtain Grafana OAuth token: %w", err)
}
token.SetAuthHeader(clonedReq)
case apiKey != "":
clonedReq.Header.Set("Authorization", "Bearer "+apiKey)
} else if basicAuth != nil {
case basicAuth != nil:
password, _ := basicAuth.Password()
clonedReq.SetBasicAuth(basicAuth.Username(), password)
}
Expand Down Expand Up @@ -742,7 +769,9 @@ func BuildTransport(cfg *GrafanaConfig, base http.RoundTripper, opts ...Transpor

// Auth (innermost header layer — wins on conflicts with ExtraHeaders)
if !options.withoutAuth {
transport = NewAuthRoundTripper(transport, cfg.AccessToken, cfg.IDToken, cfg.APIKey, cfg.BasicAuth)
authRT := NewAuthRoundTripper(transport, cfg.AccessToken, cfg.IDToken, cfg.APIKey, cfg.BasicAuth)
authRT.oauth = cfg.OAuth
transport = authRT
}

// Extra headers (always included so per-request context overrides work)
Expand Down Expand Up @@ -824,13 +853,18 @@ var ExtractGrafanaInfoFromEnv server.StdioContextFunc = func(ctx context.Context
}

extraHeaders := extraHeadersFromEnv(logger)
oauth := oauthConfigFromEnv(logger)
if oauth.Enabled() && apiKey != "" {
logger.Warn("Both OAuth and a service account token are configured; OAuth takes precedence and the service account token will be ignored.")
}

logger.Info("Using Grafana configuration", "url", parsedURL.Redacted(), "api_key_set", apiKey != "", "basic_auth_set", basicAuth != nil, "org_id", orgID, "extra_headers_count", len(extraHeaders))
logger.Info("Using Grafana configuration", "url", parsedURL.Redacted(), "api_key_set", apiKey != "", "basic_auth_set", basicAuth != nil, "oauth_set", oauth.Enabled(), "org_id", orgID, "extra_headers_count", len(extraHeaders))
config.URL = u
config.APIKey = apiKey
config.BasicAuth = basicAuth
config.OrgID = orgID
config.ExtraHeaders = extraHeaders
config.OAuth = oauth
return WithGrafanaConfig(ctx, config)
}

Expand All @@ -855,6 +889,9 @@ var ExtractGrafanaInfoFromHeaders httpContextFunc = func(ctx context.Context, re
config.BasicAuth = basicAuth
config.OrgID = orgID
config.ExtraHeaders = mergeHeaders(extraHeadersFromEnv(logger), forwardedHeadersFromRequest(req))
// OAuth is configured process-wide via GRAFANA_OAUTH_* env vars (not per
// request), so it applies as a fallback the same way in header mode.
config.OAuth = oauthConfigFromEnv(logger)
Comment on lines +892 to +894

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟠 Major | 🏗️ Heavy lift

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# OAuth가 HTTP/SSE(헤더) 전송 모드와 함께 사용될 수 있는지, 관련 가드/문서 확인
rg -nP -C3 'oauthConfigFromEnv|WithoutInteractiveOAuth|ExtractGrafanaInfoFromHeaders' --type go
rg -nP -C3 'ComposedHTTPContextFunc|ComposedSSEContextFunc' --type go

Repository: Buzzvil/mcp-grafana

Length of output: 157


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '\n== files ==\n'
git ls-files | rg '(^|/)(mcpgrafana\.go|oauth\.go|.*grafana.*\.go|.*oauth.*\.go|.*transport.*\.go|.*context.*\.go)$' || true

printf '\n== locate target file ==\n'
fd -a 'mcpgrafana.go' . || true

printf '\n== outline target if found ==\n'
if [ -f mcpgrafana.go ]; then
  ast-grep outline mcpgrafana.go --view expanded || true
fi

printf '\n== search relevant symbols ==\n'
rg -n -C3 'oauthConfigFromEnv|WithoutInteractiveOAuth|ExtractGrafanaInfoFromHeaders|ComposedHTTPContextFunc|ComposedSSEContextFunc|envOAuthOnce|envOAuthConfig|OAuthConfig' . --glob '*.go' || true

Repository: Buzzvil/mcp-grafana

Length of output: 43403


헤더 모드에서 OAuth를 요청별로 분리하세요. mcpgrafana.go:892-894에서 oauthConfigFromEnv(logger)OAuthConfig 포인터와 캐시된 토큰을 프로세스 전역으로 재사용합니다. 이 경로는 HTTP/SSE 헤더 모드에도 그대로 들어가고 WithoutInteractiveOAuth도 적용되지 않으므로, 멀티테넌트 환경에서는 한 요청의 로그인 상태가 다른 요청에 영향을 줄 수 있습니다. 헤더 모드에서 OAuth를 허용할 의도라면 요청별 격리 또는 비활성화 기준을 분리해 주세요.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@mcpgrafana.go` around lines 892 - 894, The header-mode fallback currently
reuses the process-wide OAuthConfig from oauthConfigFromEnv(logger), which can
leak cached tokens and login state across requests. Update the header-mode path
in mcpgrafana.go so OAuth is either explicitly disabled there or constructed per
request with isolation, and ensure WithoutInteractiveOAuth is applied
consistently before assigning config.OAuth. Refer to the OAuthConfig setup in
the header-mode handling logic to make the separation clear.

return WithGrafanaConfig(ctx, config)
}

Expand Down Expand Up @@ -1201,12 +1238,15 @@ func NewGrafanaClient(ctx context.Context, grafanaURL, apiKey string, auth *url.
}
}
// Use BuildTransport but skip APIKey/BasicAuth auth
// (handled by the OpenAPI client). OBO tokens still need
// transport-level injection since the OpenAPI client
// doesn't support them natively.
// (handled by the OpenAPI client). OBO tokens and OAuth
// bearer tokens still need transport-level injection since
// the OpenAPI client doesn't support them natively: OAuth
// tokens are short-lived and refreshed per request, so they
// can't be set as the client's static APIKey.
oboConfig := GrafanaConfig{
AccessToken: config.AccessToken,
IDToken: config.IDToken,
OAuth: config.OAuth,
OrgID: config.OrgID,
TLSConfig: config.TLSConfig,
ExtraHeaders: config.ExtraHeaders,
Expand All @@ -1229,6 +1269,12 @@ func NewGrafanaClient(ctx context.Context, grafanaURL, apiKey string, auth *url.
}

// Fetch the public URL from Grafana's frontend settings.
// Note: OAuth is deliberately omitted here. This fetch runs at client
// creation time (server startup), and triggering an interactive browser
// login for a best-effort, non-critical deep-link lookup would be
// surprising. When OAuth is the only credential this fetch is unauthenticated
// and may fail; that's fine (public URL is optional). The interactive login
// happens lazily on the first real Grafana tool request instead.
fetchCfg := &GrafanaConfig{
URL: grafanaURL,
APIKey: apiKey,
Expand Down
Loading