diff --git a/README.md b/README.md index 933eb41..7cd054b 100644 --- a/README.md +++ b/README.md @@ -155,6 +155,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` | @@ -216,6 +218,7 @@ The `mcp-grafana` binary supports various command-line flags for configuration: **Tool Configuration:** - `--enabled-tools`: Comma-separated list of enabled tools - default: all tools enabled +- `--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 @@ -240,6 +243,60 @@ The `mcp-grafana` binary supports various command-line flags for configuration: - `--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)** - Not yet supported +- ❌ **Streamable HTTP** - Not yet supported + +Client Support: +- ✅ **Cursor** - Fully supported (supports notifications via stdio) +- ✅ **VS Code** - Fully supported (supports notifications via stdio) +- ❌ **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 51a09c6..1e1b416 100644 --- a/cmd/mcp-grafana/main.go +++ b/cmd/mcp-grafana/main.go @@ -36,6 +36,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, @@ -57,6 +58,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", "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", 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") @@ -102,8 +104,88 @@ func (dt *disabledTools) addTools(s *server.MCPServer) { maybeAddTools(s, tools.AddNavigationTools, enabledTools, dt.navigation, "navigation") } +// 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 + addFunc func(*server.MCPServer) + }{ + {"search", "Tools for searching dashboards, folders, and other Grafana resources", tools.AddSearchTools}, + {"datasource", "Tools for listing and fetching datasource details", tools.AddDatasourceTools}, + {"incident", "Tools for managing Grafana Incident (create, update, search incidents)", tools.AddIncidentTools}, + {"prometheus", "Tools for querying Prometheus metrics and metadata", tools.AddPrometheusTools}, + {"loki", "Tools for querying Loki logs and labels", tools.AddLokiTools}, + {"alerting", "Tools for managing alert rules and notification contact points", tools.AddAlertingTools}, + {"dashboard", "Tools for managing Grafana dashboards (get, update, extract queries)", tools.AddDashboardTools}, + {"folder", "Tools for managing Grafana folders", tools.AddFolderTools}, + {"oncall", "Tools for managing OnCall schedules, shifts, teams, and users", tools.AddOnCallTools}, + {"asserts", "Tools for Grafana Asserts cloud functionality", tools.AddAssertsTools}, + {"sift", "Tools for Sift investigations (analyze logs/traces, find errors, detect slow requests)", tools.AddSiftTools}, + {"admin", "Tools for administrative tasks (list teams, manage users)", tools.AddAdminTools}, + {"pyroscope", "Tools for profiling applications with Pyroscope", tools.AddPyroscopeTools}, + {"navigation", "Tools for generating deeplink URLs to Grafana resources", 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, + AddFunc: toolset.addFunc, + }) + } + } + + // Add the dynamic discovery tools themselves + mcpgrafana.AddDynamicDiscoveryTools(dtm, s) + + return dtm +} + func newServer(dt disabledTools) *server.MCPServer { - s := server.NewMCPServer("mcp-grafana", mcpgrafana.Version(), server.WithInstructions(` + 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 + ` + } else { + instructions = ` This server provides access to your Grafana instance and the surrounding ecosystem. Available Capabilities: @@ -117,8 +199,22 @@ func newServer(dt disabledTools) *server.MCPServer { - Admin: List teams and perform administrative tasks. - Pyroscope: Profile applications and fetch profiling data. - Navigation: Generate deeplink URLs for Grafana resources like dashboards, panels, and Explore queries. - `)) - dt.addTools(s) + ` + } + + // Create server with tool capabilities enabled for dynamic tool discovery + s := server.NewMCPServer("mcp-grafana", mcpgrafana.Version(), + server.WithInstructions(instructions), + server.WithToolCapabilities(true)) + + 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 } diff --git a/dynamic_tools.go b/dynamic_tools.go new file mode 100644 index 0000000..693e948 --- /dev/null +++ b/dynamic_tools.go @@ -0,0 +1,160 @@ +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 + 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], + }) + } + 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"` +} + +// AddDynamicDiscoveryTools adds the list and enable toolset tools to the server +func AddDynamicDiscoveryTools(dtm *DynamicToolManager, srv *server.MCPServer) { + 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 0000000..7eb4d0c --- /dev/null +++ b/dynamic_tools_test.go @@ -0,0 +1,131 @@ +//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{}, + 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 +} + +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") +}