Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,24 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [0.11.3] - 2026-03-12

### Added

- Support panel filtering and template variable substitution in `get_dashboard_panel_queries` for more targeted query extraction ([#539](https://github.com/grafana/mcp-grafana/pull/539))
- New `alerting_manage_routing` tool for managing notification policies, contact points, and time intervals in a single unified tool ([#618](https://github.com/grafana/mcp-grafana/pull/618))
- Add `accountId` parameter to CloudWatch tools for cross-account monitoring support ([#616](https://github.com/grafana/mcp-grafana/pull/616))

### Fixed

- Add `OrgIDRoundTripper` to the Grafana client transport chain so organization ID is correctly sent on all requests ([#649](https://github.com/grafana/mcp-grafana/pull/649))

### Changed

- Consolidate alerting rule tools into a single `alerting_manage_rules` tool for simpler discovery ([#619](https://github.com/grafana/mcp-grafana/pull/619))
- Use typed struct for alert query parameters instead of untyped `models.AlertQuery` ([#630](https://github.com/grafana/mcp-grafana/pull/630))
- Add server-side filtering support to alerting client for more efficient rule queries (Grafana 10.0+) ([#612](https://github.com/grafana/mcp-grafana/pull/612))

## [0.11.2] - 2026-02-24

### Changed
Expand Down Expand Up @@ -56,6 +74,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

- Upgrade Docker base image packages to resolve critical OpenSSL CVE-2025-15467 (CVSS 9.8) ([#551](https://github.com/grafana/mcp-grafana/pull/551))

[0.11.3]: https://github.com/grafana/mcp-grafana/compare/v0.11.2...v0.11.3
[0.11.2]: https://github.com/grafana/mcp-grafana/compare/v0.11.1...v0.11.2
[0.11.1]: https://github.com/grafana/mcp-grafana/compare/v0.11.0...v0.11.1
[0.11.0]: https://github.com/grafana/mcp-grafana/compare/v0.10.0...v0.11.0
Expand Down
139 changes: 139 additions & 0 deletions auth/browser.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
package auth

import (
"context"
"fmt"
"log/slog"
"net/url"
"os"
"path/filepath"
"strings"
"time"

"github.com/chromedp/cdproto/network"
"github.com/chromedp/chromedp"
)

const (
loginTimeout = 5 * time.Minute
cookiePollInterval = 500 * time.Millisecond
grafanaSessionName = "grafana_session"
)

// BrowserLogin opens a real browser window to the Grafana login page and waits
// for the user to complete SSO authentication. Once authenticated, it captures
// the grafana_session cookie and returns it.
//
// A persistent Chrome profile is used so that IdP sessions (Okta, Google, etc.)
// are remembered. If the profile is locked by another Chrome instance, a
// temporary profile is used as fallback.
func BrowserLogin(grafanaURL string) (string, error) {
parsedURL, err := url.Parse(grafanaURL)
if err != nil {
return "", fmt.Errorf("invalid Grafana URL: %w", err)
}
domain := parsedURL.Hostname()
loginURL := strings.TrimRight(grafanaURL, "/") + "/login"

sendNotification(
"Grafana MCP - Login Required",
"A browser window will open for Grafana SSO login. Please complete authentication.",
)

cookie, err := tryBrowserLogin(loginURL, domain, chromeProfileDir())
if err != nil {
slog.Warn("Browser login with persistent profile failed, retrying with temp profile", "error", err)
cookie, err = tryBrowserLogin(loginURL, domain, "")
}
if err != nil {
sendNotification(
"Grafana MCP - Login Failed",
fmt.Sprintf("Browser login timed out after %s. Please try again.", loginTimeout),
)
return "", fmt.Errorf("browser login failed: %w", err)
}

return cookie, nil
}

func tryBrowserLogin(loginURL, domain, profileDir string) (string, error) {
opts := append(chromedp.DefaultExecAllocatorOptions[:],
chromedp.Flag("headless", false),
chromedp.Flag("disable-gpu", false),
chromedp.WindowSize(1024, 768),
)

if profileDir != "" {
if err := os.MkdirAll(profileDir, 0o700); err != nil {
return "", fmt.Errorf("create chrome profile dir: %w", err)
}
opts = append(opts, chromedp.UserDataDir(profileDir))
slog.Info("Starting browser login", "url", loginURL, "profile", profileDir)
} else {
slog.Info("Starting browser login with temporary profile", "url", loginURL)
}

allocCtx, allocCancel := chromedp.NewExecAllocator(context.Background(), opts...)
defer allocCancel()

ctx, cancel := chromedp.NewContext(allocCtx)
defer cancel()

timeoutCtx, timeoutCancel := context.WithTimeout(ctx, loginTimeout)
defer timeoutCancel()

var sessionCookie string

err := chromedp.Run(timeoutCtx,
chromedp.Navigate(loginURL),
chromedp.ActionFunc(func(ctx context.Context) error {
slog.Info("Browser opened, waiting for SSO login to complete...")
for {
select {
case <-ctx.Done():
return fmt.Errorf(
"login timed out after %s — please complete SSO in the browser window that opened",
loginTimeout,
)
default:
}

cookies, err := network.GetCookies().Do(ctx)
if err != nil {
return fmt.Errorf("failed to get cookies: %w", err)
}

for _, c := range cookies {
if c.Name == grafanaSessionName && matchesDomain(c.Domain, domain) {
sessionCookie = c.Value
slog.Info("Session cookie captured", "domain", c.Domain)
return nil
}
}

time.Sleep(cookiePollInterval)
}
}),
)
if err != nil {
return "", err
}

if sessionCookie == "" {
return "", fmt.Errorf("no grafana_session cookie found after login")
}

return sessionCookie, nil
}

func chromeProfileDir() string {
return filepath.Join(configDir(), "chrome-profile")
}

// matchesDomain checks if a cookie domain matches the target domain.
// Cookie domains may have a leading dot (e.g., ".jfrog.io").
func matchesDomain(cookieDomain, targetDomain string) bool {
cookieDomain = strings.TrimPrefix(cookieDomain, ".")
return strings.EqualFold(cookieDomain, targetDomain) ||
strings.HasSuffix(targetDomain, "."+cookieDomain)
}
57 changes: 57 additions & 0 deletions auth/notify.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
package auth

import (
"log/slog"
"os/exec"
"runtime"
)

// sendNotification sends a desktop notification so the user knows
// a browser window needs attention. Best-effort: failures are logged
// but never block the caller.
func sendNotification(title, message string) {
var cmd *exec.Cmd

switch runtime.GOOS {
case "darwin":
script := `display notification "` + escapeAppleScript(message) + `" with title "` + escapeAppleScript(title) + `"`
cmd = exec.Command("osascript", "-e", script)
case "linux":
cmd = exec.Command("notify-send", "--urgency=critical", title, message)
case "windows":
ps := `[void][System.Reflection.Assembly]::LoadWithPartialName('System.Windows.Forms');` +
`$n=New-Object System.Windows.Forms.NotifyIcon;` +
`$n.Icon=[System.Drawing.SystemIcons]::Information;` +
`$n.Visible=$true;` +
`$n.ShowBalloonTip(10000,'` + title + `','` + message + `',[System.Windows.Forms.ToolTipIcon]::Info)`
cmd = exec.Command("powershell", "-NoProfile", "-Command", ps)
default:
slog.Debug("Desktop notifications not supported on this platform", "os", runtime.GOOS)
return
}

if err := cmd.Start(); err != nil {
slog.Debug("Failed to send desktop notification", "error", err)
return
}

// Don't block on the notification process.
go func() {
_ = cmd.Wait()
}()
}

func escapeAppleScript(s string) string {
result := make([]byte, 0, len(s))
for i := 0; i < len(s); i++ {
switch s[i] {
case '"':
result = append(result, '\\', '"')
case '\\':
result = append(result, '\\', '\\')
default:
result = append(result, s[i])
}
}
return string(result)
}
104 changes: 104 additions & 0 deletions auth/session_store.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
package auth

import (
"encoding/json"
"fmt"
"log/slog"
"net/url"
"os"
"path/filepath"
"sync"
"time"
)

type sessionEntry struct {
Cookie string `json:"cookie"`
SavedAt time.Time `json:"saved_at"`
}

// SessionStore persists Grafana session cookies to disk, keyed by host.
type SessionStore struct {
mu sync.Mutex
path string
}

// NewSessionStore creates a store backed by ~/.config/mcp-grafana/sessions.json.
func NewSessionStore() *SessionStore {
dir := configDir()
return &SessionStore{path: filepath.Join(dir, "sessions.json")}
}

// Load returns the saved session cookie for a Grafana URL, or "" if none.
func (s *SessionStore) Load(grafanaURL string) string {
s.mu.Lock()
defer s.mu.Unlock()

host := hostFromURL(grafanaURL)
entries := s.readAll()
if e, ok := entries[host]; ok {
slog.Debug("Loaded session from disk", "host", host, "saved_at", e.SavedAt)
return e.Cookie
}
return ""
}

// Save persists the session cookie for a Grafana URL.
func (s *SessionStore) Save(grafanaURL, cookie string) error {
s.mu.Lock()
defer s.mu.Unlock()

host := hostFromURL(grafanaURL)
entries := s.readAll()
entries[host] = sessionEntry{
Cookie: cookie,
SavedAt: time.Now().UTC(),
}

if err := os.MkdirAll(filepath.Dir(s.path), 0o700); err != nil {
return fmt.Errorf("create config dir: %w", err)
}

data, err := json.MarshalIndent(entries, "", " ")
if err != nil {
return fmt.Errorf("marshal sessions: %w", err)
}

if err := os.WriteFile(s.path, data, 0o600); err != nil {
return fmt.Errorf("write sessions file: %w", err)
}

slog.Debug("Session saved to disk", "host", host, "path", s.path)
return nil
}

func (s *SessionStore) readAll() map[string]sessionEntry {
data, err := os.ReadFile(s.path)
if err != nil {
return make(map[string]sessionEntry)
}
var entries map[string]sessionEntry
if err := json.Unmarshal(data, &entries); err != nil {
slog.Warn("Corrupt sessions file, starting fresh", "path", s.path, "error", err)
return make(map[string]sessionEntry)
}
return entries
}

func hostFromURL(rawURL string) string {
u, err := url.Parse(rawURL)
if err != nil {
return rawURL
}
return u.Host
}

func configDir() string {
if xdg := os.Getenv("XDG_CONFIG_HOME"); xdg != "" {
return filepath.Join(xdg, "mcp-grafana")
}
home, err := os.UserHomeDir()
if err != nil {
home = "."
}
return filepath.Join(home, ".config", "mcp-grafana")
}
Loading