From 33074455697666a9e86f33294405f281d03adf3f Mon Sep 17 00:00:00 2001 From: Julien Pinsonneau Date: Tue, 24 Jun 2025 14:54:39 +0200 Subject: [PATCH 1/3] local config implementation for prometheus --- Makefile | 12 +++++ cmd/mcp-grafana/main.go | 61 ++++++++++++++++------ examples/tls_example.go | 10 ++-- mcpgrafana.go | 103 ++++++++++++++++++++++++++++++++++--- tools/prometheus.go | 111 +++++++++++++++++++++++++++++----------- 5 files changed, 240 insertions(+), 57 deletions(-) diff --git a/Makefile b/Makefile index 0975d5c7..9b6bb5a6 100644 --- a/Makefile +++ b/Makefile @@ -51,14 +51,26 @@ test-python-e2e: ## Run Python E2E tests (requires docker-compose services and S run: ## Run the MCP server in stdio mode. go run ./cmd/mcp-grafana +.PHONY: run-local +run-local: ## Run the MCP server in stdio mode without grafana config + go run ./cmd/mcp-grafana -log-level=debug -use-grafana-config=false + .PHONY: run-sse run-sse: ## Run the MCP server in SSE mode. go run ./cmd/mcp-grafana --transport sse --log-level debug --debug +.PHONY: run-sse-local +run-sse-local: ## Run the MCP server in SSE mode without grafana config. + go run ./cmd/mcp-grafana -transport sse --log-level debug -use-grafana-config=false + PHONY: run-streamable-http run-streamable-http: ## Run the MCP server in StreamableHTTP mode. go run ./cmd/mcp-grafana --transport streamable-http --log-level debug --debug +PHONY: run-streamable-http-local +run-streamable-http-local: ## Run the MCP server in StreamableHTTP mode without grafana config. + go run ./cmd/mcp-grafana --transport streamable-http --log-level debug -use-grafana-config=false + .PHONY: run-test-services run-test-services: ## Run the docker-compose services required for the unit and integration tests. docker compose up -d --build diff --git a/cmd/mcp-grafana/main.go b/cmd/mcp-grafana/main.go index 0828db31..ca698cff 100644 --- a/cmd/mcp-grafana/main.go +++ b/cmd/mcp-grafana/main.go @@ -102,30 +102,53 @@ func newServer(dt disabledTools) *server.MCPServer { return s } -func run(transport, addr, basePath, endpointPath string, logLevel slog.Level, dt disabledTools, gc mcpgrafana.GrafanaConfig) error { +func run(transport, addr, basePath, endpointPath string, logLevel slog.Level, dt disabledTools, gc *mcpgrafana.GrafanaConfig) error { slog.SetDefault(slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: logLevel}))) s := newServer(dt) + if gc == nil { + slog.Info("Skipping grafana config") + } + switch transport { case "stdio": srv := server.NewStdioServer(s) - srv.SetContextFunc(mcpgrafana.ComposedStdioContextFunc(gc)) + if gc != nil { + srv.SetContextFunc(mcpgrafana.ComposedStdioContextFunc(gc)) + } else { + srv.SetContextFunc(mcpgrafana.ComposeStdioContextFuncs(mcpgrafana.ExtractStdioLocalInfoFromEnv)) + } slog.Info("Starting Grafana MCP server using stdio transport") return srv.Listen(context.Background(), os.Stdin, os.Stdout) case "sse": - srv := server.NewSSEServer(s, - server.WithSSEContextFunc(mcpgrafana.ComposedSSEContextFunc(gc)), - server.WithStaticBasePath(basePath), - ) + opts := []server.SSEOption{server.WithStaticBasePath(basePath)} + if gc != nil { + opts = append(opts, server.WithSSEContextFunc(mcpgrafana.ComposedSSEContextFunc(gc))) + } else { + opts = append(opts, server.WithSSEContextFunc(mcpgrafana.ComposeSSEContextFuncs( + mcpgrafana.ExtractHttpLocalInfoFromEnv, + mcpgrafana.ExtractAuthorizationFromHeaders, + ))) + } + srv := server.NewSSEServer(s, opts...) slog.Info("Starting Grafana MCP server using SSE transport", "address", addr, "basePath", basePath) if err := srv.Start(addr); err != nil { return fmt.Errorf("Server error: %v", err) } case "streamable-http": - srv := server.NewStreamableHTTPServer(s, server.WithHTTPContextFunc(mcpgrafana.ComposedHTTPContextFunc(gc)), + opts := []server.StreamableHTTPOption{ server.WithStateLess(true), server.WithEndpointPath(endpointPath), - ) + } + if gc != nil { + opts = append(opts, server.WithHTTPContextFunc(mcpgrafana.ComposedHTTPContextFunc(gc))) + } else { + opts = append(opts, server.WithHTTPContextFunc(mcpgrafana.ComposeHTTPContextFuncs( + mcpgrafana.ExtractHttpLocalInfoFromEnv, + mcpgrafana.ExtractAuthorizationFromHeaders, + ))) + } + srv := server.NewStreamableHTTPServer(s, opts...) slog.Info("Starting Grafana MCP server using StreamableHTTP transport", "address", addr, "endpointPath", endpointPath) if err := srv.Start(addr); err != nil { return fmt.Errorf("Server error: %v", err) @@ -152,24 +175,28 @@ func main() { basePath := flag.String("base-path", "", "Base path for the sse server") endpointPath := flag.String("endpoint-path", "/mcp", "Endpoint path for the streamable-http server") logLevel := flag.String("log-level", "info", "Log level (debug, info, warn, error)") + useGrafanaConfig := flag.Bool("use-grafana-config", true, "Use grafana config") var dt disabledTools dt.addFlags() var gc grafanaConfig gc.addFlags() flag.Parse() - // Convert local grafanaConfig to mcpgrafana.GrafanaConfig - grafanaConfig := mcpgrafana.GrafanaConfig{Debug: gc.debug} - if gc.tlsCertFile != "" || gc.tlsKeyFile != "" || gc.tlsCAFile != "" || gc.tlsSkipVerify { - grafanaConfig.TLSConfig = &mcpgrafana.TLSConfig{ - CertFile: gc.tlsCertFile, - KeyFile: gc.tlsKeyFile, - CAFile: gc.tlsCAFile, - SkipVerify: gc.tlsSkipVerify, + var config *mcpgrafana.GrafanaConfig + if *useGrafanaConfig { + // Convert local grafanaConfig to mcpgrafana.GrafanaConfig + config = &mcpgrafana.GrafanaConfig{Debug: gc.debug} + if gc.tlsCertFile != "" || gc.tlsKeyFile != "" || gc.tlsCAFile != "" || gc.tlsSkipVerify { + config.TLSConfig = &mcpgrafana.TLSConfig{ + CertFile: gc.tlsCertFile, + KeyFile: gc.tlsKeyFile, + CAFile: gc.tlsCAFile, + SkipVerify: gc.tlsSkipVerify, + } } } - if err := run(transport, *addr, *basePath, *endpointPath, parseLevel(*logLevel), dt, grafanaConfig); err != nil { + if err := run(transport, *addr, *basePath, *endpointPath, parseLevel(*logLevel), dt, config); err != nil { panic(err) } } diff --git a/examples/tls_example.go b/examples/tls_example.go index 06559ee8..e0417b80 100644 --- a/examples/tls_example.go +++ b/examples/tls_example.go @@ -43,7 +43,7 @@ func basicTLSExample() { } // Create a context function that includes TLS configuration - contextFunc := mcpgrafana.ComposedStdioContextFunc(grafanaConfig) + contextFunc := mcpgrafana.ComposedStdioContextFunc(&grafanaConfig) // Test the context function ctx := contextFunc(context.Background()) @@ -85,9 +85,9 @@ func fullTLSExample() { fmt.Printf(" - Debug mode: %v\n", grafanaConfig.Debug) // Create context functions for different transport types - stdioFunc := mcpgrafana.ComposedStdioContextFunc(grafanaConfig) - sseFunc := mcpgrafana.ComposedSSEContextFunc(grafanaConfig) - httpFunc := mcpgrafana.ComposedHTTPContextFunc(grafanaConfig) + stdioFunc := mcpgrafana.ComposedStdioContextFunc(&grafanaConfig) + sseFunc := mcpgrafana.ComposedSSEContextFunc(&grafanaConfig) + httpFunc := mcpgrafana.ComposedHTTPContextFunc(&grafanaConfig) fmt.Printf("✓ Context functions created for all transport types\n") @@ -154,7 +154,7 @@ func runServerWithTLS() { // Create stdio server with TLS-enabled context function srv := server.NewStdioServer(s) - srv.SetContextFunc(mcpgrafana.ComposedStdioContextFunc(grafanaConfig)) + srv.SetContextFunc(mcpgrafana.ComposedStdioContextFunc(&grafanaConfig)) fmt.Printf("Starting MCP Grafana server with TLS support...\n") fmt.Printf("Grafana URL: %s\n", os.Getenv("GRAFANA_URL")) diff --git a/mcpgrafana.go b/mcpgrafana.go index 18ab2e66..42c33761 100644 --- a/mcpgrafana.go +++ b/mcpgrafana.go @@ -21,9 +21,14 @@ const ( defaultGrafanaHost = "localhost:3000" defaultGrafanaURL = "http://" + defaultGrafanaHost + promURL = "PROM_URL" + promSecret = "PROM_SECRET" + promCAPath = "PROM_CA_PATH" + grafanaURLEnvVar = "GRAFANA_URL" grafanaAPIEnvVar = "GRAFANA_API_KEY" + authorizationHeader = "Authorization" grafanaURLHeader = "X-Grafana-URL" grafanaAPIKeyHeader = "X-Grafana-API-Key" ) @@ -40,6 +45,12 @@ func urlAndAPIKeyFromHeaders(req *http.Request) (string, string) { return u, apiKey } +// grafanaConfigKey is the context key for local configuration. +type localConfigKey struct{} + +// authorizationKey is the context key for Authorization header. +type authorizationKey struct{} + // grafanaConfigKey is the context key for Grafana configuration. type grafanaConfigKey struct{} @@ -51,6 +62,17 @@ type TLSConfig struct { SkipVerify bool } +// LocalConfig represents the full configuration for local usage. +type LocalConfig struct { + // PromURL is the URL of the prometheus instance. + PromURL string + // PromSecret is the authorization credentials provided to the round tripper + // when Authorization header is not set + PromSecret string + // PromCAPath is the path of the prometheus CA certificate file. + PromCAPath string +} + // GrafanaConfig represents the full configuration for Grafana clients. type GrafanaConfig struct { // Debug enables debug mode for the Grafana client. @@ -74,6 +96,44 @@ type GrafanaConfig struct { TLSConfig *TLSConfig } +// WithAuthorization adds Authorization header to the context. +func WithAuthorization(ctx context.Context, authorization string) context.Context { + return context.WithValue(ctx, authorizationKey{}, authorization) +} + +// AuthorizationFromContext retrieves the Authorization header from the context. +func AuthorizationFromContext(ctx context.Context) string { + c, ok := ctx.Value(authorizationKey{}).(string) + if !ok { + return "" + } + return c +} + +func HasAuthorization(ctx context.Context) bool { + _, ok := ctx.Value(authorizationKey{}).(string) + return ok +} + +// LocalConfigFromContext retrieves the Authorization header from the context. +func LocalConfigFromContext(ctx context.Context) LocalConfig { + c, ok := ctx.Value(localConfigKey{}).(LocalConfig) + if !ok { + return LocalConfig{} + } + return c +} + +// WithLocalConfig adds local configuration to the context. +func WithLocalConfig(ctx context.Context, config LocalConfig) context.Context { + return context.WithValue(ctx, localConfigKey{}, config) +} + +func HasLocalConfig(ctx context.Context) bool { + _, ok := ctx.Value(localConfigKey{}).(LocalConfig) + return ok +} + // WithGrafanaConfig adds Grafana configuration to the context. func WithGrafanaConfig(ctx context.Context, config GrafanaConfig) context.Context { return context.WithValue(ctx, grafanaConfigKey{}, config) @@ -138,6 +198,26 @@ func (tc *TLSConfig) HTTPTransport(defaultTransport *http.Transport) (http.Round return transport, nil } +// ExtractStdioLocalInfoFromEnv is a StdioContextFunc that extracts local configuration +// from environment variables and injects a configured client into the context. +var ExtractStdioLocalInfoFromEnv server.StdioContextFunc = func(ctx context.Context) context.Context { + return WithLocalConfig(ctx, LocalConfig{ + PromURL: os.Getenv(promURL), + PromSecret: os.Getenv(promSecret), + PromCAPath: os.Getenv(promCAPath), + }) +} + +// ExtractHttpLocalInfoFromEnv is a StdioContextFunc that extracts local configuration +// from environment variables and injects a configured client into the context. +var ExtractHttpLocalInfoFromEnv httpContextFunc = func(ctx context.Context, req *http.Request) context.Context { + return WithLocalConfig(ctx, LocalConfig{ + PromURL: os.Getenv(promURL), + PromSecret: os.Getenv(promSecret), + PromCAPath: os.Getenv(promCAPath), + }) +} + // ExtractGrafanaInfoFromEnv is a StdioContextFunc that extracts Grafana configuration // from environment variables and injects a configured client into the context. var ExtractGrafanaInfoFromEnv server.StdioContextFunc = func(ctx context.Context) context.Context { @@ -164,6 +244,12 @@ var ExtractGrafanaInfoFromEnv server.StdioContextFunc = func(ctx context.Context // identical, they have distinct types and cannot be passed around interchangeably. type httpContextFunc func(ctx context.Context, req *http.Request) context.Context +// ExtractAuthorizationFromHeaders is a HTTPContextFunc that extracts Authorization +// from request headers and injects a configured client into the context. +var ExtractAuthorizationFromHeaders httpContextFunc = func(ctx context.Context, req *http.Request) context.Context { + return WithAuthorization(ctx, req.Header.Get(authorizationHeader)) +} + // ExtractGrafanaInfoFromHeaders is a HTTPContextFunc that extracts Grafana configuration // from request headers and injects a configured client into the context. var ExtractGrafanaInfoFromHeaders httpContextFunc = func(ctx context.Context, req *http.Request) context.Context { @@ -314,6 +400,11 @@ func GrafanaClientFromContext(ctx context.Context) *client.GrafanaHTTPAPI { return c } +func HasGrafanaClient(ctx context.Context) bool { + _, ok := ctx.Value(grafanaClientKey{}).(*client.GrafanaHTTPAPI) + return ok +} + type incidentClientKey struct{} var ExtractIncidentClientFromEnv server.StdioContextFunc = func(ctx context.Context) context.Context { @@ -422,10 +513,10 @@ func ComposeHTTPContextFuncs(funcs ...httpContextFunc) server.HTTPContextFunc { // ComposedStdioContextFunc returns a StdioContextFunc that comprises all predefined StdioContextFuncs, // as well as the Grafana debug flag and TLS configuration. -func ComposedStdioContextFunc(config GrafanaConfig) server.StdioContextFunc { +func ComposedStdioContextFunc(config *GrafanaConfig) server.StdioContextFunc { return ComposeStdioContextFuncs( func(ctx context.Context) context.Context { - return WithGrafanaConfig(ctx, config) + return WithGrafanaConfig(ctx, *config) }, ExtractGrafanaInfoFromEnv, ExtractGrafanaClientFromEnv, @@ -434,10 +525,10 @@ func ComposedStdioContextFunc(config GrafanaConfig) server.StdioContextFunc { } // ComposedSSEContextFunc is a SSEContextFunc that comprises all predefined SSEContextFuncs. -func ComposedSSEContextFunc(config GrafanaConfig) server.SSEContextFunc { +func ComposedSSEContextFunc(config *GrafanaConfig) server.SSEContextFunc { return ComposeSSEContextFuncs( func(ctx context.Context, req *http.Request) context.Context { - return WithGrafanaConfig(ctx, config) + return WithGrafanaConfig(ctx, *config) }, ExtractGrafanaInfoFromHeaders, ExtractGrafanaClientFromHeaders, @@ -446,10 +537,10 @@ func ComposedSSEContextFunc(config GrafanaConfig) server.SSEContextFunc { } // ComposedHTTPContextFunc is a HTTPContextFunc that comprises all predefined HTTPContextFuncs. -func ComposedHTTPContextFunc(config GrafanaConfig) server.HTTPContextFunc { +func ComposedHTTPContextFunc(config *GrafanaConfig) server.HTTPContextFunc { return ComposeHTTPContextFuncs( func(ctx context.Context, req *http.Request) context.Context { - return WithGrafanaConfig(ctx, config) + return WithGrafanaConfig(ctx, *config) }, ExtractGrafanaInfoFromHeaders, ExtractGrafanaClientFromHeaders, diff --git a/tools/prometheus.go b/tools/prometheus.go index 6be84665..2d0247d9 100644 --- a/tools/prometheus.go +++ b/tools/prometheus.go @@ -2,8 +2,11 @@ package tools import ( "context" + "crypto/tls" + "crypto/x509" "fmt" "net/http" + "os" "regexp" "strings" "time" @@ -29,42 +32,92 @@ var ( } ) -func promClientFromContext(ctx context.Context, uid string) (promv1.API, error) { - // First check if the datasource exists - _, err := getDatasourceByUID(ctx, GetDatasourceByUIDParams{UID: uid}) - if err != nil { - return nil, err - } +const ( + defaultPrometheusURL = "https://localhost:9090/" +) - cfg := mcpgrafana.GrafanaConfigFromContext(ctx) - url := fmt.Sprintf("%s/api/datasources/proxy/uid/%s", strings.TrimRight(cfg.URL, "/"), uid) +func promClientFromContext(ctx context.Context, uid string) (promv1.API, error) { + // TODO: allow override from ENV + url := defaultPrometheusURL + var rt http.RoundTripper - // Create custom transport with TLS configuration if available - rt := api.DefaultRoundTripper - if tlsConfig := cfg.TLSConfig; tlsConfig != nil { - customTransport, err := tlsConfig.HTTPTransport(rt.(*http.Transport)) + if mcpgrafana.HasGrafanaClient(ctx) { + // First check if the datasource exists + _, err := getDatasourceByUID(ctx, GetDatasourceByUIDParams{UID: uid}) if err != nil { - return nil, fmt.Errorf("failed to create custom transport: %w", err) + return nil, err } - rt = customTransport - } - if cfg.AccessToken != "" && cfg.IDToken != "" { - rt = config.NewHeadersRoundTripper(&config.Headers{ - Headers: map[string]config.Header{ - "X-Access-Token": { - Secrets: []config.Secret{config.Secret(cfg.AccessToken)}, - }, - "X-Grafana-Id": { - Secrets: []config.Secret{config.Secret(cfg.IDToken)}, + cfg := mcpgrafana.GrafanaConfigFromContext(ctx) + url = fmt.Sprintf("%s/api/datasources/proxy/uid/%s", strings.TrimRight(cfg.URL, "/"), uid) + + // Create custom transport with TLS configuration if available + rt := api.DefaultRoundTripper + if tlsConfig := cfg.TLSConfig; tlsConfig != nil { + customTransport, err := tlsConfig.HTTPTransport(rt.(*http.Transport)) + if err != nil { + return nil, fmt.Errorf("failed to create custom transport: %w", err) + } + rt = customTransport + } + + if cfg.AccessToken != "" && cfg.IDToken != "" { + rt = config.NewHeadersRoundTripper(&config.Headers{ + Headers: map[string]config.Header{ + "X-Access-Token": { + Secrets: []config.Secret{config.Secret(cfg.AccessToken)}, + }, + "X-Grafana-Id": { + Secrets: []config.Secret{config.Secret(cfg.IDToken)}, + }, }, - }, - }, rt) - } else if cfg.APIKey != "" { - rt = config.NewAuthorizationCredentialsRoundTripper( - "Bearer", config.NewInlineSecret(cfg.APIKey), rt, - ) + }, rt) + } else if cfg.APIKey != "" { + rt = config.NewAuthorizationCredentialsRoundTripper( + "Bearer", config.NewInlineSecret(cfg.APIKey), rt, + ) + } + } else { + secret := "" + transport := http.Transport{} + + // read local config + if mcpgrafana.HasLocalConfig(ctx) { + config := mcpgrafana.LocalConfigFromContext(ctx) + if len(config.PromURL) > 0 { + url = config.PromURL + } + + if len(config.PromSecret) > 0 { + secret = config.PromSecret + } + + if len(config.PromCAPath) > 0 { + caCert, err := os.ReadFile(config.PromCAPath) + if err != nil { + return nil, fmt.Errorf("reading Prometheus ca: %w", err) + } else { + pool := x509.NewCertPool() + pool.AppendCertsFromPEM(caCert) + transport.TLSClientConfig = &tls.Config{ + RootCAs: pool, + } + } + } + } + + // set secret from token if provided + if mcpgrafana.HasAuthorization(ctx) { + secret = strings.TrimPrefix(mcpgrafana.AuthorizationFromContext(ctx), "Bearer ") + } + + // create round tripper + if transport.TLSClientConfig == nil { + transport.TLSClientConfig = &tls.Config{InsecureSkipVerify: true} + } + rt = config.NewAuthorizationCredentialsRoundTripper("Bearer", config.NewInlineSecret(secret), &transport) } + c, err := api.NewClient(api.Config{ Address: url, RoundTripper: rt, From 54a736f58303f9a78efb536593de61d891284fac Mon Sep 17 00:00:00 2001 From: Julien Pinsonneau Date: Tue, 24 Jun 2025 16:47:59 +0200 Subject: [PATCH 2/3] add loki config --- mcpgrafana.go | 17 +++++++++ tools/loki.go | 89 ++++++++++++++++++++++++++++++++++++--------- tools/prometheus.go | 1 - 3 files changed, 88 insertions(+), 19 deletions(-) diff --git a/mcpgrafana.go b/mcpgrafana.go index 42c33761..6f1a30b4 100644 --- a/mcpgrafana.go +++ b/mcpgrafana.go @@ -21,6 +21,10 @@ const ( defaultGrafanaHost = "localhost:3000" defaultGrafanaURL = "http://" + defaultGrafanaHost + lokiURL = "LOKI_URL" + lokiSecret = "LOKI_SECRET" + lokiCAPath = "LOKI_CA_PATH" + promURL = "PROM_URL" promSecret = "PROM_SECRET" promCAPath = "PROM_CA_PATH" @@ -64,6 +68,13 @@ type TLSConfig struct { // LocalConfig represents the full configuration for local usage. type LocalConfig struct { + // LokiURL is the URL of the Lokietheus instance. + LokiURL string + // LokiSecret is the authorization credentials provided to the round tripper + // when Authorization header is not set + LokiSecret string + // LokiCAPath is the path of the Lokietheus CA certificate file. + LokiCAPath string // PromURL is the URL of the prometheus instance. PromURL string // PromSecret is the authorization credentials provided to the round tripper @@ -202,6 +213,9 @@ func (tc *TLSConfig) HTTPTransport(defaultTransport *http.Transport) (http.Round // from environment variables and injects a configured client into the context. var ExtractStdioLocalInfoFromEnv server.StdioContextFunc = func(ctx context.Context) context.Context { return WithLocalConfig(ctx, LocalConfig{ + LokiURL: os.Getenv(lokiURL), + LokiSecret: os.Getenv(lokiSecret), + LokiCAPath: os.Getenv(lokiCAPath), PromURL: os.Getenv(promURL), PromSecret: os.Getenv(promSecret), PromCAPath: os.Getenv(promCAPath), @@ -212,6 +226,9 @@ var ExtractStdioLocalInfoFromEnv server.StdioContextFunc = func(ctx context.Cont // from environment variables and injects a configured client into the context. var ExtractHttpLocalInfoFromEnv httpContextFunc = func(ctx context.Context, req *http.Request) context.Context { return WithLocalConfig(ctx, LocalConfig{ + LokiURL: os.Getenv(lokiURL), + LokiSecret: os.Getenv(lokiSecret), + LokiCAPath: os.Getenv(lokiCAPath), PromURL: os.Getenv(promURL), PromSecret: os.Getenv(promSecret), PromCAPath: os.Getenv(promCAPath), diff --git a/tools/loki.go b/tools/loki.go index 53b726c2..8009b617 100644 --- a/tools/loki.go +++ b/tools/loki.go @@ -3,11 +3,15 @@ package tools import ( "bytes" "context" + "crypto/tls" + "crypto/x509" "encoding/json" "fmt" "io" + "maps" "net/http" "net/url" + "os" "strconv" "strings" "time" @@ -18,6 +22,8 @@ import ( ) const ( + defaultLokiURL = "https://localhost:3100/" + // DefaultLokiLogLimit is the default number of log lines to return if not specified DefaultLokiLogLimit = 10 @@ -28,6 +34,7 @@ const ( type Client struct { httpClient *http.Client baseURL string + headers map[string][]string } // LabelResponse represents the http json response to a label query @@ -45,37 +52,80 @@ type Stats struct { } func newLokiClient(ctx context.Context, uid string) (*Client, error) { - // First check if the datasource exists - _, err := getDatasourceByUID(ctx, GetDatasourceByUIDParams{UID: uid}) - if err != nil { - return nil, err - } + url := defaultLokiURL + client := &http.Client{} + headers := map[string][]string{} - cfg := mcpgrafana.GrafanaConfigFromContext(ctx) - url := fmt.Sprintf("%s/api/datasources/proxy/uid/%s", strings.TrimRight(cfg.URL, "/"), uid) - - // Create custom transport with TLS configuration if available - var transport http.RoundTripper = http.DefaultTransport - if tlsConfig := cfg.TLSConfig; tlsConfig != nil { - var err error - transport, err = tlsConfig.HTTPTransport(transport.(*http.Transport)) + if mcpgrafana.HasGrafanaClient(ctx) { + // First check if the datasource exists + _, err := getDatasourceByUID(ctx, GetDatasourceByUIDParams{UID: uid}) if err != nil { - return nil, fmt.Errorf("failed to create custom transport: %w", err) + return nil, err } - } - client := &http.Client{ - Transport: &authRoundTripper{ + cfg := mcpgrafana.GrafanaConfigFromContext(ctx) + url = fmt.Sprintf("%s/api/datasources/proxy/uid/%s", strings.TrimRight(cfg.URL, "/"), uid) + + // Create custom transport with TLS configuration if available + var transport http.RoundTripper = http.DefaultTransport + if tlsConfig := cfg.TLSConfig; tlsConfig != nil { + var err error + transport, err = tlsConfig.HTTPTransport(transport.(*http.Transport)) + if err != nil { + return nil, fmt.Errorf("failed to create custom transport: %w", err) + } + } + + client.Transport = &authRoundTripper{ accessToken: cfg.AccessToken, idToken: cfg.IDToken, apiKey: cfg.APIKey, underlying: transport, - }, + } + } else { + transport := http.Transport{} + + // read local config + if mcpgrafana.HasLocalConfig(ctx) { + config := mcpgrafana.LocalConfigFromContext(ctx) + if len(config.LokiURL) > 0 { + url = config.LokiURL + } + + if len(config.LokiSecret) > 0 { + headers["Authorization"] = []string{config.LokiSecret} + } + + if len(config.LokiCAPath) > 0 { + caCert, err := os.ReadFile(config.LokiCAPath) + if err != nil { + return nil, fmt.Errorf("reading Loki ca: %w", err) + } else { + pool := x509.NewCertPool() + pool.AppendCertsFromPEM(caCert) + transport.TLSClientConfig = &tls.Config{ + RootCAs: pool, + } + } + } + } + + // set secret from token if provided + if mcpgrafana.HasAuthorization(ctx) { + headers["Authorization"] = []string{mcpgrafana.AuthorizationFromContext(ctx)} + } + + // set transport + if transport.TLSClientConfig == nil { + transport.TLSClientConfig = &tls.Config{InsecureSkipVerify: true} + } + client.Transport = &transport } return &Client{ httpClient: client, baseURL: url, + headers: headers, }, nil } @@ -109,6 +159,9 @@ func (c *Client) makeRequest(ctx context.Context, method, urlPath string, params return nil, fmt.Errorf("creating request: %w", err) } + // Forward headers + maps.Copy(req.Header, c.headers) + resp, err := c.httpClient.Do(req) if err != nil { return nil, fmt.Errorf("executing request: %w", err) diff --git a/tools/prometheus.go b/tools/prometheus.go index 2d0247d9..22d1e4cf 100644 --- a/tools/prometheus.go +++ b/tools/prometheus.go @@ -37,7 +37,6 @@ const ( ) func promClientFromContext(ctx context.Context, uid string) (promv1.API, error) { - // TODO: allow override from ENV url := defaultPrometheusURL var rt http.RoundTripper From 6f9f0b793be0314467be80bbb75dbe749964ce97 Mon Sep 17 00:00:00 2001 From: Julien Pinsonneau Date: Tue, 24 Jun 2025 16:52:07 +0200 Subject: [PATCH 3/3] k8s pod example --- examples/k8s/k8s-mcp.yaml | 42 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) create mode 100644 examples/k8s/k8s-mcp.yaml diff --git a/examples/k8s/k8s-mcp.yaml b/examples/k8s/k8s-mcp.yaml new file mode 100644 index 00000000..fa389778 --- /dev/null +++ b/examples/k8s/k8s-mcp.yaml @@ -0,0 +1,42 @@ +kind: Pod +apiVersion: v1 +metadata: + name: k8s-mcp + namespace: my-app + labels: + app: k8s-mcp +spec: + restartPolicy: Always + containers: + - name: mcp-grafana + image: 'quay.io/grafana/mcp-grafana:dev' + command: ["/app/mcp-grafana"] + args: + - '-transport=streamable-http' + - '-address=localhost:8000' + - '-endpoint-path=/mcp' + - '-use-grafana-config=false' + - '-enabled-tools=prometheus,loki' + - '-log-level=debug' + env: + - name: LOKI_URL + value: http://loki.my-app.svc:3100 + - name: PROM_URL + value: http://prom.my-app.svc:9091 +--- +kind: Service +apiVersion: v1 +metadata: + name: k8s-mcp + namespace: my-app + labels: + app: k8s-mcp +spec: + ports: + - protocol: TCP + port: 8000 + targetPort: 8000 + internalTrafficPolicy: Cluster + type: ClusterIP + selector: + app: k8s-mcp \ No newline at end of file