diff --git a/README.md b/README.md index 965dda7c..1da6be70 100644 --- a/README.md +++ b/README.md @@ -168,6 +168,8 @@ Scopes define the specific resources that permissions apply to. Each action requ | Tool | Category | Description | Required RBAC Permissions | Required Scopes | | --------------------------------- | ----------- | ------------------------------------------------------------------- | --------------------------------------- | --------------------------------------------------- | +| `grafana_list_toolsets` | Meta | List available toolsets for [dynamic discovery](#dynamic-toolset-discovery) | None (meta-tool) | N/A | +| `grafana_enable_toolset` | Meta | Enable a specific toolset [dynamically](#dynamic-toolset-discovery) | None (meta-tool) | N/A | | `list_teams` | Admin | List all teams | `teams:read` | `teams:*` or `teams:id:1` | | `list_users_by_org` | Admin | List all users in an organization | `users:read` | `global.users:*` or `global.users:id:123` | | `search_dashboards` | Search | Search for dashboards | `dashboards:read` | `dashboards:*` or `dashboards:uid:abc123` | @@ -235,6 +237,7 @@ The `mcp-grafana` binary supports various command-line flags for configuration: **Tool Configuration:** - `--enabled-tools`: Comma-separated list of enabled categories - default: all categories enabled - example: "loki,datasources" +- `--dynamic-toolsets`: Enable dynamic toolset discovery mode (tools loaded on-demand, reduces context window usage) - `--disable-search`: Disable search tools - `--disable-datasource`: Disable datasource tools - `--disable-incident`: Disable incident tools @@ -298,6 +301,60 @@ All read operations remain available, allowing you to query dashboards, run Prom - `--server.tls-cert-file`: Path to TLS certificate file for server HTTPS - `--server.tls-key-file`: Path to TLS private key file for server HTTPS +### Dynamic Toolset Discovery + +For even more efficient context window usage, you can enable **dynamic toolset discovery** mode with the `--dynamic-toolsets` flag. In this mode, tools are not loaded at startup. Instead, clients can discover available toolsets and selectively enable only the ones they need at runtime. + +**Benefits:** +- Significantly reduces initial context window usage by not loading tool descriptions upfront +- Tools are loaded on-demand only when needed +- Preserves context space for more important data + +**How it works:** +1. Start the server with `--dynamic-toolsets` flag +2. Use `grafana_list_toolsets` to discover available toolset categories +3. Use `grafana_enable_toolset` to load specific toolsets (e.g., "datasource", "dashboard") +4. The client receives a `tools/list_changed` notification and refreshes its tool list + +**Integration with `--enabled-tools`:** +- No flag → all toolsets are discoverable +- `--enabled-tools=""` → no toolsets are discoverable +- `--enabled-tools="datasource,dashboard"` → only specified toolsets are discoverable + +**Example configuration for Cursor/VS Code:** +```json +{ + "mcpServers": { + "grafana": { + "command": "mcp-grafana", + "args": ["--dynamic-toolsets"], + "env": { + "GRAFANA_URL": "http://localhost:3000", + "GRAFANA_SERVICE_ACCOUNT_TOKEN": "" + } + } + } +} +``` + +**Limitations and Client Compatibility:** + +Protocol Support: +- ✅ **stdio protocol** - Fully supported +- ✅ **SSE (Server-Sent Events)** - Fully supported +- ✅ **Streamable HTTP** - Fully supported + +Client Support: +- ✅ **Cursor** - Fully supported (supports notifications via stdio, SSE, and streamable-http) +- ✅ **VS Code** - Fully supported (supports notifications via stdio, SSE, and streamable-http) +- ❌ **Claude Desktop** - Not yet supported (no notification support, but open issues exist) +- ❌ **Claude Code** - Not yet supported (no notification support, but open issues exist) + +**Known Behavior:** +There may be a few seconds of delay between calling `grafana_enable_toolset` and the tools becoming available in the client, as the client needs to receive and process the `tools/list_changed` notification. + +**Note:** This is an opt-in feature via the `--dynamic-toolsets` flag. Existing static tool registration remains the default behavior for maximum compatibility. + ## Usage This MCP server works with both local Grafana instances and Grafana Cloud. For Grafana Cloud, use your instance URL (e.g., `https://myinstance.grafana.net`) instead of `http://localhost:3000` in the configuration examples below. diff --git a/cmd/mcp-grafana/main.go b/cmd/mcp-grafana/main.go index 3d1d13a5..46bf5b81 100644 --- a/cmd/mcp-grafana/main.go +++ b/cmd/mcp-grafana/main.go @@ -37,6 +37,7 @@ func maybeAddTools(s *server.MCPServer, tf func(*server.MCPServer), enabledTools // disabledTools indicates whether each category of tools should be disabled. type disabledTools struct { enabledTools string + dynamicTools bool search, datasource, incident, prometheus, loki, alerting, @@ -58,6 +59,7 @@ type grafanaConfig struct { func (dt *disabledTools) addFlags() { flag.StringVar(&dt.enabledTools, "enabled-tools", "search,datasource,incident,prometheus,loki,alerting,dashboard,folder,oncall,asserts,sift,admin,pyroscope,navigation,proxied,annotations", "A comma separated list of tools enabled for this server. Can be overwritten entirely or by disabling specific components, e.g. --disable-search.") + flag.BoolVar(&dt.dynamicTools, "dynamic-toolsets", getEnvBool("GRAFANA_DYNAMIC_TOOLSETS", false), "Enable dynamic tool discovery. When enabled, only discovery tools are registered initially, and other toolsets can be enabled on-demand.") flag.BoolVar(&dt.search, "disable-search", false, "Disable search tools") flag.BoolVar(&dt.datasource, "disable-datasource", false, "Disable datasource tools") flag.BoolVar(&dt.incident, "disable-incident", false, "Disable incident tools") @@ -107,6 +109,61 @@ func (dt *disabledTools) addTools(s *server.MCPServer) { maybeAddTools(s, func(mcp *server.MCPServer) { tools.AddAnnotationTools(mcp, enableWriteTools) }, enabledTools, dt.annotations, "annotations") } +// addToolsDynamically sets up dynamic tool discovery +func (dt *disabledTools) addToolsDynamically(s *server.MCPServer) *mcpgrafana.DynamicToolManager { + dtm := mcpgrafana.NewDynamicToolManager(s) + + enabledTools := strings.Split(dt.enabledTools, ",") + + isEnabled := func(toolName string) bool { + // If enabledTools is empty string, no tools should be available + if dt.enabledTools == "" { + return false + } + return slices.Contains(enabledTools, toolName) + } + + // Define all available toolsets + allToolsets := []struct { + name string + description string + toolNames []string + addFunc func(*server.MCPServer) + }{ + {"search", "Tools for searching dashboards, folders, and other Grafana resources", []string{"search_dashboards", "search_folders"}, tools.AddSearchTools}, + {"datasource", "Tools for listing and fetching datasource details", []string{"list_datasources", "get_datasource_by_uid", "get_datasource_by_name"}, tools.AddDatasourceTools}, + {"incident", "Tools for managing Grafana Incident (create, update, search incidents)", []string{"list_incidents", "create_incident", "add_activity_to_incident", "get_incident"}, tools.AddIncidentTools}, + {"prometheus", "Tools for querying Prometheus metrics and metadata", []string{"list_prometheus_metric_metadata", "query_prometheus", "list_prometheus_metric_names", "list_prometheus_label_names", "list_prometheus_label_values"}, tools.AddPrometheusTools}, + {"loki", "Tools for querying Loki logs and labels", []string{"list_loki_label_names", "list_loki_label_values", "query_loki_stats", "query_loki_logs"}, tools.AddLokiTools}, + {"alerting", "Tools for managing alert rules and notification contact points", []string{"list_alert_rules", "get_alert_rule_by_uid", "list_contact_points", "create_alert_rule", "update_alert_rule", "delete_alert_rule"}, tools.AddAlertingTools}, + {"dashboard", "Tools for managing Grafana dashboards (get, update, extract queries)", []string{"get_dashboard_by_uid", "update_dashboard", "get_dashboard_panel_queries", "get_dashboard_property", "get_dashboard_summary"}, tools.AddDashboardTools}, + {"folder", "Tools for managing Grafana folders", []string{"create_folder"}, tools.AddFolderTools}, + {"oncall", "Tools for managing OnCall schedules, shifts, teams, and users", []string{"list_oncall_schedules", "get_oncall_shift", "get_current_oncall_users", "list_oncall_teams", "list_oncall_users", "list_alert_groups", "get_alert_group"}, tools.AddOnCallTools}, + {"asserts", "Tools for Grafana Asserts cloud functionality", []string{"get_assertions"}, tools.AddAssertsTools}, + {"sift", "Tools for Sift investigations (analyze logs/traces, find errors, detect slow requests)", []string{"get_sift_investigation", "get_sift_analysis", "list_sift_investigations", "find_error_pattern_logs", "find_slow_requests"}, tools.AddSiftTools}, + {"admin", "Tools for administrative tasks (list teams, manage users)", []string{"list_teams", "list_users_by_org"}, tools.AddAdminTools}, + {"pyroscope", "Tools for profiling applications with Pyroscope", []string{"list_pyroscope_label_names", "list_pyroscope_label_values", "list_pyroscope_profile_types", "fetch_pyroscope_profile"}, tools.AddPyroscopeTools}, + {"navigation", "Tools for generating deeplink URLs to Grafana resources", []string{"generate_deeplink"}, tools.AddNavigationTools}, + } + + // Only register toolsets that are enabled + for _, toolset := range allToolsets { + if isEnabled(toolset.name) { + dtm.RegisterToolset(&mcpgrafana.Toolset{ + Name: toolset.name, + Description: toolset.description, + ToolNames: toolset.toolNames, + AddFunc: toolset.addFunc, + }) + } + } + + // Add the dynamic discovery tools themselves + mcpgrafana.AddDynamicDiscoveryTools(dtm, s) + + return dtm +} + func newServer(transport string, dt disabledTools) (*server.MCPServer, *mcpgrafana.ToolManager) { sm := mcpgrafana.NewSessionManager() @@ -145,8 +202,39 @@ func newServer(transport string, dt disabledTools) (*server.MCPServer, *mcpgrafa }, } } - s := server.NewMCPServer("mcp-grafana", mcpgrafana.Version(), - server.WithInstructions(` + + // Build instructions based on configuration + var instructions string + if dt.dynamicTools { + instructions = ` +This server provides access to your Grafana instance and the surrounding ecosystem with dynamic tool discovery. + +Getting Started: +1. Use 'grafana_list_toolsets' to see all available toolsets +2. Use 'grafana_enable_toolset' to enable specific functionality you need +3. Once enabled, the toolset's tools will be available for use + +Available Toolset Categories: +- search: Search dashboards, folders, and resources +- datasource: Manage datasources +- prometheus: Query Prometheus metrics +- loki: Query Loki logs +- dashboard: Manage dashboards +- folder: Manage folders +- incident: Manage incidents +- alerting: Manage alerts +- oncall: Manage OnCall schedules +- asserts: Grafana Asserts functionality +- sift: Sift investigations +- admin: Administrative tasks +- pyroscope: Application profiling +- navigation: Generate deeplinks +- proxied: Access tools from external MCP servers (like Tempo) through dynamic discovery + +Note that some of these capabilities may be disabled. Do not try to use features that are not available via tools. +` + } else { + instructions = ` This server provides access to your Grafana instance and the surrounding ecosystem. Available Capabilities: @@ -163,14 +251,29 @@ Available Capabilities: - Proxied Tools: Access tools from external MCP servers (like Tempo) through dynamic discovery. Note that some of these capabilities may be disabled. Do not try to use features that are not available via tools. -`), +` + } + + // Determine if we need tool capabilities enabled (for dynamic toolsets or proxied tools) + toolCapabilitiesEnabled := dt.dynamicTools || (!dt.proxied && transport != "stdio") + + s := server.NewMCPServer("mcp-grafana", mcpgrafana.Version(), + server.WithInstructions(instructions), server.WithHooks(hooks), + server.WithToolCapabilities(toolCapabilitiesEnabled), ) // Initialize ToolManager now that server is created stm = mcpgrafana.NewToolManager(sm, s, mcpgrafana.WithProxiedTools(!dt.proxied)) - dt.addTools(s) + if dt.dynamicTools { + // For dynamic toolsets, start with only discovery tools + // Tools will be added dynamically when toolsets are enabled + dt.addToolsDynamically(s) + } else { + dt.addTools(s) + } + return s, stm } @@ -368,6 +471,14 @@ func main() { } } +// getEnvBool reads a boolean from an environment variable +func getEnvBool(key string, defaultValue bool) bool { + if value, exists := os.LookupEnv(key); exists { + return value == "1" || strings.ToLower(value) == "true" + } + return defaultValue +} + func parseLevel(level string) slog.Level { var l slog.Level if err := l.UnmarshalText([]byte(level)); err != nil { diff --git a/dynamic_tools.go b/dynamic_tools.go new file mode 100644 index 00000000..4af981c5 --- /dev/null +++ b/dynamic_tools.go @@ -0,0 +1,164 @@ +package mcpgrafana + +import ( + "context" + "fmt" + "log/slog" + "sync" + + "github.com/mark3labs/mcp-go/server" +) + +// Toolset represents a category of related tools that can be dynamically enabled or disabled +type Toolset struct { + Name string + Description string + Tools []Tool + ToolNames []string // Names of tools provided by this toolset (e.g., ["grafana_query_prometheus", "grafana_list_prometheus_metric_metadata"]) + AddFunc func(*server.MCPServer) +} + +// DynamicToolManager manages dynamic tool registration and discovery +type DynamicToolManager struct { + server *server.MCPServer + toolsets map[string]*Toolset + enabled map[string]bool + mu sync.RWMutex +} + +// NewDynamicToolManager creates a new dynamic tool manager +func NewDynamicToolManager(srv *server.MCPServer) *DynamicToolManager { + return &DynamicToolManager{ + server: srv, + toolsets: make(map[string]*Toolset), + enabled: make(map[string]bool), + } +} + +// RegisterToolset registers a toolset for dynamic discovery +func (dtm *DynamicToolManager) RegisterToolset(toolset *Toolset) { + dtm.mu.Lock() + defer dtm.mu.Unlock() + dtm.toolsets[toolset.Name] = toolset + slog.Debug("Registered toolset", "name", toolset.Name, "description", toolset.Description) +} + +// EnableToolset enables a specific toolset by name +func (dtm *DynamicToolManager) EnableToolset(ctx context.Context, name string) error { + dtm.mu.Lock() + defer dtm.mu.Unlock() + + toolset, exists := dtm.toolsets[name] + if !exists { + return fmt.Errorf("toolset not found: %s", name) + } + + if dtm.enabled[name] { + slog.Debug("Toolset already enabled", "name", name) + return nil + } + + // Add tools using the toolset's AddFunc + // Note: The mcp-go library automatically sends a tools/list_changed notification + // when AddTool is called (via the Register method), so we don't need to manually + // send notifications here. This happens because WithToolCapabilities(true) was set + // during server initialization. + if toolset.AddFunc != nil { + toolset.AddFunc(dtm.server) + } + + dtm.enabled[name] = true + slog.Info("Enabled toolset", "name", name) + return nil +} + +// DisableToolset disables a specific toolset +// Note: mcp-go doesn't support removing tools at runtime, so this just marks it as disabled +func (dtm *DynamicToolManager) DisableToolset(name string) error { + dtm.mu.Lock() + defer dtm.mu.Unlock() + + if _, exists := dtm.toolsets[name]; !exists { + return fmt.Errorf("toolset not found: %s", name) + } + + dtm.enabled[name] = false + slog.Info("Disabled toolset", "name", name) + return nil +} + +// ListToolsets returns information about all available toolsets +func (dtm *DynamicToolManager) ListToolsets() []ToolsetInfo { + dtm.mu.RLock() + defer dtm.mu.RUnlock() + + toolsets := make([]ToolsetInfo, 0, len(dtm.toolsets)) + for name, toolset := range dtm.toolsets { + toolsets = append(toolsets, ToolsetInfo{ + Name: name, + Description: toolset.Description, + Enabled: dtm.enabled[name], + ToolNames: toolset.ToolNames, + }) + } + return toolsets +} + +// ToolsetInfo provides information about a toolset +type ToolsetInfo struct { + Name string `json:"name" jsonschema:"required,description=The name of the toolset"` + Description string `json:"description" jsonschema:"description=Description of what the toolset provides"` + Enabled bool `json:"enabled" jsonschema:"description=Whether the toolset is currently enabled"` + ToolNames []string `json:"toolNames" jsonschema:"description=List of tool names provided by this toolset (e.g.\\, ['grafana_query_prometheus'\\, 'grafana_list_prometheus_metric_metadata'])"` +} + +// AddDynamicDiscoveryTools adds the list and enable toolset tools to the server +func AddDynamicDiscoveryTools(dtm *DynamicToolManager, srv *server.MCPServer) { + // Tool to list all available toolsets + type ListToolsetsRequest struct{} + + listToolsetsHandler := func(ctx context.Context, request ListToolsetsRequest) ([]ToolsetInfo, error) { + return dtm.ListToolsets(), nil + } + + listToolsetsTool := MustTool( + "grafana_list_toolsets", + "List all available Grafana toolsets that can be enabled dynamically. Each toolset provides a category of related functionality.", + listToolsetsHandler, + ) + listToolsetsTool.Register(srv) + + // Tool to enable a specific toolset + type EnableToolsetRequest struct { + Toolset string `json:"toolset" jsonschema:"required,description=The name of the toolset to enable (e.g. 'prometheus' 'loki' 'dashboard' 'incident')"` + } + + enableToolsetHandler := func(ctx context.Context, request EnableToolsetRequest) (string, error) { + if err := dtm.EnableToolset(ctx, request.Toolset); err != nil { + return "", err + } + + // Get toolset info to provide better guidance + toolsetInfo := dtm.getToolsetInfo(request.Toolset) + if toolsetInfo == nil { + return fmt.Sprintf("Successfully enabled toolset: %s. The tools are now available for use.", request.Toolset), nil + } + + return fmt.Sprintf("Successfully enabled toolset: %s\n\nDescription: %s\n\nNote: All tools are already registered and available. You can now use the tools from this toolset directly.", + request.Toolset, toolsetInfo.Description), nil + } + + enableToolsetTool := MustTool( + "grafana_enable_toolset", + "Enable a specific Grafana toolset to make its tools available. Use grafana_list_toolsets to see available toolsets.", + enableToolsetHandler, + ) + enableToolsetTool.Register(srv) +} + +// getToolsetInfo returns information about a specific toolset +func (dtm *DynamicToolManager) getToolsetInfo(name string) *Toolset { + dtm.mu.RLock() + defer dtm.mu.RUnlock() + return dtm.toolsets[name] +} diff --git a/dynamic_tools_test.go b/dynamic_tools_test.go new file mode 100644 index 00000000..db600717 --- /dev/null +++ b/dynamic_tools_test.go @@ -0,0 +1,133 @@ +//go:build unit +// +build unit + +package mcpgrafana + +import ( + "context" + "testing" + + "github.com/mark3labs/mcp-go/server" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestNewDynamicToolManager(t *testing.T) { + srv := server.NewMCPServer("test-server", "1.0.0") + dtm := NewDynamicToolManager(srv) + + assert.NotNil(t, dtm) + assert.NotNil(t, dtm.server) + assert.NotNil(t, dtm.toolsets) + assert.NotNil(t, dtm.enabled) +} + +func TestRegisterAndListToolsets(t *testing.T) { + srv := server.NewMCPServer("test-server", "1.0.0") + dtm := NewDynamicToolManager(srv) + + // Create a test toolset + toolset := &Toolset{ + Name: "test_toolset", + Description: "A test toolset for unit testing", + Tools: []Tool{}, + ToolNames: []string{"test_tool_1", "test_tool_2"}, + AddFunc: func(s *server.MCPServer) { + // Mock add function + }, + } + + // Register the toolset + dtm.RegisterToolset(toolset) + + // List toolsets + toolsets := dtm.ListToolsets() + require.Len(t, toolsets, 1) + assert.Equal(t, "test_toolset", toolsets[0].Name) + assert.Equal(t, "A test toolset for unit testing", toolsets[0].Description) + assert.False(t, toolsets[0].Enabled) // Should be disabled by default + assert.Equal(t, []string{"test_tool_1", "test_tool_2"}, toolsets[0].ToolNames) // Should include tool names when specified +} + +func TestEnableToolset(t *testing.T) { + srv := server.NewMCPServer("test-server", "1.0.0") + dtm := NewDynamicToolManager(srv) + + // Track if AddFunc was called + addFuncCalled := false + + // Create a test toolset + toolset := &Toolset{ + Name: "test_toolset", + Description: "A test toolset", + Tools: []Tool{}, + AddFunc: func(s *server.MCPServer) { + addFuncCalled = true + }, + } + + dtm.RegisterToolset(toolset) + + // Enable the toolset + ctx := context.Background() + err := dtm.EnableToolset(ctx, "test_toolset") + require.NoError(t, err) + assert.True(t, addFuncCalled, "AddFunc should have been called") + + // Check that it's enabled + toolsets := dtm.ListToolsets() + require.Len(t, toolsets, 1) + assert.True(t, toolsets[0].Enabled) + + // Enabling again should not error + err = dtm.EnableToolset(ctx, "test_toolset") + require.NoError(t, err) +} + +func TestEnableNonExistentToolset(t *testing.T) { + srv := server.NewMCPServer("test-server", "1.0.0") + dtm := NewDynamicToolManager(srv) + + ctx := context.Background() + err := dtm.EnableToolset(ctx, "non_existent_toolset") + assert.Error(t, err) + assert.Contains(t, err.Error(), "toolset not found") +} + +func TestDisableToolset(t *testing.T) { + srv := server.NewMCPServer("test-server", "1.0.0") + dtm := NewDynamicToolManager(srv) + + // Create and register a toolset + toolset := &Toolset{ + Name: "test_toolset", + Description: "A test toolset", + Tools: []Tool{}, + AddFunc: nil, + } + dtm.RegisterToolset(toolset) + + // Enable it first + ctx := context.Background() + err := dtm.EnableToolset(ctx, "test_toolset") + require.NoError(t, err) + + // Verify it's enabled + toolsets := dtm.ListToolsets() + require.Len(t, toolsets, 1) + assert.True(t, toolsets[0].Enabled) + + // Disable it + err = dtm.DisableToolset("test_toolset") + require.NoError(t, err) + + // Verify it's disabled + toolsets = dtm.ListToolsets() + require.Len(t, toolsets, 1) + assert.False(t, toolsets[0].Enabled) + + // Try to disable a non-existent toolset + err = dtm.DisableToolset("non_existent") + assert.Error(t, err) + assert.Contains(t, err.Error(), "toolset not found") +}