Repository navigation
Expand file tree
/
Copy pathmcpgrafana.go
More file actions
1903 lines (1690 loc) · 73.9 KB
/
Copy pathmcpgrafana.go
File metadata and controls
1903 lines (1690 loc) · 73.9 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
package mcpgrafana
import (
"context"
"crypto/tls"
"crypto/x509"
"encoding/json"
"fmt"
"io"
"log/slog"
"net"
"net/http"
"net/http/httputil"
"net/textproto"
"net/url"
"os"
"reflect"
"runtime/debug"
"strconv"
"strings"
"sync"
"time"
"github.com/go-openapi/runtime"
openapiclient "github.com/go-openapi/runtime/client"
"github.com/go-openapi/strfmt"
"github.com/grafana/grafana-openapi-client-go/client"
"github.com/grafana/incident-go"
"github.com/modelcontextprotocol/go-sdk/mcp"
"github.com/prometheus/prometheus/model/labels"
"go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"
"go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/metric"
"golang.org/x/sync/singleflight"
)
const (
defaultGrafanaHost = "localhost:3000"
defaultGrafanaURL = "http://" + defaultGrafanaHost
grafanaURLEnvVar = "GRAFANA_URL"
grafanaServiceAccountTokenEnvVar = "GRAFANA_SERVICE_ACCOUNT_TOKEN"
grafanaServiceAccountTokenFileEnvVar = "GRAFANA_SERVICE_ACCOUNT_TOKEN_FILE"
grafanaAPIEnvVar = "GRAFANA_API_KEY" // Deprecated: use GRAFANA_SERVICE_ACCOUNT_TOKEN instead
grafanaOrgIDEnvVar = "GRAFANA_ORG_ID"
grafanaUsernameEnvVar = "GRAFANA_USERNAME"
grafanaPasswordEnvVar = "GRAFANA_PASSWORD"
grafanaExtraHeadersEnvVar = "GRAFANA_EXTRA_HEADERS"
grafanaForwardHeadersEnvVar = "GRAFANA_FORWARD_HEADERS"
grafanaURLHeader = "X-Grafana-URL"
grafanaServiceAccountTokenHeader = "X-Grafana-Service-Account-Token"
grafanaAPIKeyHeader = "X-Grafana-API-Key" // Deprecated: use X-Grafana-Service-Account-Token instead
)
// lookupEnv is os.LookupEnv, except that an MCPB host's unsubstituted
// "${user_config.*}" placeholder (left for blank optional fields) counts as unset.
func lookupEnv(key string) (string, bool) {
v, ok := os.LookupEnv(key)
if strings.HasPrefix(v, "${user_config.") && strings.HasSuffix(v, "}") {
return "", false
}
return v, ok
}
func getEnv(key string) string {
v, _ := lookupEnv(key)
return v
}
func urlAndAPIKeyFromEnv(logger *slog.Logger) (string, string) {
u := normalizeGrafanaURL(getEnv(grafanaURLEnvVar))
// Check for the new service account token environment variable first.
apiKey := getEnv(grafanaServiceAccountTokenEnvVar)
if apiKey != "" {
return u, apiKey
}
// Next, check for a file-based service account token. This is read fresh on
// every call so that rotated tokens (e.g. a Kubernetes Secret mounted as a
// volume) are picked up without restarting the server. See issue #800.
if tokenFile := getEnv(grafanaServiceAccountTokenFileEnvVar); tokenFile != "" {
token, err := os.ReadFile(tokenFile)
if err != nil {
logger.Warn("Failed to read GRAFANA_SERVICE_ACCOUNT_TOKEN_FILE, ignoring", "path", tokenFile, "error", err)
} else if apiKey = strings.TrimSpace(string(token)); apiKey != "" {
return u, apiKey
}
}
// Fall back to the deprecated API key environment variable
apiKey = getEnv(grafanaAPIEnvVar)
if apiKey != "" {
logger.Warn("GRAFANA_API_KEY is deprecated, please use GRAFANA_SERVICE_ACCOUNT_TOKEN instead. See https://grafana.com/docs/grafana/latest/administration/service-accounts/#add-a-token-to-a-service-account-in-grafana for details on creating service account tokens.")
}
return u, apiKey
}
func userAndPassFromEnv() *url.Userinfo {
username := getEnv(grafanaUsernameEnvVar)
password, exists := lookupEnv(grafanaPasswordEnvVar)
if username == "" && password == "" {
return nil
}
if !exists {
return url.User(username)
}
return url.UserPassword(username, password)
}
func orgIdFromEnv(logger *slog.Logger) int64 {
orgIDStr := getEnv(grafanaOrgIDEnvVar)
if orgIDStr == "" {
return 0
}
orgID, err := strconv.ParseInt(orgIDStr, 10, 64)
if err != nil {
logger.Warn("Invalid GRAFANA_ORG_ID value, ignoring", "value", orgIDStr, "error", err)
return 0
}
return orgID
}
func extraHeadersFromEnv(logger *slog.Logger) map[string]string {
headersJSON := os.Getenv(grafanaExtraHeadersEnvVar)
if headersJSON == "" {
return nil
}
var headers map[string]string
if err := json.Unmarshal([]byte(headersJSON), &headers); err != nil {
logger.Warn("invalid GRAFANA_EXTRA_HEADERS value, ignoring", "value", headersJSON, "error", err)
return nil
}
return headers
}
func forwardHeaderNamesFromEnv() []string {
raw := os.Getenv(grafanaForwardHeadersEnvVar)
if raw == "" {
return nil
}
parts := strings.Split(raw, ",")
names := make([]string, 0, len(parts))
for _, p := range parts {
p = strings.TrimSpace(p)
if p != "" {
names = append(names, p)
}
}
return names
}
// forwardedHeadersFromRequest reads GRAFANA_FORWARD_HEADERS and copies matching
// headers from the incoming HTTP request. Returns nil when no headers match.
func forwardedHeadersFromRequest(req *http.Request) map[string]string {
names := forwardHeaderNamesFromEnv()
if len(names) == 0 {
return nil
}
var forwarded map[string]string
for _, name := range names {
if v := req.Header.Get(name); v != "" {
if forwarded == nil {
forwarded = make(map[string]string, len(names))
}
forwarded[name] = v
}
}
return forwarded
}
// mergeHeaders returns a new map containing all entries from base, with entries
// from override taking precedence. When both maps are non-empty, header names
// are canonicalized (via textproto.CanonicalMIMEHeaderKey) so that
// case-insensitive matches are merged correctly and the documented
// guarantee—incoming request wins—is upheld. When only one side is present the
// original key casing is preserved.
func mergeHeaders(base, override map[string]string) map[string]string {
if len(base) == 0 && len(override) == 0 {
return nil
}
if len(override) == 0 {
return base
}
if len(base) == 0 {
return override
}
merged := make(map[string]string, len(base)+len(override))
for k, v := range base {
merged[textproto.CanonicalMIMEHeaderKey(k)] = v
}
for k, v := range override {
merged[textproto.CanonicalMIMEHeaderKey(k)] = v
}
return merged
}
func orgIdFromHeaders(req *http.Request, logger *slog.Logger) int64 {
orgIDStr := req.Header.Get(client.OrgIDHeader)
if orgIDStr == "" {
return 0
}
orgID, err := strconv.ParseInt(orgIDStr, 10, 64)
if err != nil {
logger.Warn("Invalid X-Grafana-Org-Id header value, ignoring", "value", orgIDStr, "error", err)
return 0
}
return orgID
}
func apiKeyFromHeaders(req *http.Request) string {
// Check for the new service account token header first
apiKey := req.Header.Get(grafanaServiceAccountTokenHeader)
if apiKey != "" {
return apiKey
}
// Fall back to the deprecated API key header
return req.Header.Get(grafanaAPIKeyHeader)
}
// grafanaConfigKey is the context key for Grafana configuration.
type grafanaConfigKey struct{}
// TLSConfig holds TLS configuration for Grafana clients.
// It supports mutual TLS authentication with client certificates, custom CA certificates for server verification, and development options like skipping certificate verification.
type TLSConfig struct {
CertFile string
KeyFile string
CAFile string
SkipVerify bool
}
// Loki guardrail modes for GrafanaConfig.LokiGuardrailMode.
const (
// LokiGuardrailOff disables the Loki query cost guardrail (default).
LokiGuardrailOff = "off"
// LokiGuardrailShadow evaluates the guardrail and logs queries that
// would be blocked, but always lets them run. It pays the same
// index/stats round trip as enforce mode.
LokiGuardrailShadow = "shadow"
// LokiGuardrailEnforce rejects blocked queries with a tool error
// containing rewrite guidance.
LokiGuardrailEnforce = "enforce"
)
// GrafanaConfig represents the full configuration for Grafana clients.
// It includes connection details, authentication credentials, debug settings, and TLS options used throughout the MCP server's lifecycle.
type GrafanaConfig struct {
// Debug enables debug mode for the Grafana client.
Debug bool
// IncludeArgumentsInSpans enables logging of tool arguments in OpenTelemetry spans.
// This should only be enabled in non-production environments or when you're certain
// the arguments don't contain PII. Defaults to false for safety.
// Note: OpenTelemetry spans are always created for context propagation, but arguments
// are only included when this flag is enabled.
IncludeArgumentsInSpans bool
// URL is the URL of the Grafana instance.
URL string
// OverrideURL pins outbound requests to a request-selected Grafana base URL.
// It is set only after the HTTP override middleware authorizes the target.
OverrideURL string
// AllowGrafanaURLOverride enables request-selected URLs on HTTP transports.
// Without an allowlist, callers may select any valid HTTP(S) URL.
AllowGrafanaURLOverride bool
// AllowedGrafanaURLs optionally restricts selection to exact targets.
AllowedGrafanaURLs []string
// AllowCrossOriginRedirects permits HTTP redirects to a different origin.
// It disables the default redirect guard for clients built with BuildTransport.
AllowCrossOriginRedirects bool
// APIKey is the API key or service account token for the Grafana instance.
// It may be empty if we are using on-behalf-of auth.
APIKey string
// Credentials if user is using basic auth
BasicAuth *url.Userinfo
// OrgID is the organization ID to use for multi-org support.
// When set, it will be sent as X-Grafana-Org-Id header regardless of authentication method.
// Works with service account tokens, API keys, and basic authentication.
OrgID int64
// AccessToken is the Grafana Cloud access policy token used for on-behalf-of auth in Grafana Cloud.
AccessToken string
// IDToken is an ID token identifying the user for the current request.
// It comes from the `X-Grafana-Id` header sent from Grafana to plugin backends.
// It is used for on-behalf-of auth in Grafana Cloud.
IDToken string
// TLSConfig holds TLS configuration for all Grafana clients.
TLSConfig *TLSConfig
// Timeout specifies a time limit for requests made by the Grafana client.
// A Timeout of zero means no timeout.
// Default is 10 seconds.
Timeout time.Duration
// ExtraHeaders contains additional HTTP headers to send with all Grafana API requests.
// Parsed from GRAFANA_EXTRA_HEADERS environment variable as JSON object.
ExtraHeaders map[string]string
// SOCKS5ProxyURL is an optional SOCKS5 proxy URL (socks5:// or socks5h://,
// treated identically: hostname resolution is delegated to the proxy)
// applied to all Grafana traffic built via BuildTransport. It is scoped to
// this configuration and independent of the HTTP_PROXY/HTTPS_PROXY
// environment variables. The CLI populates it from GRAFANA_SOCKS5_PROXY.
// When set, BuildTransport returns an error if BaseTransport is present
// but is not an *http.Transport, since the proxy cannot be applied to an
// opaque RoundTripper.
SOCKS5ProxyURL string
// MaxLokiLogLimit is the maximum number of log lines that can be returned
// from Loki queries.
MaxLokiLogLimit int
// LokiGuardrailMode controls the query cost guardrail for query_loki_logs.
// One of LokiGuardrailOff (default), LokiGuardrailShadow, or
// LokiGuardrailEnforce. Loki does not enforce max_query_bytes_read on log
// queries without a line filter, so the guardrail requires selective
// stream selectors, bounds the effective time range, and pre-checks the
// byte estimate from Loki's index/stats API before admitting a query.
LokiGuardrailMode string
// LokiGuardrailMaxBytes is the maximum number of bytes a single
// query_loki_logs call may scan, estimated via Loki's index/stats API
// before the query runs. Zero disables the byte-budget check.
LokiGuardrailMaxBytes int64
// LokiGuardrailMaxRange is the maximum effective time range allowed for
// a single query_loki_logs call, including range-vector durations like
// [30d]. Zero disables the range check.
LokiGuardrailMaxRange time.Duration
// LokiEnforcedMatchers, when non-empty, is a set of label matchers that are
// AND-ed into every stream selector of every native-Loki query the server
// issues (query, stats, patterns, and label enumeration). It lets an
// operator restrict which log streams the MCP can ever read (e.g.
// `namespace!~"vault|payments"` to exclude streams that may contain
// sensitive information).
// Parsed once at startup; an empty slice disables enforcement.
// NOTE: this is only effective if raw datasource-proxy access is disabled
// (see --disable-api); otherwise a query can bypass the Loki tools entirely.
LokiEnforcedMatchers []*labels.Matcher
// LokiLabelEnumerationFallback controls what the label-enumeration tools
// (list_loki_label_names / list_loki_label_values) do when the enforced
// matchers cannot be applied to them — which happens only when the enforced
// set is purely negative, because Loki rejects a standalone selector with no
// positive matcher. Positive/allowlist matchers always scope cleanly and
// never hit this fallback. Valid values: "reject" (default, fail closed) and
// "unfiltered" (allow unscoped enumeration; never exposes log lines, only
// low-cardinality label metadata). Empty means "reject".
LokiLabelEnumerationFallback string
// BaseTransport is an optional base HTTP transport used as the innermost
// layer of the middleware chain in NewGrafanaClient. When set, it replaces
// the default http.Transport that NewGrafanaClient would otherwise create.
// The caller can use this to provide a pre-configured transport with custom
// connection pooling, timeouts, or tracing instrumentation.
// Note: NewGrafanaClient still wraps this transport with ExtraHeaders,
// OrgID, UserAgent, and otelhttp layers.
BaseTransport http.RoundTripper
// Logger is an optional structured logger. When set, functions that have
// access to the GrafanaConfig will use this logger instead of the global
// slog.Default(). This allows callers (e.g. the hosted Cloud MCP server)
// to inject their own slog.Logger for consistent structured logging with
// per-request context such as tenant_id.
Logger *slog.Logger
// UserAgent overrides the default "mcp-grafana/<version>" User-Agent
// header sent with every Grafana API request. Embedders (e.g. the
// hosted Cloud MCP server) can set this to distinguish their traffic
// from the open-source CLI. When empty the default is used.
UserAgent string
// MeterProvider is an optional OTel metric.MeterProvider used by
// instrumentation that lives inside tool handlers (which have no
// constructor to take a WithXxxMeterProvider option), such as the Loki
// cost guardrail. When unset, otel.GetMeterProvider() is used.
//
// Setting this matters for embedders whose process installs a noop
// global provider: without it every recording is silently dropped. It
// mirrors Logger above — the same injection point, for the other signal.
MeterProvider metric.MeterProvider
}
// HTTPTransport returns the base HTTP transport for this config.
// If BaseTransport is set it is returned; otherwise http.DefaultTransport.
func (c GrafanaConfig) HTTPTransport() http.RoundTripper {
if c.BaseTransport != nil {
return c.BaseTransport
}
return http.DefaultTransport
}
// LoggerOrDefault returns the configured logger, or slog.Default() if none is set.
func (c GrafanaConfig) LoggerOrDefault() *slog.Logger {
if c.Logger != nil {
return c.Logger
}
return slog.Default()
}
// MeterProviderOrDefault returns the configured meter provider, or the global
// otel.GetMeterProvider() if none is set.
func (c GrafanaConfig) MeterProviderOrDefault() metric.MeterProvider {
if c.MeterProvider != nil {
return c.MeterProvider
}
return otel.GetMeterProvider()
}
// LoggerFromContext extracts the logger from the GrafanaConfig in the context.
// Returns slog.Default() if no config or logger is set.
func LoggerFromContext(ctx context.Context) *slog.Logger {
return GrafanaConfigFromContext(ctx).LoggerOrDefault()
}
const (
// DefaultGrafanaClientTimeout is the default timeout for Grafana HTTP client requests.
DefaultGrafanaClientTimeout = 10 * time.Second
)
// WithGrafanaConfig adds Grafana configuration to the context.
// This configuration includes API credentials, debug settings, and TLS options that will be used by all Grafana clients created from this context.
func WithGrafanaConfig(ctx context.Context, config GrafanaConfig) context.Context {
config.URL = normalizeGrafanaURL(config.URL)
return context.WithValue(ctx, grafanaConfigKey{}, config)
}
// normalizeGrafanaURL cleans up a configured Grafana URL so that requests built
// from it are well-formed and don't trip avoidable redirects. It trims
// surrounding whitespace and trailing slashes, and supplies a scheme when none
// is provided — a schemeless value like "grafana.example.com" would otherwise
// be parsed as a relative path and produce broken request URLs. Schemeless
// local addresses default to http (local Grafana rarely serves TLS); everything
// else defaults to https. An empty input is returned unchanged.
func normalizeGrafanaURL(raw string) string {
u := strings.TrimRight(strings.TrimSpace(raw), "/")
if u == "" {
return ""
}
if !hasScheme(u) {
if isLocalHostPort(u) {
u = "http://" + u
} else {
u = "https://" + u
}
}
return u
}
// hasScheme reports whether u begins with a URL scheme (e.g. "https://"). It
// looks for "://" in scheme position rather than anywhere in the string, so a
// schemeless URL whose path or query happens to contain "://" (e.g. a query
// parameter holding another URL) is still recognised as needing a scheme.
func hasScheme(u string) bool {
i := strings.Index(u, "://")
if i <= 0 {
return false
}
// Anything path-, query-, or fragment-like before the "://" means it isn't
// a real scheme separator.
return !strings.ContainsAny(u[:i], "/?#")
}
// isLocalHostPort reports whether a schemeless URL points at a local address,
// e.g. "localhost:3000", "127.0.0.1", or "[::1]:3000".
func isLocalHostPort(hostPort string) bool {
// Drop any path/query so only the host[:port] is inspected.
if i := strings.IndexAny(hostPort, "/?"); i >= 0 {
hostPort = hostPort[:i]
}
host := hostPort
if h, _, err := net.SplitHostPort(hostPort); err == nil {
host = h
}
host = strings.ToLower(strings.Trim(host, "[]"))
return host == "localhost" || strings.HasSuffix(host, ".localhost") ||
host == "127.0.0.1" || host == "::1"
}
// GrafanaConfigFromContext extracts Grafana configuration from the context.
// If no config is found, returns a zero-value GrafanaConfig. This function is typically used by internal components to access configuration set earlier in the request lifecycle.
func GrafanaConfigFromContext(ctx context.Context) GrafanaConfig {
if config, ok := ctx.Value(grafanaConfigKey{}).(GrafanaConfig); ok {
return config
}
return GrafanaConfig{}
}
// CreateTLSConfig creates a *tls.Config from TLSConfig.
// It supports client certificates, custom CA certificates, and the option to skip TLS verification for development environments.
func (tc *TLSConfig) CreateTLSConfig() (*tls.Config, error) {
if tc == nil {
return nil, nil
}
tlsConfig := &tls.Config{
InsecureSkipVerify: tc.SkipVerify,
}
// Load client certificate if both cert and key files are provided
if tc.CertFile != "" && tc.KeyFile != "" {
cert, err := tls.LoadX509KeyPair(tc.CertFile, tc.KeyFile)
if err != nil {
return nil, fmt.Errorf("failed to load client certificate: %w", err)
}
tlsConfig.Certificates = []tls.Certificate{cert}
}
// Load CA certificate if provided
if tc.CAFile != "" {
caCert, err := os.ReadFile(tc.CAFile)
if err != nil {
return nil, fmt.Errorf("failed to read CA certificate: %w", err)
}
caCertPool := x509.NewCertPool()
if !caCertPool.AppendCertsFromPEM(caCert) {
return nil, fmt.Errorf("failed to parse CA certificate")
}
tlsConfig.RootCAs = caCertPool
}
return tlsConfig, nil
}
// HTTPTransport creates an HTTP transport with custom TLS configuration.
// It clones the provided transport and applies the TLS settings, preserving other transport configurations like timeouts and connection pools.
func (tc *TLSConfig) HTTPTransport(defaultTransport *http.Transport) (http.RoundTripper, error) {
transport := defaultTransport.Clone()
if tc != nil {
tlsCfg, err := tc.CreateTLSConfig()
if err != nil {
return nil, err
}
transport.TLSClientConfig = tlsCfg
}
return transport, nil
}
// UserAgentTransport wraps an http.RoundTripper to add a custom User-Agent header.
// This ensures all HTTP requests from the MCP server are properly identified with version information for debugging and analytics.
type UserAgentTransport struct {
rt http.RoundTripper
UserAgent string
}
func (t *UserAgentTransport) RoundTrip(req *http.Request) (*http.Response, error) {
// Clone the request to avoid modifying the original
clonedReq := req.Clone(req.Context())
// Add or update the User-Agent header
if clonedReq.Header.Get("User-Agent") == "" {
clonedReq.Header.Set("User-Agent", t.UserAgent)
}
return t.rt.RoundTrip(clonedReq)
}
// version is set at build time via ldflags:
//
// -X github.com/grafana/mcp-grafana/v2.version=v1.2.3
var version string
// Version returns the version of the mcp-grafana binary.
// It prefers an ldflags-injected value, then falls back to runtime/debug build info,
// and finally returns "(devel)" for local development builds.
var Version = sync.OnceValue(func() string {
if version != "" {
return version
}
if bi, ok := debug.ReadBuildInfo(); ok && bi.Main.Version != "" {
return bi.Main.Version
}
return "(devel)"
})
// UserAgent returns the user agent string for HTTP requests.
// It includes the mcp-grafana identifier and version number for proper request attribution and debugging.
func UserAgent() string {
return fmt.Sprintf("mcp-grafana/%s", Version())
}
// NewUserAgentTransport creates a new UserAgentTransport with the specified user agent.
// If no user agent is provided, it uses the default UserAgent() with version information.
// The transport wraps the provided RoundTripper, defaulting to http.DefaultTransport if nil.
func NewUserAgentTransport(rt http.RoundTripper, userAgent ...string) *UserAgentTransport {
if rt == nil {
rt = http.DefaultTransport
}
ua := UserAgent() // default
if len(userAgent) > 0 {
ua = userAgent[0]
}
return &UserAgentTransport{
rt: rt,
UserAgent: ua,
}
}
// OrgIDRoundTripper wraps an http.RoundTripper to add the X-Grafana-Org-Id header.
type OrgIDRoundTripper struct {
underlying http.RoundTripper
orgID int64
}
func (t *OrgIDRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
clonedReq := req.Clone(req.Context())
orgID := t.orgID
if cfg := GrafanaConfigFromContext(req.Context()); cfg.OrgID > 0 {
orgID = cfg.OrgID
}
if orgID > 0 {
clonedReq.Header.Set(client.OrgIDHeader, strconv.FormatInt(orgID, 10))
}
return t.underlying.RoundTrip(clonedReq)
}
func NewOrgIDRoundTripper(rt http.RoundTripper, orgID int64) *OrgIDRoundTripper {
if rt == nil {
rt = http.DefaultTransport
}
return &OrgIDRoundTripper{
underlying: rt,
orgID: orgID,
}
}
type ExtraHeadersRoundTripper struct {
underlying http.RoundTripper
headers map[string]string
}
func (t *ExtraHeadersRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
clonedReq := req.Clone(req.Context())
headers := t.headers
if cfg := GrafanaConfigFromContext(req.Context()); len(cfg.ExtraHeaders) > 0 {
headers = mergeHeaders(t.headers, cfg.ExtraHeaders)
}
if len(headers) > 0 {
propagated := propagatedHeaderFields()
for k, v := range headers {
// Never overwrite a trace-context header the OTel propagator has
// already injected for this request. A traceparent forwarded from
// the incoming request (GRAFANA_FORWARD_HEADERS) names the caller's
// span, so writing it here would re-parent Grafana's spans onto the
// caller and cut mcp-grafana out of the middle of the trace. When
// nothing was injected — tracing not wired up, or no active span —
// the forwarded value is still applied, preserving the behaviour
// operators relied on before the propagator existed.
if _, ok := propagated[textproto.CanonicalMIMEHeaderKey(k)]; ok && clonedReq.Header.Get(k) != "" {
continue
}
clonedReq.Header.Set(k, v)
}
}
return t.underlying.RoundTrip(clonedReq)
}
// propagatedHeaderFields returns the canonicalised names of the headers the
// globally configured OTel TextMapPropagator writes when injecting trace
// context (e.g. traceparent, tracestate, baggage). Returns nil when no
// propagator is configured, i.e. when nothing is ever injected.
func propagatedHeaderFields() map[string]struct{} {
fields := otel.GetTextMapPropagator().Fields()
if len(fields) == 0 {
return nil
}
set := make(map[string]struct{}, len(fields))
for _, f := range fields {
set[textproto.CanonicalMIMEHeaderKey(f)] = struct{}{}
}
return set
}
func NewExtraHeadersRoundTripper(rt http.RoundTripper, headers map[string]string) *ExtraHeadersRoundTripper {
if rt == nil {
rt = http.DefaultTransport
}
return &ExtraHeadersRoundTripper{
underlying: rt,
headers: headers,
}
}
// 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.
type AuthRoundTripper struct {
accessToken string
idToken string
apiKey string
basicAuth *url.Userinfo
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
cfg := GrafanaConfigFromContext(req.Context())
if cfg.AccessToken != "" {
accessToken = cfg.AccessToken
}
if cfg.IDToken != "" {
idToken = cfg.IDToken
}
if cfg.APIKey != "" {
apiKey = cfg.APIKey
}
if cfg.BasicAuth != nil {
basicAuth = cfg.BasicAuth
}
if accessToken != "" && idToken != "" {
clonedReq.Header.Set("X-Access-Token", accessToken)
clonedReq.Header.Set("X-Grafana-Id", idToken)
} else if apiKey != "" {
clonedReq.Header.Set("Authorization", "Bearer "+apiKey)
} else if basicAuth != nil {
password, _ := basicAuth.Password()
clonedReq.SetBasicAuth(basicAuth.Username(), password)
}
return rt.underlying.RoundTrip(clonedReq)
}
func NewAuthRoundTripper(rt http.RoundTripper, accessToken, idToken, apiKey string, basicAuth *url.Userinfo) *AuthRoundTripper {
if rt == nil {
rt = http.DefaultTransport
}
return &AuthRoundTripper{
accessToken: accessToken,
idToken: idToken,
apiKey: apiKey,
basicAuth: basicAuth,
underlying: rt,
}
}
// sensitiveHeaders lists HTTP header names whose values must be redacted in
// debug logs to prevent credential leakage (see #919).
var sensitiveHeaders = map[string]bool{
"Authorization": true,
"X-Access-Token": true,
"X-Grafana-Id": true,
"X-Grafana-Service-Account-Token": true,
"X-Grafana-Api-Key": true,
"Cookie": true,
}
// redactHeaderValue masks the middle portion of a credential value,
// preserving the first 4 and last 4 characters for identification.
// Values shorter than 12 characters are fully replaced.
func redactHeaderValue(v string) string {
if len(v) < 12 {
return "[REDACTED]"
}
return v[:4] + "***" + v[len(v)-4:]
}
// debugLoggingRoundTripper logs HTTP requests and responses with sensitive
// headers redacted. It replaces the go-openapi Debug flag, which uses
// httputil.DumpRequestOut and exposes credentials in plaintext.
type debugLoggingRoundTripper struct {
underlying http.RoundTripper
logger *slog.Logger
}
func (rt *debugLoggingRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
redacted := req.Clone(req.Context())
redacted.Body = nil
for name := range sensitiveHeaders {
if v := redacted.Header.Get(name); v != "" {
redacted.Header.Set(name, redactHeaderValue(v))
}
}
if dump, err := httputil.DumpRequestOut(redacted, false); err == nil {
rt.logger.Debug(string(dump))
}
resp, err := rt.underlying.RoundTrip(req)
if err != nil {
return resp, err
}
if dump, dumpErr := httputil.DumpResponse(resp, false); dumpErr == nil {
rt.logger.Debug(string(dump))
}
return resp, nil
}
// transportOptions controls which middleware layers BuildTransport includes.
type transportOptions struct {
withoutAuth bool
withoutOrgID bool
withoutOtel bool
withoutUserAgent bool
}
// TransportOption configures optional behaviour of BuildTransport.
type TransportOption func(*transportOptions)
// WithoutAuth skips the authentication middleware layer.
// Use this when the HTTP client library handles auth itself (e.g. OnCall, incident).
func WithoutAuth() TransportOption {
return func(o *transportOptions) { o.withoutAuth = true }
}
// WithoutOrgID skips the X-Grafana-Org-Id header layer.
func WithoutOrgID() TransportOption {
return func(o *transportOptions) { o.withoutOrgID = true }
}
// WithoutOtel skips the otelhttp tracing wrapper.
func WithoutOtel() TransportOption {
return func(o *transportOptions) { o.withoutOtel = true }
}
// WithoutUserAgent skips the User-Agent header layer.
func WithoutUserAgent() TransportOption {
return func(o *transportOptions) { o.withoutUserAgent = true }
}
// BuildTransport constructs an http.RoundTripper with the standard middleware
// chain derived from cfg. The default chain (innermost to outermost) is:
//
// base → TLS → redirectGuard → debugLogging → Auth → ExtraHeaders → OrgID → UserAgent → otelhttp
//
// Auth is innermost among the header-setting layers so that credentials take
// precedence over any forwarded/extra headers with the same keys.
//
// When cfg.Debug is true a debug-logging layer is added just above the base
// transport. It sees the fully-decorated request (all headers set by outer
// layers) and redacts sensitive values (Authorization, X-Access-Token, etc.)
// before writing request/response details to the logger.
//
// Individual layers can be disabled with WithoutAuth, WithoutOrgID, etc.
func BuildTransport(cfg *GrafanaConfig, base http.RoundTripper, opts ...TransportOption) (http.RoundTripper, error) {
var options transportOptions
for _, o := range opts {
o(&options)
}
if base == nil {
base = cfg.HTTPTransport()
}
// SOCKS5 proxy (applied to the innermost *http.Transport so the TLS layer
// below clones it with the proxy already in place).
if cfg.SOCKS5ProxyURL != "" {
proxyURL, err := parseSOCKS5ProxyURL(cfg.SOCKS5ProxyURL)
if err != nil {
return nil, err
}
t, ok := base.(*http.Transport)
if !ok {
return nil, fmt.Errorf("SOCKS5 proxy requires the base transport to be an *http.Transport, got %T", base)
}
t = t.Clone()
t.Proxy = http.ProxyURL(proxyURL)
base = t
}
transport := base
// TLS
if cfg.TLSConfig != nil {
t, ok := base.(*http.Transport)
if !ok {
t = http.DefaultTransport.(*http.Transport).Clone()
}
var err error
transport, err = cfg.TLSConfig.HTTPTransport(t)
if err != nil {
return nil, fmt.Errorf("failed to create TLS transport: %w", err)
}
}
if !cfg.AllowCrossOriginRedirects {
transport = &redirectGuardTransport{next: transport}
}
// Debug logging with redacted credentials (innermost among the
// non-TLS layers so it sees the final request with all headers).
if cfg.Debug {
transport = &debugLoggingRoundTripper{
underlying: transport,
logger: cfg.LoggerOrDefault(),
}
}
// Auth (innermost header layer — wins on conflicts with ExtraHeaders)
if !options.withoutAuth {
transport = NewAuthRoundTripper(transport, cfg.AccessToken, cfg.IDToken, cfg.APIKey, cfg.BasicAuth)
}
// Extra headers (always included so per-request context overrides work)
transport = NewExtraHeadersRoundTripper(transport, cfg.ExtraHeaders)
// Org ID (always included so per-request context overrides work)
if !options.withoutOrgID {
transport = NewOrgIDRoundTripper(transport, cfg.OrgID)
}
// User-Agent
if !options.withoutUserAgent {
if cfg.UserAgent != "" {
transport = NewUserAgentTransport(transport, cfg.UserAgent)
} else {
transport = NewUserAgentTransport(transport)
}
}
// OpenTelemetry HTTP tracing (outermost)
if !options.withoutOtel {
transport = otelhttp.NewTransport(transport)
}
if cfg.OverrideURL != "" {
transport = &grafanaTargetTransport{baseURL: cfg.OverrideURL, next: transport}
}
return transport, nil
}
// Gets info from environment
func extractKeyGrafanaInfoFromEnv(logger *slog.Logger) (url, apiKey string, auth *url.Userinfo, orgId int64) {
url, apiKey = urlAndAPIKeyFromEnv(logger)
if url == "" {
url = defaultGrafanaURL
}
auth = userAndPassFromEnv()
orgId = orgIdFromEnv(logger)
return
}
// Gets the Grafana URL from the environment unless the HTTP override middleware
// selected a target. Selected targets use only request-scoped credentials.
func extractKeyGrafanaInfoFromReq(req *http.Request, logger *slog.Logger) (grafanaUrl, apiKey string, auth *url.Userinfo, orgId int64) {
if overrideURL, ok := req.Context().Value(grafanaOverrideKey{}).(string); ok && overrideURL != "" {
// This path is reachable only through GrafanaURLOverrideMiddleware.
// Never consult credentials from the environment for a selected target.
return overrideURL, apiKeyFromHeaders(req), nil, orgIdFromHeaders(req, logger)
}
eUrl, eApiKey, eAuth, eOrgId := extractKeyGrafanaInfoFromEnv(logger)
username, password, _ := req.BasicAuth()
grafanaUrl = eUrl
apiKey = apiKeyFromHeaders(req)
// Fall back to the environment token when none was supplied in the request.
if apiKey == "" {
apiKey = eApiKey
}
// Use request basic auth if supplied; otherwise fall back to the environment.
if username == "" && password == "" {
auth = eAuth
} else {
auth = url.UserPassword(username, password)
}
// extract org ID from header, fall back to environment.
// The org ID is not a secret, so it is not gated on the target URL.
orgId = orgIdFromHeaders(req, logger)
if orgId == 0 {
orgId = eOrgId
}
return
}
// StdioContextFunc extracts or modifies the context for the stdio transport.
// Unlike httpContextFunc, it runs once at server startup rather than per call,
// since a stdio process serves exactly one session for its whole lifetime.
type StdioContextFunc func(ctx context.Context) context.Context
// ExtractGrafanaInfoFromEnv is a StdioContextFunc that extracts Grafana configuration from environment variables.
// It reads GRAFANA_URL and GRAFANA_SERVICE_ACCOUNT_TOKEN (or deprecated GRAFANA_API_KEY) environment variables and adds the configuration to the context for use by Grafana clients.
var ExtractGrafanaInfoFromEnv StdioContextFunc = func(ctx context.Context) context.Context {
// Get existing config or create a new one.
// This will respect the existing debug flag, if set.
config := GrafanaConfigFromContext(ctx)
logger := config.LoggerOrDefault()