Skip to content

Commit d0664e5

Browse files
authored
feat: forward selected request headers to Grafana (SSE/streamable-http) for SSO/ALB session cookies (#659)
1 parent 6fe9de0 commit d0664e5

4 files changed

Lines changed: 247 additions & 2 deletions

File tree

README.md

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -468,6 +468,32 @@ You can add arbitrary HTTP headers to all Grafana API requests using the `GRAFAN
468468
}
469469
```
470470

471+
### Forwarding Headers from the Client (SSE/Streamable-HTTP Only)
472+
473+
When the MCP server runs behind a gateway or reverse proxy that handles SSO (e.g. an AWS ALB with OIDC), each user's session cookie must reach Grafana so it can associate the request with the authenticated user. The `GRAFANA_FORWARD_HEADERS` environment variable enables this by specifying a comma-separated allowlist of header names to copy from the **incoming** HTTP request to every outbound Grafana API request.
474+
475+
This only applies when using SSE (`-t sse`) or streamable-http (`-t streamable-http`) transports. It has no effect in stdio mode.
476+
477+
**Example: forward the session cookie**
478+
479+
```json
480+
{
481+
"env": {
482+
"GRAFANA_URL": "https://grafana.internal",
483+
"GRAFANA_SERVICE_ACCOUNT_TOKEN": "<your token>",
484+
"GRAFANA_FORWARD_HEADERS": "Cookie"
485+
}
486+
}
487+
```
488+
489+
You can forward multiple headers by separating them with commas:
490+
491+
```
492+
GRAFANA_FORWARD_HEADERS=Cookie,X-Session-Id
493+
```
494+
495+
Forwarded headers are merged with any headers defined in `GRAFANA_EXTRA_HEADERS`. If a header name appears in both, the value from the incoming request takes precedence for that request.
496+
471497
2. You have several options to install `mcp-grafana`:
472498

473499
- **uvx (recommended)**: If you have [uv](https://docs.astral.sh/uv/getting-started/installation/) installed, no extra setup is needed — `uvx` will automatically download and run the server:

mcpgrafana.go

Lines changed: 66 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import (
1010
"log/slog"
1111
"net"
1212
"net/http"
13+
"net/textproto"
1314
"net/url"
1415
"os"
1516
"reflect"
@@ -41,7 +42,8 @@ const (
4142
grafanaUsernameEnvVar = "GRAFANA_USERNAME"
4243
grafanaPasswordEnvVar = "GRAFANA_PASSWORD"
4344

44-
grafanaExtraHeadersEnvVar = "GRAFANA_EXTRA_HEADERS"
45+
grafanaExtraHeadersEnvVar = "GRAFANA_EXTRA_HEADERS"
46+
grafanaForwardHeadersEnvVar = "GRAFANA_FORWARD_HEADERS"
4547

4648
grafanaURLHeader = "X-Grafana-URL"
4749
grafanaServiceAccountTokenHeader = "X-Grafana-Service-Account-Token"
@@ -104,6 +106,67 @@ func extraHeadersFromEnv() map[string]string {
104106
return headers
105107
}
106108

109+
func forwardHeaderNamesFromEnv() []string {
110+
raw := os.Getenv(grafanaForwardHeadersEnvVar)
111+
if raw == "" {
112+
return nil
113+
}
114+
parts := strings.Split(raw, ",")
115+
names := make([]string, 0, len(parts))
116+
for _, p := range parts {
117+
p = strings.TrimSpace(p)
118+
if p != "" {
119+
names = append(names, p)
120+
}
121+
}
122+
return names
123+
}
124+
125+
// forwardedHeadersFromRequest reads GRAFANA_FORWARD_HEADERS and copies matching
126+
// headers from the incoming HTTP request. Returns nil when no headers match.
127+
func forwardedHeadersFromRequest(req *http.Request) map[string]string {
128+
names := forwardHeaderNamesFromEnv()
129+
if len(names) == 0 {
130+
return nil
131+
}
132+
var forwarded map[string]string
133+
for _, name := range names {
134+
if v := req.Header.Get(name); v != "" {
135+
if forwarded == nil {
136+
forwarded = make(map[string]string, len(names))
137+
}
138+
forwarded[name] = v
139+
}
140+
}
141+
return forwarded
142+
}
143+
144+
// mergeHeaders returns a new map containing all entries from base, with entries
145+
// from override taking precedence. When both maps are non-empty, header names
146+
// are canonicalized (via textproto.CanonicalMIMEHeaderKey) so that
147+
// case-insensitive matches are merged correctly and the documented
148+
// guarantee—incoming request wins—is upheld. When only one side is present the
149+
// original key casing is preserved.
150+
func mergeHeaders(base, override map[string]string) map[string]string {
151+
if len(base) == 0 && len(override) == 0 {
152+
return nil
153+
}
154+
if len(override) == 0 {
155+
return base
156+
}
157+
if len(base) == 0 {
158+
return override
159+
}
160+
merged := make(map[string]string, len(base)+len(override))
161+
for k, v := range base {
162+
merged[textproto.CanonicalMIMEHeaderKey(k)] = v
163+
}
164+
for k, v := range override {
165+
merged[textproto.CanonicalMIMEHeaderKey(k)] = v
166+
}
167+
return merged
168+
}
169+
107170
func orgIdFromHeaders(req *http.Request) int64 {
108171
orgIDStr := req.Header.Get(client.OrgIDHeader)
109172
if orgIDStr == "" {
@@ -487,6 +550,7 @@ type httpContextFunc func(ctx context.Context, req *http.Request) context.Contex
487550

488551
// ExtractGrafanaInfoFromHeaders is a HTTPContextFunc that extracts Grafana configuration from HTTP request headers.
489552
// It reads X-Grafana-URL and X-Grafana-API-Key headers, falling back to environment variables if headers are not present.
553+
// Headers listed in GRAFANA_FORWARD_HEADERS are copied from the incoming request and merged with GRAFANA_EXTRA_HEADERS.
490554
var ExtractGrafanaInfoFromHeaders httpContextFunc = func(ctx context.Context, req *http.Request) context.Context {
491555
u, apiKey, basicAuth, orgID := extractKeyGrafanaInfoFromReq(req)
492556

@@ -497,7 +561,7 @@ var ExtractGrafanaInfoFromHeaders httpContextFunc = func(ctx context.Context, re
497561
config.APIKey = apiKey
498562
config.BasicAuth = basicAuth
499563
config.OrgID = orgID
500-
config.ExtraHeaders = extraHeadersFromEnv()
564+
config.ExtraHeaders = mergeHeaders(extraHeadersFromEnv(), forwardedHeadersFromRequest(req))
501565
return WithGrafanaConfig(ctx, config)
502566
}
503567

mcpgrafana_test.go

Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -865,6 +865,154 @@ func TestExtractGrafanaInfoWithExtraHeaders(t *testing.T) {
865865
})
866866
}
867867

868+
func TestForwardHeaderNamesFromEnv(t *testing.T) {
869+
t.Run("empty env returns nil", func(t *testing.T) {
870+
t.Setenv("GRAFANA_FORWARD_HEADERS", "")
871+
names := forwardHeaderNamesFromEnv()
872+
assert.Nil(t, names)
873+
})
874+
875+
t.Run("single header", func(t *testing.T) {
876+
t.Setenv("GRAFANA_FORWARD_HEADERS", "Cookie")
877+
names := forwardHeaderNamesFromEnv()
878+
assert.Equal(t, []string{"Cookie"}, names)
879+
})
880+
881+
t.Run("multiple headers with spaces", func(t *testing.T) {
882+
t.Setenv("GRAFANA_FORWARD_HEADERS", " Cookie , X-Session-Id , X-Request-Id ")
883+
names := forwardHeaderNamesFromEnv()
884+
assert.Equal(t, []string{"Cookie", "X-Session-Id", "X-Request-Id"}, names)
885+
})
886+
887+
t.Run("trailing comma ignored", func(t *testing.T) {
888+
t.Setenv("GRAFANA_FORWARD_HEADERS", "Cookie,")
889+
names := forwardHeaderNamesFromEnv()
890+
assert.Equal(t, []string{"Cookie"}, names)
891+
})
892+
}
893+
894+
func TestForwardedHeadersFromRequest(t *testing.T) {
895+
t.Run("no env returns nil", func(t *testing.T) {
896+
t.Setenv("GRAFANA_FORWARD_HEADERS", "")
897+
req, _ := http.NewRequest("GET", "http://example.com", nil)
898+
req.Header.Set("Cookie", "session=abc")
899+
assert.Nil(t, forwardedHeadersFromRequest(req))
900+
})
901+
902+
t.Run("header present in request", func(t *testing.T) {
903+
t.Setenv("GRAFANA_FORWARD_HEADERS", "Cookie")
904+
req, _ := http.NewRequest("GET", "http://example.com", nil)
905+
req.Header.Set("Cookie", "session=abc")
906+
forwarded := forwardedHeadersFromRequest(req)
907+
assert.Equal(t, map[string]string{"Cookie": "session=abc"}, forwarded)
908+
})
909+
910+
t.Run("header missing from request returns nil", func(t *testing.T) {
911+
t.Setenv("GRAFANA_FORWARD_HEADERS", "Cookie")
912+
req, _ := http.NewRequest("GET", "http://example.com", nil)
913+
assert.Nil(t, forwardedHeadersFromRequest(req))
914+
})
915+
916+
t.Run("multiple headers partial match", func(t *testing.T) {
917+
t.Setenv("GRAFANA_FORWARD_HEADERS", "Cookie,X-Session-Id")
918+
req, _ := http.NewRequest("GET", "http://example.com", nil)
919+
req.Header.Set("Cookie", "session=abc")
920+
forwarded := forwardedHeadersFromRequest(req)
921+
assert.Equal(t, map[string]string{"Cookie": "session=abc"}, forwarded)
922+
})
923+
}
924+
925+
func TestMergeHeaders(t *testing.T) {
926+
t.Run("both nil", func(t *testing.T) {
927+
assert.Nil(t, mergeHeaders(nil, nil))
928+
})
929+
930+
t.Run("base only", func(t *testing.T) {
931+
result := mergeHeaders(map[string]string{"A": "1"}, nil)
932+
assert.Equal(t, map[string]string{"A": "1"}, result)
933+
})
934+
935+
t.Run("override only", func(t *testing.T) {
936+
result := mergeHeaders(nil, map[string]string{"B": "2"})
937+
assert.Equal(t, map[string]string{"B": "2"}, result)
938+
})
939+
940+
t.Run("override wins on conflict", func(t *testing.T) {
941+
base := map[string]string{"A": "1", "B": "2"}
942+
override := map[string]string{"B": "override", "C": "3"}
943+
result := mergeHeaders(base, override)
944+
assert.Equal(t, map[string]string{"A": "1", "B": "override", "C": "3"}, result)
945+
})
946+
947+
t.Run("case-insensitive header merge: override wins", func(t *testing.T) {
948+
base := map[string]string{"cookie": "static"}
949+
override := map[string]string{"Cookie": "from-request"}
950+
result := mergeHeaders(base, override)
951+
assert.Len(t, result, 1)
952+
assert.Equal(t, "from-request", result["Cookie"], "forwarded request value must win over extra header")
953+
})
954+
}
955+
956+
func TestExtractGrafanaInfoFromHeadersForwardedHeaders(t *testing.T) {
957+
t.Run("forwarded headers merged into config", func(t *testing.T) {
958+
t.Setenv("GRAFANA_EXTRA_HEADERS", `{"X-Tenant-ID": "tenant-123"}`)
959+
t.Setenv("GRAFANA_FORWARD_HEADERS", "Cookie")
960+
req, _ := http.NewRequest("GET", "http://example.com", nil)
961+
req.Header.Set("Cookie", "session=user1")
962+
963+
ctx := ExtractGrafanaInfoFromHeaders(context.Background(), req)
964+
config := GrafanaConfigFromContext(ctx)
965+
assert.Equal(t, map[string]string{
966+
"X-Tenant-Id": "tenant-123",
967+
"Cookie": "session=user1",
968+
}, config.ExtraHeaders)
969+
})
970+
971+
t.Run("forwarded header overrides extra header with same name", func(t *testing.T) {
972+
t.Setenv("GRAFANA_EXTRA_HEADERS", `{"Cookie": "static-cookie"}`)
973+
t.Setenv("GRAFANA_FORWARD_HEADERS", "Cookie")
974+
req, _ := http.NewRequest("GET", "http://example.com", nil)
975+
req.Header.Set("Cookie", "dynamic-cookie")
976+
977+
ctx := ExtractGrafanaInfoFromHeaders(context.Background(), req)
978+
config := GrafanaConfigFromContext(ctx)
979+
assert.Equal(t, "dynamic-cookie", config.ExtraHeaders["Cookie"])
980+
})
981+
982+
t.Run("no forward env uses only extra headers", func(t *testing.T) {
983+
t.Setenv("GRAFANA_EXTRA_HEADERS", `{"X-Tenant-ID": "tenant-789"}`)
984+
t.Setenv("GRAFANA_FORWARD_HEADERS", "")
985+
req, _ := http.NewRequest("GET", "http://example.com", nil)
986+
req.Header.Set("Cookie", "session=ignored")
987+
988+
ctx := ExtractGrafanaInfoFromHeaders(context.Background(), req)
989+
config := GrafanaConfigFromContext(ctx)
990+
assert.Equal(t, map[string]string{"X-Tenant-ID": "tenant-789"}, config.ExtraHeaders)
991+
})
992+
993+
t.Run("forward header not present in request", func(t *testing.T) {
994+
t.Setenv("GRAFANA_EXTRA_HEADERS", "")
995+
t.Setenv("GRAFANA_FORWARD_HEADERS", "Cookie")
996+
req, _ := http.NewRequest("GET", "http://example.com", nil)
997+
998+
ctx := ExtractGrafanaInfoFromHeaders(context.Background(), req)
999+
config := GrafanaConfigFromContext(ctx)
1000+
assert.Nil(t, config.ExtraHeaders)
1001+
})
1002+
1003+
t.Run("forwarded Cookie wins when extra headers has lowercase cookie", func(t *testing.T) {
1004+
t.Setenv("GRAFANA_EXTRA_HEADERS", `{"cookie": "static"}`)
1005+
t.Setenv("GRAFANA_FORWARD_HEADERS", "Cookie")
1006+
req, _ := http.NewRequest("GET", "http://example.com", nil)
1007+
req.Header.Set("Cookie", "session=user2")
1008+
1009+
ctx := ExtractGrafanaInfoFromHeaders(context.Background(), req)
1010+
config := GrafanaConfigFromContext(ctx)
1011+
assert.Len(t, config.ExtraHeaders, 1)
1012+
assert.Equal(t, "session=user2", config.ExtraHeaders["Cookie"], "incoming request must take precedence regardless of extra header key case")
1013+
})
1014+
}
1015+
8681016
func TestOrgIDRoundTripper(t *testing.T) {
8691017
t.Run("adds org ID header to request", func(t *testing.T) {
8701018
var capturedReq *http.Request

server.json

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,13 @@
5757
"format": "string",
5858
"isSecret": false,
5959
"name": "GRAFANA_EXTRA_HEADERS"
60+
},
61+
{
62+
"description": "Comma-separated list of HTTP header names to forward from the incoming request to Grafana (SSE/streamable-http only). Example: Cookie,X-Session-Id",
63+
"isRequired": false,
64+
"format": "string",
65+
"isSecret": false,
66+
"name": "GRAFANA_FORWARD_HEADERS"
6067
}
6168
]
6269
}

0 commit comments

Comments
 (0)