Skip to content
2 changes: 1 addition & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,7 @@ Run `make lint` and `make test-unit` before pushing. The specifics:
- **Pass `context.Context` through Grafana API client calls** — enforced by `make lint-openapi`.
- **Write tools must respect `--disable-write`.** Any tool that creates, updates or deletes anything must be registered so that it disappears when the server runs read-only. Take the `enableWriteTools` parameter in your category's `Add*Tools` function.
- **Query tools must respect `--disable-query`.** Any tool that executes a query against a datasource must disappear when query execution is disabled. Take the `enableQueryTools` parameter in your category's `Add*Tools` function, and leave metadata and discovery tools registered unconditionally. If the tool passes the query through unfiltered — raw SQL, InfluxQL — it can write, so it counts as a write tool too: add its category to `mutatingQueryCategories` in `cmd/mcp-grafana/main.go` so `--disable-write` removes it.
- **New tools need a row in the README's tool table**, including the category and the required RBAC permissions and scopes.
- **New tools declare their RBAC permissions** with `mcpgrafana.RequiresPermissions(...)` on the `MustTool` call (see `tools/rbac.go` for shared groups such as `datasourceQuery`), and **need a row in the README's tool table** with the category and required scopes. The permissions column is generated: run `go test ./cmd/mcp-grafana -run TestReadmeToolPermissions -update-readme`.

The checks that must pass to merge are `license/cla`, `Test Unit`, `Test Integration`, `Lint Go`, `Lint JSON Schemas`, `docker` and `docker-alpine`. `Test Cloud` and the Python E2E suites are informational — `Test Cloud` cannot pass from a fork at all, because it needs credentials, so please don't worry about it being red.

Expand Down
228 changes: 124 additions & 104 deletions README.md

Large diffs are not rendered by default.

17 changes: 14 additions & 3 deletions cmd/mcp-grafana/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1283,6 +1283,17 @@ var metadataToolNames = []string{
// and returns the set of advertised tool names.
func registerAllCategories(t *testing.T, dt disabledTools) map[string]bool {
t.Helper()
names := make(map[string]bool)
for name := range listAllCategoryTools(t, dt) {
names[name] = true
}
return names
}

// listAllCategoryTools enables every tool category on top of dt and returns
// the tools a client sees in tools/list.
func listAllCategoryTools(t *testing.T, dt disabledTools) map[string]*mcp.Tool {
t.Helper()

categories := make([]string, 0, len(dt.toolEntries()))
for _, e := range dt.toolEntries() {
Expand All @@ -1297,11 +1308,11 @@ func registerAllCategories(t *testing.T, dt disabledTools) map[string]bool {
result, err := session.ListTools(context.Background(), nil)
require.NoError(t, err)

names := make(map[string]bool, len(result.Tools))
tools := make(map[string]*mcp.Tool, len(result.Tools))
for _, tool := range result.Tools {
names[tool.Name] = true
tools[tool.Name] = tool
}
return names
return tools
}

func TestProcessTools_QueryToolsRegisteredByDefault(t *testing.T) {
Expand Down
23 changes: 23 additions & 0 deletions cmd/mcp-grafana/rbac_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
package main

import (
"testing"

"github.com/stretchr/testify/assert"

mcpgrafana "github.com/grafana/mcp-grafana/v2"
)

// TestEveryToolDeclaresPermissions fails when a tool is registered without
// mcpgrafana.RequiresPermissions. It checks the read-write and read-only
// variants, which are separate MustTool calls for some tools.
func TestEveryToolDeclaresPermissions(t *testing.T) {
for _, readOnly := range []bool{false, true} {
for name, tool := range listAllCategoryTools(t, disabledTools{write: readOnly}) {
_, ok := mcpgrafana.ToolRequiredPermissions(tool)
assert.True(t, ok, "tool %q declares no RBAC permissions: add mcpgrafana.RequiresPermissions(...) "+
"to its MustTool call, e.g. RequiresPermissions(datasourceQuery...) for a datasource query tool "+
"(see tools/rbac.go), or RequiresPermissions() if it needs none", name)
}
}
}
107 changes: 107 additions & 0 deletions cmd/mcp-grafana/readme_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
package main

import (
"flag"
"os"
"slices"
"strings"
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"

mcpgrafana "github.com/grafana/mcp-grafana/v2"
)

var updateReadme = flag.Bool("update-readme", false, "rewrite the README tool table's permissions column")

const readmePath = "../../README.md"

// TestReadmeToolPermissions keeps the README tool table's "Required RBAC
// Permissions" column in step with each tool's RequiresPermissions. Run
// `go test ./cmd/mcp-grafana -run TestReadmeToolPermissions -update-readme`
// to regenerate it. The other columns are written by hand.
func TestReadmeToolPermissions(t *testing.T) {
declared := make(map[string][]string)
for name, tool := range listAllCategoryTools(t, disabledTools{}) {
declared[name], _ = mcpgrafana.ToolRequiredPermissions(tool)
}

raw, err := os.ReadFile(readmePath)
require.NoError(t, err)
lines := strings.Split(string(raw), "\n")

start := slices.IndexFunc(lines, func(l string) bool { return strings.HasPrefix(l, "| Tool ") })
require.NotEqual(t, -1, start, "README tool table not found")
end := start
for end < len(lines) && strings.HasPrefix(lines[end], "|") {
end++
}

var rows [][]string
listed := make(map[string]bool)
for i, line := range lines[start:end] {
cells := strings.Split(strings.Trim(line, "|"), "|")
for j := range cells {
cells[j] = strings.TrimSpace(cells[j])
}
if i > 1 {
name := strings.Trim(cells[0], "`")
perms, ok := declared[name]
if !assert.True(t, ok, "README lists %q, which is not a registered tool", name) {
continue
}
listed[name] = true
cells[3] = formatPermissions(perms)
}
rows = append(rows, cells)
}
for name := range declared {
assert.True(t, listed[name], "tool %q has no row in the README tool table", name)
}

want := strings.Join(slices.Concat(lines[:start], formatTable(rows), lines[end:]), "\n")
if *updateReadme {
require.NoError(t, os.WriteFile(readmePath, []byte(want), 0o644))
return
}
assert.Equal(t, string(raw), want, "README tool permissions are out of date; regenerate with "+
"`go test ./cmd/mcp-grafana -run TestReadmeToolPermissions -update-readme`")
}

func formatPermissions(perms []string) string {
if len(perms) == 0 {
return "None"
}
quoted := make([]string, len(perms))
for i, p := range perms {
quoted[i] = "`" + p + "`"
}
return strings.Join(quoted, ", ")
}

// formatTable pads a markdown table so its columns line up. Row 1 is the
// separator row.
func formatTable(rows [][]string) []string {
widths := make([]int, len(rows[0]))
for i, row := range rows {
for j, cell := range row {
if i != 1 {
widths[j] = max(widths[j], len([]rune(cell)))
}
}
}
out := make([]string, len(rows))
for i, row := range rows {
var b strings.Builder
b.WriteString("|")
for j, cell := range row {
if i == 1 {
cell = strings.Repeat("-", widths[j])
}
b.WriteString(" " + cell + strings.Repeat(" ", widths[j]-len([]rune(cell))) + " |")
}
out[i] = b.String()
}
return out
}
60 changes: 60 additions & 0 deletions permissions.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
package mcpgrafana

import (
"slices"

"github.com/modelcontextprotocol/go-sdk/mcp"
)

// RequiredPermissionsMetaKey is the tool _meta key that lists the Grafana RBAC
// actions a tool's calls need. Set it with RequiresPermissions.
const RequiredPermissionsMetaKey = "grafana.com/requiredPermissions"

// RequiresPermissions declares Grafana RBAC actions the tool's calls need. A
// caller missing any of them gets a 403 from the tool. Every tool must declare
// its actions, including tools that need none (call it with no arguments); a
// test fails otherwise. Repeated calls add to the list, so a shared group can
// be combined with extra actions:
//
// mcpgrafana.RequiresPermissions(datasourceQuery...),
// mcpgrafana.RequiresPermissions("dashboards:read"),
//
// Calls to /apis endpoints are checked twice: the user's legacy action (the
// verb as mapped by Grafana, e.g. dashboards:read) and, for on-behalf-of
// tokens, the token's group/resource:verb permission (e.g.
// dashboard.grafana.app/dashboards:get). Tools that use /apis declare both.
//
// Declare the actions for every operation the tool can perform, including
// write operations behind a read-write variant.
func RequiresPermissions(actions ...string) ToolOption {
return func(t *mcp.Tool) {
if t.Meta == nil {
t.Meta = mcp.Meta{}
}
perms, _ := ToolRequiredPermissions(t)
perms = append(append([]string{}, perms...), actions...)
slices.Sort(perms)
t.Meta[RequiredPermissionsMetaKey] = slices.Compact(perms)
}
}

// ToolRequiredPermissions returns the actions declared with
// RequiresPermissions and whether the tool declared any. It accepts both the
// in-process value and one decoded from a tools/list response.
func ToolRequiredPermissions(t *mcp.Tool) ([]string, bool) {
switch v := t.Meta[RequiredPermissionsMetaKey].(type) {
case []string:
return v, true
case []any:
perms := make([]string, 0, len(v))
for _, p := range v {
s, ok := p.(string)
if !ok {
return nil, false
}
perms = append(perms, s)
}
return perms, true
}
return nil, false
}
33 changes: 33 additions & 0 deletions permissions_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
package mcpgrafana

import (
"encoding/json"
"testing"

"github.com/modelcontextprotocol/go-sdk/mcp"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

func TestRequiresPermissions(t *testing.T) {
tool := &mcp.Tool{}
RequiresPermissions("b:read", "a:read")(tool)
RequiresPermissions("a:read", "c:write")(tool)
perms, ok := ToolRequiredPermissions(tool)
require.True(t, ok)
assert.Equal(t, []string{"a:read", "b:read", "c:write"}, perms)

// An empty declaration still counts, including after a tools/list round trip.
empty := &mcp.Tool{Name: "none"}
RequiresPermissions()(empty)
b, err := json.Marshal(empty)
require.NoError(t, err)
var decoded mcp.Tool
require.NoError(t, json.Unmarshal(b, &decoded))
perms, ok = ToolRequiredPermissions(&decoded)
require.True(t, ok)
assert.Empty(t, perms)

_, ok = ToolRequiredPermissions(&mcp.Tool{})
assert.False(t, ok)
}
9 changes: 9 additions & 0 deletions tools/admin.go
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ var ListTeams = mcpgrafana.MustTool(
mcpgrafana.WithReadOnlyHintAnnotation(true),
mcpgrafana.WithDestructiveHintAnnotation(false),
mcpgrafana.WithOpenWorldHintAnnotation(false),
mcpgrafana.RequiresPermissions("teams:read"),
)

const (
Expand Down Expand Up @@ -108,6 +109,7 @@ var ListUsersByOrg = mcpgrafana.MustTool(
mcpgrafana.WithReadOnlyHintAnnotation(true),
mcpgrafana.WithDestructiveHintAnnotation(false),
mcpgrafana.WithOpenWorldHintAnnotation(false),
mcpgrafana.RequiresPermissions("org.users:read"),
)

type ListAllRolesParams struct {
Expand Down Expand Up @@ -139,6 +141,7 @@ var ListAllRoles = mcpgrafana.MustTool(
mcpgrafana.WithReadOnlyHintAnnotation(true),
mcpgrafana.WithDestructiveHintAnnotation(false),
mcpgrafana.WithOpenWorldHintAnnotation(false),
mcpgrafana.RequiresPermissions("roles:read"),
)

type GetRoleDetailsParams struct {
Expand All @@ -165,6 +168,7 @@ var GetRoleDetails = mcpgrafana.MustTool(
mcpgrafana.WithReadOnlyHintAnnotation(true),
mcpgrafana.WithDestructiveHintAnnotation(false),
mcpgrafana.WithOpenWorldHintAnnotation(false),
mcpgrafana.RequiresPermissions("roles:read"),
)

type GetRoleAssignmentsParams struct {
Expand All @@ -191,6 +195,7 @@ var GetRoleAssignments = mcpgrafana.MustTool(
mcpgrafana.WithReadOnlyHintAnnotation(true),
mcpgrafana.WithDestructiveHintAnnotation(false),
mcpgrafana.WithOpenWorldHintAnnotation(false),
mcpgrafana.RequiresPermissions("roles:read", "users.roles:read", "teams.roles:read"),
)

type ListUserRolesParams struct {
Expand Down Expand Up @@ -218,6 +223,7 @@ var ListUserRoles = mcpgrafana.MustTool(
mcpgrafana.WithReadOnlyHintAnnotation(true),
mcpgrafana.WithDestructiveHintAnnotation(false),
mcpgrafana.WithOpenWorldHintAnnotation(false),
mcpgrafana.RequiresPermissions("users.roles:read"),
)

type ListTeamRolesParams struct {
Expand Down Expand Up @@ -245,6 +251,7 @@ var ListTeamRoles = mcpgrafana.MustTool(
mcpgrafana.WithReadOnlyHintAnnotation(true),
mcpgrafana.WithDestructiveHintAnnotation(false),
mcpgrafana.WithOpenWorldHintAnnotation(false),
mcpgrafana.RequiresPermissions("teams.roles:read"),
)

type GetResourcePermissionsParams struct {
Expand Down Expand Up @@ -272,6 +279,7 @@ var GetResourcePermissions = mcpgrafana.MustTool(
mcpgrafana.WithReadOnlyHintAnnotation(true),
mcpgrafana.WithDestructiveHintAnnotation(false),
mcpgrafana.WithOpenWorldHintAnnotation(false),
mcpgrafana.RequiresPermissions(resourcePermissionsRead...),
)

type GetResourceDescriptionParams struct {
Expand Down Expand Up @@ -301,6 +309,7 @@ var GetResourceDescription = mcpgrafana.MustTool(
mcpgrafana.WithReadOnlyHintAnnotation(true),
mcpgrafana.WithDestructiveHintAnnotation(false),
mcpgrafana.WithOpenWorldHintAnnotation(false),
mcpgrafana.RequiresPermissions(resourcePermissionsRead...),
)

func AddAdminTools(s *mcp.Server) {
Expand Down
Loading
Loading