Skip to content

Commit d0cbad8

Browse files
authored
Support username/password authentication (#259)
1 parent 2e4577b commit d0cbad8

File tree

16 files changed

+167
-61
lines changed

16 files changed

+167
-61
lines changed

.github/workflows/e2e.yml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,10 @@ jobs:
2121
permissions:
2222
id-token: write
2323
contents: read
24+
env:
25+
# Set auth here so stdio transport and background process pick them up
26+
GRAFANA_USERNAME: admin
27+
GRAFANA_PASSWORD: admin
2428
steps:
2529
- name: Checkout code
2630
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0

Makefile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ endif
4545
.PHONY: test-python-e2e
4646
test-python-e2e: ## Run Python E2E tests (requires docker-compose services and SSE server to be running, use `make run-test-services` and `make run-sse` to start them).
4747
cd tests && uv sync --all-groups
48-
cd tests && uv run pytest
48+
cd tests && GRAFANA_USERNAME=admin GRAFANA_PASSWORD=admin uv run pytest
4949

5050
.PHONY: run
5151
run: ## Run the MCP server in stdio mode.

README.md

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -201,7 +201,7 @@ Scopes define the specific resources that permissions apply to. Each action requ
201201

202202
This MCP server works with both local Grafana instances and Grafana Cloud. For Grafana Cloud, use your instance URL (e.g., `https://myinstance.grafana.net`) instead of `http://localhost:3000` in the configuration examples below.
203203

204-
1. Create a service account in Grafana with enough permissions to use the tools you want to use,
204+
1. If using API key authentication, create a service account in Grafana with enough permissions to use the tools you want to use,
205205
generate a service account token, and copy it to the clipboard for use in the configuration file.
206206
Follow the [Grafana documentation][service-account] for details.
207207

@@ -264,7 +264,10 @@ This MCP server works with both local Grafana instances and Grafana Cloud. For G
264264
"args": [],
265265
"env": {
266266
"GRAFANA_URL": "http://localhost:3000", // Or "https://myinstance.grafana.net" for Grafana Cloud
267-
"GRAFANA_API_KEY": "<your service account token>"
267+
"GRAFANA_API_KEY": "<your service account token>",
268+
// If using username/password authentication
269+
"GRAFANA_USERNAME": "<your username>",
270+
"GRAFANA_PASSWORD": "<your password>"
268271
}
269272
}
270273
}
@@ -294,7 +297,10 @@ This MCP server works with both local Grafana instances and Grafana Cloud. For G
294297
],
295298
"env": {
296299
"GRAFANA_URL": "http://localhost:3000", // Or "https://myinstance.grafana.net" for Grafana Cloud
297-
"GRAFANA_API_KEY": "<your service account token>"
300+
"GRAFANA_API_KEY": "<your service account token>",
301+
// If using username/password authentication
302+
"GRAFANA_USERNAME": "<your username>",
303+
"GRAFANA_PASSWORD": "<your password>"
298304
}
299305
}
300306
}

docker-compose.yaml

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,7 @@ services:
22
grafana:
33
image: grafana/grafana
44
environment:
5-
GF_AUTH_ANONYMOUS_ENABLED: "true"
6-
GF_AUTH_ANONYMOUS_ORG_ROLE: "Admin"
5+
GF_AUTH_ANONYMOUS_ENABLED: "false"
76
GF_LOG_LEVEL: debug
87
GF_SERVER_ROUTER_LOGGING: "true"
98
ports:

mcpgrafana.go

Lines changed: 73 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,9 @@ const (
2828
grafanaURLEnvVar = "GRAFANA_URL"
2929
grafanaAPIEnvVar = "GRAFANA_API_KEY"
3030

31+
grafanaUsernameEnvVar = "GRAFANA_USERNAME"
32+
grafanaPasswordEnvVar = "GRAFANA_PASSWORD"
33+
3134
grafanaURLHeader = "X-Grafana-URL"
3235
grafanaAPIKeyHeader = "X-Grafana-API-Key"
3336
)
@@ -38,6 +41,18 @@ func urlAndAPIKeyFromEnv() (string, string) {
3841
return u, apiKey
3942
}
4043

44+
func userAndPassFromEnv() *url.Userinfo {
45+
username := os.Getenv(grafanaUsernameEnvVar)
46+
password, exists := os.LookupEnv(grafanaPasswordEnvVar)
47+
if username == "" && password == "" {
48+
return nil
49+
}
50+
if !exists {
51+
return url.User(username)
52+
}
53+
return url.UserPassword(username, password)
54+
}
55+
4156
func urlAndAPIKeyFromHeaders(req *http.Request) (string, string) {
4257
u := strings.TrimRight(req.Header.Get(grafanaURLHeader), "/")
4358
apiKey := req.Header.Get(grafanaAPIKeyHeader)
@@ -76,6 +91,9 @@ type GrafanaConfig struct {
7691
// It may be empty if we are using on-behalf-of auth.
7792
APIKey string
7893

94+
// Credentials if user is using basic auth
95+
BasicAuth *url.Userinfo
96+
7997
// AccessToken is the Grafana Cloud access policy token used for on-behalf-of auth in Grafana Cloud.
8098
AccessToken string
8199
// IDToken is an ID token identifying the user for the current request.
@@ -216,24 +234,59 @@ func wrapWithUserAgent(rt http.RoundTripper) http.RoundTripper {
216234
return NewUserAgentTransport(rt)
217235
}
218236

237+
// Gets info from environment
238+
func extractKeyGrafanaInfoFromEnv() (url, apiKey string, auth *url.Userinfo) {
239+
url, apiKey = urlAndAPIKeyFromEnv()
240+
if url == "" {
241+
url = defaultGrafanaURL
242+
}
243+
auth = userAndPassFromEnv()
244+
return
245+
}
246+
247+
// Tries to get grafana info from a request.
248+
// Gets info from environment if it can't get it from request
249+
func extractKeyGrafanaInfoFromReq(req *http.Request) (grafanaUrl, apiKey string, auth *url.Userinfo) {
250+
eUrl, eApiKey, eAuth := extractKeyGrafanaInfoFromEnv()
251+
username, password, _ := req.BasicAuth()
252+
253+
grafanaUrl, apiKey = urlAndAPIKeyFromHeaders(req)
254+
// If anything is missing, check if we can get it from the environment
255+
if grafanaUrl == "" {
256+
grafanaUrl = eUrl
257+
}
258+
259+
if apiKey == "" {
260+
apiKey = eApiKey
261+
}
262+
263+
// Use environment configured auth if nothing was passed in request
264+
if username == "" && password == "" {
265+
auth = eAuth
266+
} else {
267+
auth = url.UserPassword(username, password)
268+
}
269+
270+
return
271+
}
272+
219273
// ExtractGrafanaInfoFromEnv is a StdioContextFunc that extracts Grafana configuration from environment variables.
220274
// It reads GRAFANA_URL and GRAFANA_API_KEY environment variables and adds the configuration to the context for use by Grafana clients.
221275
var ExtractGrafanaInfoFromEnv server.StdioContextFunc = func(ctx context.Context) context.Context {
222-
u, apiKey := urlAndAPIKeyFromEnv()
223-
if u == "" {
224-
u = defaultGrafanaURL
225-
}
276+
u, apiKey, basicAuth := extractKeyGrafanaInfoFromEnv()
226277
parsedURL, err := url.Parse(u)
227278
if err != nil {
228279
panic(fmt.Errorf("invalid Grafana URL %s: %w", u, err))
229280
}
230-
slog.Info("Using Grafana configuration", "url", parsedURL.Redacted(), "api_key_set", apiKey != "")
281+
282+
slog.Info("Using Grafana configuration", "url", parsedURL.Redacted(), "api_key_set", apiKey != "", "basic_auth_set", basicAuth != nil)
231283

232284
// Get existing config or create a new one.
233285
// This will respect the existing debug flag, if set.
234286
config := GrafanaConfigFromContext(ctx)
235287
config.URL = u
236288
config.APIKey = apiKey
289+
config.BasicAuth = basicAuth
237290
return WithGrafanaConfig(ctx, config)
238291
}
239292

@@ -245,23 +298,14 @@ type httpContextFunc func(ctx context.Context, req *http.Request) context.Contex
245298
// ExtractGrafanaInfoFromHeaders is a HTTPContextFunc that extracts Grafana configuration from HTTP request headers.
246299
// It reads X-Grafana-URL and X-Grafana-API-Key headers, falling back to environment variables if headers are not present.
247300
var ExtractGrafanaInfoFromHeaders httpContextFunc = func(ctx context.Context, req *http.Request) context.Context {
248-
u, apiKey := urlAndAPIKeyFromHeaders(req)
249-
uEnv, apiKeyEnv := urlAndAPIKeyFromEnv()
250-
if u == "" {
251-
u = uEnv
252-
}
253-
if u == "" {
254-
u = defaultGrafanaURL
255-
}
256-
if apiKey == "" {
257-
apiKey = apiKeyEnv
258-
}
301+
u, apiKey, basicAuth := extractKeyGrafanaInfoFromReq(req)
259302

260303
// Get existing config or create a new one.
261304
// This will respect the existing debug flag, if set.
262305
config := GrafanaConfigFromContext(ctx)
263306
config.URL = u
264307
config.APIKey = apiKey
308+
config.BasicAuth = basicAuth
265309
return WithGrafanaConfig(ctx, config)
266310
}
267311

@@ -295,7 +339,7 @@ func makeBasePath(path string) string {
295339

296340
// NewGrafanaClient creates a Grafana client with the provided URL and API key.
297341
// The client is automatically configured with the correct HTTP scheme, debug settings from context, custom TLS configuration if present, and OpenTelemetry instrumentation for distributed tracing.
298-
func NewGrafanaClient(ctx context.Context, grafanaURL, apiKey string) *client.GrafanaHTTPAPI {
342+
func NewGrafanaClient(ctx context.Context, grafanaURL, apiKey string, auth *url.Userinfo) *client.GrafanaHTTPAPI {
299343
cfg := client.DefaultTransportConfig()
300344

301345
var parsedURL *url.URL
@@ -322,6 +366,10 @@ func NewGrafanaClient(ctx context.Context, grafanaURL, apiKey string) *client.Gr
322366
cfg.APIKey = apiKey
323367
}
324368

369+
if auth != nil {
370+
cfg.BasicAuth = auth
371+
}
372+
325373
config := GrafanaConfigFromContext(ctx)
326374
cfg.Debug = config.Debug
327375

@@ -338,7 +386,7 @@ func NewGrafanaClient(ctx context.Context, grafanaURL, apiKey string) *client.Gr
338386
"skip_verify", tlsConfig.SkipVerify)
339387
}
340388

341-
slog.Debug("Creating Grafana client", "url", parsedURL.Redacted(), "api_key_set", apiKey != "")
389+
slog.Debug("Creating Grafana client", "url", parsedURL.Redacted(), "api_key_set", apiKey != "", "basic_auth_set", config.BasicAuth != nil)
342390
grafanaClient := client.NewHTTPClientWithConfig(strfmt.Default, cfg)
343391

344392
// Always enable HTTP tracing for context propagation (no-op when no exporter configured)
@@ -364,36 +412,27 @@ func NewGrafanaClient(ctx context.Context, grafanaURL, apiKey string) *client.Gr
364412
}
365413

366414
// ExtractGrafanaClientFromEnv is a StdioContextFunc that creates and injects a Grafana client into the context.
367-
// It uses configuration from GRAFANA_URL and GRAFANA_API_KEY environment variables to initialize the client with proper authentication.
415+
// It uses configuration from GRAFANA_URL, GRAFANA_API_KEY, GRAFANA_USERNAME/PASSWORD environment variables to initialize
416+
// the client with proper authentication.
368417
var ExtractGrafanaClientFromEnv server.StdioContextFunc = func(ctx context.Context) context.Context {
369418
// Extract transport config from env vars
370419
grafanaURL, ok := os.LookupEnv(grafanaURLEnvVar)
371420
if !ok {
372421
grafanaURL = defaultGrafanaURL
373422
}
374423
apiKey := os.Getenv(grafanaAPIEnvVar)
375-
376-
grafanaClient := NewGrafanaClient(ctx, grafanaURL, apiKey)
424+
auth := userAndPassFromEnv()
425+
grafanaClient := NewGrafanaClient(ctx, grafanaURL, apiKey, auth)
377426
return context.WithValue(ctx, grafanaClientKey{}, grafanaClient)
378427
}
379428

380429
// ExtractGrafanaClientFromHeaders is a HTTPContextFunc that creates and injects a Grafana client into the context.
381430
// It prioritizes configuration from HTTP headers (X-Grafana-URL, X-Grafana-API-Key) over environment variables for multi-tenant scenarios.
382431
var ExtractGrafanaClientFromHeaders httpContextFunc = func(ctx context.Context, req *http.Request) context.Context {
383432
// Extract transport config from request headers, and set it on the context.
384-
u, apiKey := urlAndAPIKeyFromHeaders(req)
385-
uEnv, apiKeyEnv := urlAndAPIKeyFromEnv()
386-
if u == "" {
387-
u = uEnv
388-
}
389-
if u == "" {
390-
u = defaultGrafanaURL
391-
}
392-
if apiKey == "" {
393-
apiKey = apiKeyEnv
394-
}
433+
u, apiKey, basicAuth := extractKeyGrafanaInfoFromReq(req)
395434

396-
grafanaClient := NewGrafanaClient(ctx, u, apiKey)
435+
grafanaClient := NewGrafanaClient(ctx, u, apiKey, basicAuth)
397436
return WithGrafanaClient(ctx, grafanaClient)
398437
}
399438

@@ -453,17 +492,7 @@ var ExtractIncidentClientFromEnv server.StdioContextFunc = func(ctx context.Cont
453492
// ExtractIncidentClientFromHeaders is a HTTPContextFunc that creates and injects a Grafana Incident client into the context.
454493
// It uses HTTP headers for configuration with environment variable fallbacks, enabling per-request incident management configuration.
455494
var ExtractIncidentClientFromHeaders httpContextFunc = func(ctx context.Context, req *http.Request) context.Context {
456-
grafanaURL, apiKey := urlAndAPIKeyFromHeaders(req)
457-
grafanaURLEnv, apiKeyEnv := urlAndAPIKeyFromEnv()
458-
if grafanaURL == "" {
459-
grafanaURL = grafanaURLEnv
460-
}
461-
if grafanaURL == "" {
462-
grafanaURL = defaultGrafanaURL
463-
}
464-
if apiKey == "" {
465-
apiKey = apiKeyEnv
466-
}
495+
grafanaURL, apiKey, _ := extractKeyGrafanaInfoFromReq(req)
467496
incidentURL := fmt.Sprintf("%s/api/plugins/grafana-irm-app/resources/api/v1/", grafanaURL)
468497
client := incident.NewClient(incidentURL, apiKey)
469498

mcpgrafana_test.go

Lines changed: 41 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,7 @@ func TestExtractGrafanaInfoFromHeaders(t *testing.T) {
8787
config := GrafanaConfigFromContext(ctx)
8888
assert.Equal(t, defaultGrafanaURL, config.URL)
8989
assert.Equal(t, "", config.APIKey)
90+
assert.Nil(t, config.BasicAuth)
9091
})
9192

9293
t.Run("no headers, with env", func(t *testing.T) {
@@ -126,6 +127,44 @@ func TestExtractGrafanaInfoFromHeaders(t *testing.T) {
126127
assert.Equal(t, "http://my-test-url.grafana.com", config.URL)
127128
assert.Equal(t, "my-test-api-key", config.APIKey)
128129
})
130+
131+
t.Run("no headers, with env", func(t *testing.T) {
132+
t.Setenv("GRAFANA_USERNAME", "foo")
133+
t.Setenv("GRAFANA_PASSWORD", "bar")
134+
135+
req, err := http.NewRequest("GET", "http://example.com", nil)
136+
require.NoError(t, err)
137+
ctx := ExtractGrafanaInfoFromHeaders(context.Background(), req)
138+
config := GrafanaConfigFromContext(ctx)
139+
assert.Equal(t, "foo", config.BasicAuth.Username())
140+
password, _ := config.BasicAuth.Password()
141+
assert.Equal(t, "bar", password)
142+
})
143+
144+
t.Run("user auth with headers, no env", func(t *testing.T) {
145+
req, err := http.NewRequest("GET", "http://example.com", nil)
146+
req.SetBasicAuth("foo", "bar")
147+
require.NoError(t, err)
148+
ctx := ExtractGrafanaInfoFromHeaders(context.Background(), req)
149+
config := GrafanaConfigFromContext(ctx)
150+
assert.Equal(t, "foo", config.BasicAuth.Username())
151+
password, _ := config.BasicAuth.Password()
152+
assert.Equal(t, "bar", password)
153+
})
154+
155+
t.Run("user auth with headers, with env", func(t *testing.T) {
156+
t.Setenv("GRAFANA_USERNAME", "will-not-be-used")
157+
t.Setenv("GRAFANA_PASSWORD", "will-not-be-used")
158+
159+
req, err := http.NewRequest("GET", "http://example.com", nil)
160+
req.SetBasicAuth("foo", "bar")
161+
require.NoError(t, err)
162+
ctx := ExtractGrafanaInfoFromHeaders(context.Background(), req)
163+
config := GrafanaConfigFromContext(ctx)
164+
assert.Equal(t, "foo", config.BasicAuth.Username())
165+
password, _ := config.BasicAuth.Password()
166+
assert.Equal(t, "bar", password)
167+
})
129168
}
130169

131170
func TestExtractGrafanaClientPath(t *testing.T) {
@@ -522,7 +561,7 @@ func TestHTTPTracingConfiguration(t *testing.T) {
522561
ctx := WithGrafanaConfig(context.Background(), config)
523562

524563
// Create Grafana client
525-
client := NewGrafanaClient(ctx, "http://localhost:3000", "test-api-key")
564+
client := NewGrafanaClient(ctx, "http://localhost:3000", "test-api-key", nil)
526565
require.NotNil(t, client)
527566

528567
// Verify the client was created successfully (should not panic)
@@ -537,7 +576,7 @@ func TestHTTPTracingConfiguration(t *testing.T) {
537576
ctx := WithGrafanaConfig(context.Background(), config)
538577

539578
// Create Grafana client (should not panic even without OTEL configured)
540-
client := NewGrafanaClient(ctx, "http://localhost:3000", "test-api-key")
579+
client := NewGrafanaClient(ctx, "http://localhost:3000", "test-api-key", nil)
541580
require.NotNil(t, client)
542581

543582
// Verify the client was created successfully

tests/conftest.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
import os
33
import asyncio
44
import gc
5+
import base64
56
from dotenv import load_dotenv
67
from mcp.client.sse import sse_client
78
from mcp.client.stdio import stdio_client
@@ -47,6 +48,9 @@ def grafana_env():
4748
env = {"GRAFANA_URL": os.environ.get("GRAFANA_URL", DEFAULT_GRAFANA_URL)}
4849
if key := os.environ.get("GRAFANA_API_KEY"):
4950
env["GRAFANA_API_KEY"] = key
51+
elif (username := os.environ.get("GRAFANA_USERNAME")) and (password := os.environ.get("GRAFANA_USERNAME")):
52+
env["GRAFANA_USERNAME"] = username
53+
env["GRAFANA_PASSWORD"] = password
5054
return env
5155

5256

@@ -57,6 +61,9 @@ def grafana_headers():
5761
}
5862
if key := os.environ.get("GRAFANA_API_KEY"):
5963
headers["X-Grafana-API-Key"] = key
64+
elif (username := os.environ.get("GRAFANA_USERNAME")) and (password := os.environ.get("GRAFANA_PASSWORD")):
65+
credentials = f"{username}:{password}"
66+
headers["Authorization"] = "Basic " + base64.b64encode(credentials.encode("utf-8")).decode()
6067
return headers
6168

6269

0 commit comments

Comments
 (0)