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
31 changes: 31 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ A Model Context Protocol (MCP) server that provides seamless access to SigNoz ob
- **Traces**: Search, analyze, get hierarchy and relationship of traces.
- **List Dashboards**: Get dashboard summaries (name, UUID, description, tags).
- **Get Dashboard**: Retrieve complete dashboard configurations with panels and queries.
- **Create Dashboard**: Creates a new monitoring dashboard based on the provided title, layout, and widget configuration. **Warning**: Requires full dashboard JSON which can consume large amounts of context window space.
- **Update Dashboard**: Updates an existing dashboard using its UUID and a complete dashboard JSON object. Requires the entire post-update configuration and cannot accept partial patches.
- **List Services**: Discover all services within specified time ranges.
- **Service Top Operations**: Analyze performance metrics for specific services.
- **Query Builder**: Generates query to get complex response.
Expand Down Expand Up @@ -255,6 +257,7 @@ The MCP server provides the following tools that can be used through natural lan
```
"List all dashboards"
"Show me the Host Metrics dashboard details"
"Create a dashboard with a specified name, optional tags, and a widget visualizing a chosen metric."
```

#### Service Analysis
Expand Down Expand Up @@ -307,6 +310,34 @@ Lists all dashboards with summaries (name, UUID, description, tags).
Gets complete dashboard configuration.
- **Parameters**: `uuid` (required) - Dashboard UUID

#### `signoz_create_dashboard`
Creates a dashboard.

- **Parameters:**
- title (required) – Dashboard name
- description (optional) – Short summary of what the dashboard shows
- tags (optional) – List of tags
- layout (required) – Widget positioning grid
- variables (optional) – Map of variables available for use in queries
- widgets (required) – List of widgets added to the dashboard
- **Returns**
Dashboard metadata, layout array, widgets array, and stored dashboard config.

### `signoz_update_dashboard`
Updates an existing dashboard.

- **Parameters**
- uuid (required) – Unique identifier of the dashboard to update
- title (required) – Dashboard name
- description (optional) – Short summary of what the dashboard shows
- tags (optional) – List of tags applied to the dashboard
- layout (required) – Full widget positioning grid
- variables (optional) – Map of variables available for use in queries
- widgets (required) – Complete set of widgets defining the updated dashboard

**Returns**
A success confirmation only. No response body is provided.

#### `list_services`
Lists all services within a time range.
- **Parameters**:
Expand Down
75 changes: 75 additions & 0 deletions internal/client/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -719,3 +719,78 @@ func (s *SigNoz) GetTraceSpanHierarchy(ctx context.Context, traceID string, star

return s.QueryBuilderV5(ctx, queryJSON)
}

func (s *SigNoz) CreateDashboard(ctx context.Context, dashboard types.Dashboard) (json.RawMessage, error) {
url := fmt.Sprintf("%s/api/v1/dashboards", s.baseURL)

dashboardJSON, err := json.Marshal(dashboard)
if err != nil {
return nil, fmt.Errorf("marshal dashboard: %w", err)
}

req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewBuffer(dashboardJSON))
if err != nil {
return nil, fmt.Errorf("new request: %w", err)
}

req.Header.Set(SignozApiKey, s.apiKey)
req.Header.Set(ContentType, "application/json")

timeoutCtx, cancel := context.WithTimeout(ctx, 30*time.Second)
defer cancel()

client := &http.Client{Timeout: 30 * time.Second}

resp, err := client.Do(req.WithContext(timeoutCtx))
if err != nil {
return nil, fmt.Errorf("do request: %w", err)
}
defer func() { _ = resp.Body.Close() }()

if resp.StatusCode < 200 || resp.StatusCode >= 300 {
body, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("unexpected status %d: %s", resp.StatusCode, string(body))
}

body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("read response: %w", err)
}

return body, nil
}

func (s *SigNoz) UpdateDashboard(ctx context.Context, id string, dashboard types.Dashboard) error {
url := fmt.Sprintf("%s/api/v1/dashboards/%s", s.baseURL, id)

dashboardJSON, err := json.Marshal(dashboard)
if err != nil {
return fmt.Errorf("marshal dashboard: %w", err)
}

req, err := http.NewRequestWithContext(ctx, http.MethodPut, url, bytes.NewBuffer(dashboardJSON))
if err != nil {
return fmt.Errorf("new request: %w", err)
}

req.Header.Set(SignozApiKey, s.apiKey)
req.Header.Set(ContentType, "application/json")

timeoutCtx, cancel := context.WithTimeout(ctx, 30*time.Second)
defer cancel()

client := &http.Client{Timeout: 30 * time.Second}

resp, err := client.Do(req.WithContext(timeoutCtx))
if err != nil {
return fmt.Errorf("do request: %w", err)
}
defer func() { _ = resp.Body.Close() }()

if resp.StatusCode < 200 || resp.StatusCode >= 300 {
body, _ := io.ReadAll(resp.Body)
return fmt.Errorf("unexpected status %d: %s", resp.StatusCode, string(body))
}

return nil
}
71 changes: 71 additions & 0 deletions internal/client/client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -812,3 +812,74 @@ func TestQueryBuilderV5(t *testing.T) {
})
}
}

func TestCreateDashboard(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, http.MethodPost, r.Method)
assert.Equal(t, "/api/v1/dashboards", r.URL.Path)
assert.Equal(t, "application/json", r.Header.Get("Content-Type"))
assert.Equal(t, "test-api-key", r.Header.Get("SIGNOZ-API-KEY"))

var body types.Dashboard
err := json.NewDecoder(r.Body).Decode(&body)
require.NoError(t, err)

assert.NotEmpty(t, body.Title)
assert.NotNil(t, body.Layout)
assert.NotNil(t, body.Widgets)

w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(`{"status":"success","id":"dashboard-123"}`))
}))
defer server.Close()

logger, _ := zap.NewDevelopment()
client := NewClient(logger, server.URL, "test-api-key")

d := types.Dashboard{
Title: "whatever",
Layout: []types.LayoutItem{},
Widgets: []types.Widget{},
}

ctx := context.Background()
resp, err := client.CreateDashboard(ctx, d)
require.NoError(t, err)

var out map[string]interface{}
err = json.Unmarshal(resp, &out)
require.NoError(t, err)

assert.Equal(t, "success", out["status"])
assert.Equal(t, "dashboard-123", out["id"])
}

func TestUpdateDashboard(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, http.MethodPut, r.Method)
assert.Equal(t, "/api/v1/dashboards/id-123", r.URL.Path)
assert.Equal(t, "application/json", r.Header.Get("Content-Type"))
assert.Equal(t, "test-api-key", r.Header.Get("SIGNOZ-API-KEY"))

var body types.Dashboard
err := json.NewDecoder(r.Body).Decode(&body)
require.NoError(t, err)

assert.Equal(t, "updated-title", body.Title)

w.WriteHeader(http.StatusOK)
}))
defer srv.Close()

logger, _ := zap.NewDevelopment()
client := NewClient(logger, srv.URL, "test-api-key")

d := types.Dashboard{
Title: "updated-title",
Layout: []types.LayoutItem{},
Widgets: []types.Widget{},
}

err := client.UpdateDashboard(context.Background(), "id-123", d)
require.NoError(t, err)
}
91 changes: 91 additions & 0 deletions internal/handler/tools/handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -378,6 +378,97 @@ func (h *Handler) RegisterDashboardHandlers(s *server.MCPServer) {
}
return mcp.NewToolResultText(string(data)), nil
})

createDashboardTool := mcp.NewTool(
"signoz_create_dashboard",
mcp.WithDescription("Creates a new monitoring dashboard based on the provided title, layout, and widget configuration. Use this tool when the user asks to build or create a new dashboard."),
mcp.WithInputSchema[types.Dashboard](),
)

s.AddTool(createDashboardTool, func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
rawConfig, ok := req.Params.Arguments.(map[string]any)

if !ok || len(rawConfig) == 0 {
h.logger.Warn("Received empty or invalid arguments map from Claude.")
return mcp.NewToolResultError(`Parameter validation failed: The dashboard configuration object is empty or improperly formatted.`), nil
}

configJSON, err := json.Marshal(rawConfig)
if err != nil {
h.logger.Error("Failed to unmarshal raw configuration", zap.Error(err))
return mcp.NewToolResultError(
fmt.Sprintf("Could not decode raw configuration. Error: %s", err.Error()),
), nil
}

var dashboardConfig types.Dashboard
if err := json.Unmarshal(configJSON, &dashboardConfig); err != nil {
return mcp.NewToolResultError(
fmt.Sprintf("Parameter decoding error: The provided JSON structure for the dashboard configuration is invalid. Error details: %s", err.Error()),
), nil
}

h.logger.Debug("Tool called: signoz_create_dashboard", zap.String("title", dashboardConfig.Title))
client := h.GetClient(ctx)
data, err := client.CreateDashboard(ctx, dashboardConfig)

if err != nil {
h.logger.Error("Failed to create dashboard in SigNoz", zap.Error(err))
return mcp.NewToolResultError(fmt.Sprintf("SigNoz API Error: %s", err.Error())), nil
}

return mcp.NewToolResultText(string(data)), nil
})

updateDashboardTool := mcp.NewTool(
"signoz_update_dashboard",
mcp.WithDescription(
"Update an existing dashboard by supplying its UUID along with a fully assembled dashboard JSON object."+
"The provided object must represent the complete post-update state, combining the current dashboard data and the intended modifications."+
"Use this tool when the user asks to update an existing dashboard.",
),
mcp.WithInputSchema[types.UpdateDashboardInput](),
)

s.AddTool(updateDashboardTool, func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
rawConfig, ok := req.Params.Arguments.(map[string]any)

if !ok || len(rawConfig) == 0 {
h.logger.Warn("Received empty or invalid arguments map from Claude.")
return mcp.NewToolResultError(`Parameter validation failed: The dashboard configuration object is empty or improperly formatted.`), nil
}

configJSON, err := json.Marshal(rawConfig)
if err != nil {
h.logger.Error("Failed to unmarshal raw configuration", zap.Error(err))
return mcp.NewToolResultError(
fmt.Sprintf("Could not decode raw configuration. Error: %s", err.Error()),
), nil
}

var updateDashboardConfig types.UpdateDashboardInput
if err := json.Unmarshal(configJSON, &updateDashboardConfig); err != nil {
return mcp.NewToolResultError(
fmt.Sprintf("Parameter decoding error: The provided JSON structure for the dashboard configuration is invalid. Error details: %s", err.Error()),
), nil
}

if updateDashboardConfig.UUID == "" {
h.logger.Warn("Empty uuid parameter")
return mcp.NewToolResultError(`Parameter validation failed: "uuid" cannot be empty. Provide a valid dashboard UUID. Use list_dashboards tool to see available dashboards.`), nil
}

h.logger.Debug("Tool called: signoz_update_dashboard", zap.String("title", updateDashboardConfig.Dashboard.Title))
client := h.GetClient(ctx)
err = client.UpdateDashboard(ctx, updateDashboardConfig.UUID, updateDashboardConfig.Dashboard)

if err != nil {
h.logger.Error("Failed to update dashboard in SigNoz", zap.Error(err))
return mcp.NewToolResultError(fmt.Sprintf("SigNoz API Error: %s", err.Error())), nil
}

return mcp.NewToolResultText("dashboard updated"), nil
})
}

func (h *Handler) RegisterServiceHandlers(s *server.MCPServer) {
Expand Down
8 changes: 8 additions & 0 deletions manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,14 @@
{
"name": "signoz_query_helper",
"description": "Helper for building SigNoz queries"
},
{
"name": "signoz_create_dashboard",
"description": "Creates a new monitoring dashboard based on the provided title, layout, and widget configuration. Use this tool when the user asks to build or create a new dashboard."
},
{
"name": "signoz_update_dashboard",
"description": "Update an existing dashboard by supplying its UUID along with a fully assembled dashboard JSON object. The provided object must represent the complete post-update state, combining the current dashboard data and the intended modifications. Use this tool when the user asks to update an existing dashboard."
}
],
"compatibility": {
Expand Down
62 changes: 62 additions & 0 deletions pkg/types/dashboard.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
package types

type Dashboard struct {
Title string `json:"title" jsonschema:"required" jsonschema_extras:"description=The display name of the dashboard."`
Description string `json:"description,omitempty" jsonschema_extras:"description=A brief explanation of what the dashboard shows."`
Tags []string `json:"tags,omitempty" jsonschema_extras:"description=Keywords for categorization e.g performance latency."`

Layout []LayoutItem `json:"layout" jsonschema:"required" jsonschema_extras:"description=Defines the grid positioning and size for each widget."`
Variables map[string]Variable `json:"variables,omitempty" jsonschema_extras:"description=Key-value map of template variables available for queries."`

Widgets []Widget `json:"widgets" jsonschema:"required" jsonschema_extras:"description=The list of all graphical components displayed on the dashboard."`
}

type LayoutItem struct {
X int `json:"x" jsonschema:"required" jsonschema_extras:"description=The x-coordinate (column) of the item."`
Y int `json:"y" jsonschema:"required" jsonschema_extras:"description=The y-coordinate (row) of the item."`
W int `json:"w" jsonschema:"required" jsonschema_extras:"description=The width of the item in grid units."`
H int `json:"h" jsonschema:"required" jsonschema_extras:"description=The height of the item in grid units."`
I string `json:"i" jsonschema:"required" jsonschema_extras:"description=The unique ID linking the layout item to a specific widget."`
Moved bool `json:"moved,omitempty" jsonschema_extras:"description=Indicates if the item has been moved from its default position."`
Static bool `json:"static,omitempty" jsonschema_extras:"description=If true, the item cannot be moved or resized."`
}

type Variable struct {
ID string `json:"id,omitempty" jsonschema_extras:"description=The unique ID of the variable."`
Name string `json:"name,omitempty" jsonschema_extras:"description=The user-facing display name."`
Description string `json:"description,omitempty" jsonschema_extras:"description=Description of the variable's purpose."`
Key string `json:"key,omitempty" jsonschema_extras:"description=The internal key used in queries e.g $instance."`
Type string `json:"type,omitempty" jsonschema_extras:"description=The variable type e.g query or textbox."`
QueryValue string `json:"queryValue,omitempty" jsonschema_extras:"description=The expression used to populate variable options."`
AllSelected bool `json:"allSelected,omitempty" jsonschema_extras:"description=True if the All option is selected."`
CustomValue string `json:"customValue,omitempty" jsonschema_extras:"description=Custom user-defined value if supported."`
MultiSelect bool `json:"multiSelect,omitempty" jsonschema_extras:"description=Allows selecting multiple values."`
Order int `json:"order,omitempty" jsonschema_extras:"description=Display order in the UI."`
ShowALLOption bool `json:"showALLOption,omitempty" jsonschema_extras:"description=If true the All option appears in the list."`
Sort string `json:"sort,omitempty" jsonschema_extras:"description=Sorting of variable options."`
TextboxValue string `json:"textboxValue,omitempty" jsonschema_extras:"description=Current value for a textbox variable."`
}

type Widget struct {
ID string `json:"id" jsonschema:"required" jsonschema_extras:"description=Unique identifier for the widget."`
Description string `json:"description,omitempty" jsonschema_extras:"description=Details about the data shown in the panel."`
IsStacked bool `json:"isStacked,omitempty" jsonschema_extras:"description=Applies to graph panels. True means stacked series."`
NullZeroValues bool `json:"nullZeroValues,omitempty" jsonschema_extras:"description=Treats null values as zero."`
Opacity int `json:"opacity,omitempty" jsonschema_extras:"description=Transparency level of the visualization."`
PanelTypes string `json:"panelTypes" jsonschema:"required" jsonschema_extras:"description=Visualization type e.g - graph table value list trace."`
TimePreferance string `json:"timePreferance,omitempty" jsonschema_extras:"description=Widget-specific time override instead of dashboard time."`
Title string `json:"title" jsonschema:"required" jsonschema_extras:"description=Title displayed at the top of the widget."`
YAxisUnit string `json:"yAxisUnit,omitempty" jsonschema_extras:"description=Unit of the Y-axis e.g. ms | percent"`
Query QueryBody `json:"query" jsonschema:"required" jsonschema_extras:"description=Data source and expressions used to fetch data for the widget."`
}

type QueryBody struct {
PromQL []string `json:"promql,omitempty" jsonschema_extras:"description=List of Prometheus Query Language expressions."`
ClickhouseSQL []string `json:"clickhouse_sql,omitempty" jsonschema_extras:"description=List of ClickHouse SQL queries."`
Builder map[string]interface{} `json:"builder,omitempty" jsonschema_extras:"description=Configuration for visual query builder mode."`
}

type UpdateDashboardInput struct {
UUID string `json:"uuid" jsonschema:"required" jsonschema_extras:"description=Dashboard UUID to update."`
Dashboard Dashboard `json:"dashboard" jsonschema:"required" jsonschema_extras:"description=Full dashboard configuration representing the complete post-update state."`
}