Files
zooqueen 0b920d37c6 o11y: this module is hanzoai/o11y-mcp, and it consumes hanzoai/otel-collector
The module declared itself github.com/SigNoz/signoz-mcp-server and imported the
schema migrator from github.com/SigNoz/signoz-otel-collector — a fork that
hanzoai/otel-collector superseded, where the same package is
cmd/o11yschemamigrator/schema_migrator. Both now point at the repo that is
actually maintained, at v1.3.0.

Go identifiers came with it: the client type is Client rather than SigNoz, the
import alias is o11yclient, and the env-name constants are EnvURL and
EnvAPIKey.

NOT touched, because each is a contract with something running and renaming it
breaks that thing rather than debranding it:

  the 30 MCP tool names (signoz_list_alerts, signoz_get_trace_details, …) and
    the signoz://dashboard/* resource URIs, which are this server's public API
    and are what an agent calls;
  the env vars SIGNOZ_URL and SIGNOZ_API_KEY and the SIGNOZ-API-KEY header,
    which the deployment sets;
  the table names — signoz_traces.distributed_signoz_index_v3, signoz_logs.*,
    signoz_metrics.* — and the metric names signoz_calls_total and
    signoz_latency_*, which name rows and series that exist on the server.

Those want a coordinated change on both sides, not a rename here.

go build and go vet both clean.
2026-08-11 11:47:40 -07:00

158 lines
4.5 KiB
Go

package tools
import (
"encoding/json"
"fmt"
"strconv"
"strings"
"github.com/hanzoai/o11y-mcp/pkg/timeutil"
"github.com/hanzoai/o11y-mcp/pkg/types"
)
var validAggregations = map[string]bool{
"count": true,
"count_distinct": true,
"avg": true,
"sum": true,
"min": true,
"max": true,
"p50": true,
"p75": true,
"p90": true,
"p95": true,
"p99": true,
"rate": true,
}
var aggregationsWithoutField = map[string]bool{
"count": true,
"rate": true,
}
const allowedAggregations = "avg, count, count_distinct, max, min, p50, p75, p90, p95, p99, rate, sum"
// AggregateRequest keeps parameters for any aggregation query.
type AggregateRequest struct {
AggregationExpr string
FilterExpression string
GroupBy []types.SelectField
OrderExpr string
OrderDir string
Limit int
StartTime int64
EndTime int64
}
// parseAggregateArgs validates and parses aggregate arguments.
// this is crucial as the input is provided by llm and if there is an error it must be suggested how to correct
func parseAggregateArgs(args map[string]any, signal string, filterExpr string) (*AggregateRequest, error) {
aggregation, _ := args["aggregation"].(string)
if aggregation == "" {
return nil, fmt.Errorf(
"\"aggregation\" is required. Supported values: %s. "+
"Tip: for simple totals use {\"aggregation\": \"count\", \"groupBy\": \"service.name\"}",
allowedAggregations)
}
if !validAggregations[aggregation] {
return nil, fmt.Errorf(
"invalid aggregation %q. Supported values: %s. "+
"Tip: for counting use \"count\", for averages use \"avg\"",
aggregation, allowedAggregations)
}
aggregateOn, _ := args["aggregateOn"].(string)
if !aggregationsWithoutField[aggregation] && aggregateOn == "" {
return nil, fmt.Errorf(
"\"aggregateOn\" is required for %q aggregation. Specify the field to aggregate, "+
"e.g. {\"aggregation\": \"%s\", \"aggregateOn\": \"duration\"}",
aggregation, aggregation)
}
var aggregationExpr string
if aggregateOn != "" {
aggregationExpr = fmt.Sprintf("%s(%s)", aggregation, aggregateOn)
} else {
aggregationExpr = fmt.Sprintf("%s()", aggregation)
}
var groupByFields []types.SelectField
if groupByStr, _ := args["groupBy"].(string); groupByStr != "" {
for _, field := range strings.Split(groupByStr, ",") {
field = strings.TrimSpace(field)
if field != "" {
groupByFields = append(groupByFields, types.SelectField{Name: field, Signal: signal})
}
}
}
orderByRaw, _ := args["orderBy"].(string)
orderByStr := strings.TrimSpace(orderByRaw)
orderExpr, orderDir := aggregationExpr, "desc"
if orderByStr != "" {
lower := strings.ToLower(orderByStr)
switch {
case strings.HasSuffix(lower, " asc"):
orderExpr = strings.TrimSpace(orderByStr[:len(orderByStr)-4])
orderDir = "asc"
case strings.HasSuffix(lower, " desc"):
orderExpr = strings.TrimSpace(orderByStr[:len(orderByStr)-5])
default:
orderExpr = orderByStr
}
}
limit, err := intArg(args, "limit", 10)
if err != nil {
return nil, err
}
startTime, endTime, err := resolveTimestamps(args, "1h")
if err != nil {
return nil, err
}
return &AggregateRequest{
AggregationExpr: aggregationExpr,
FilterExpression: filterExpr,
GroupBy: groupByFields,
OrderExpr: orderExpr,
OrderDir: orderDir,
Limit: limit,
StartTime: startTime,
EndTime: endTime,
}, nil
}
func resolveTimestamps(args map[string]any, defaultRange string) (int64, int64, error) {
if _, ok := args["timeRange"]; !ok {
if _, ok := args["start"]; !ok {
args["timeRange"] = defaultRange
}
}
start, end := timeutil.GetTimestampsWithDefaults(args, "ms")
var startTime, endTime int64
if err := json.Unmarshal([]byte(start), &startTime); err != nil {
return 0, 0, fmt.Errorf("invalid start timestamp: use timeRange instead (e.g., \"1h\", \"24h\")")
}
if err := json.Unmarshal([]byte(end), &endTime); err != nil {
return 0, 0, fmt.Errorf("invalid end timestamp: use timeRange instead (e.g., \"1h\", \"24h\")")
}
return startTime, endTime, nil
}
func intArg(args map[string]any, key string, defaultVal int) (int, error) {
str, _ := args[key].(string)
if str == "" {
return defaultVal, nil
}
num, err := strconv.Atoi(str)
if err != nil {
return 0, fmt.Errorf("invalid %q value %q: must be a number", key, str)
}
if num <= 0 {
return defaultVal, nil
}
return num, nil
}