Skip to content
Merged
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
5 changes: 0 additions & 5 deletions e2e/e2e_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -423,7 +423,6 @@ func TestQueryIncidents(t *testing.T) {
"since": strconv.FormatInt(startTime, 10),
"until": strconv.FormatInt(now, 10),
"limit": 10,
"include_alerts": false,
})

var result struct {
Expand Down Expand Up @@ -605,7 +604,6 @@ func TestIncidentLifecycle(t *testing.T) {
t.Log("Querying the created incident...")
queryResponseText := callTool(t, mcpClient, "query_incidents", map[string]any{
"incident_ids": incidentID,
"include_alerts": false,
})

var queryResult struct {
Expand Down Expand Up @@ -643,7 +641,6 @@ func TestIncidentLifecycle(t *testing.T) {
t.Log("Verifying incident is in Processing state...")
queryResponseText = callTool(t, mcpClient, "query_incidents", map[string]any{
"incident_ids": incidentID,
"include_alerts": false,
})
unmarshalToolResponse(t, queryResponseText, &queryResult)

Expand All @@ -669,7 +666,6 @@ func TestIncidentLifecycle(t *testing.T) {
t.Log("Verifying incident is Closed...")
queryResponseText = callTool(t, mcpClient, "query_incidents", map[string]any{
"incident_ids": incidentID,
"include_alerts": false,
})
unmarshalToolResponse(t, queryResponseText, &queryResult)

Expand Down Expand Up @@ -825,7 +821,6 @@ func TestUpdateIncident(t *testing.T) {
t.Log("Verifying the update...")
queryResponseText := callTool(t, mcpClient, "query_incidents", map[string]any{
"incident_ids": incidentID,
"include_alerts": false,
})

var queryResult struct {
Expand Down
2 changes: 0 additions & 2 deletions e2e/fixes_validation_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,6 @@ func TestQueryIncidentsChannelFilter(t *testing.T) {
"since": strconv.FormatInt(startTime, 10),
"until": strconv.FormatInt(now, 10),
"limit": 100,
"include_alerts": false,
})
var allResp struct {
Incidents []struct {
Expand Down Expand Up @@ -62,7 +61,6 @@ func TestQueryIncidentsChannelFilter(t *testing.T) {
"since": strconv.FormatInt(startTime, 10),
"until": strconv.FormatInt(now, 10),
"limit": 100,
"include_alerts": false,
"channel_ids": strconv.FormatInt(target, 10),
})
var filtered struct {
Expand Down
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ go 1.25.5

require (
github.com/bluele/gcache v0.0.2
github.com/flashcatcloud/flashduty-sdk v0.8.1-0.20260510030603-7a1f724ceb79
github.com/flashcatcloud/flashduty-sdk v0.8.1-0.20260510062220-7b45b9d90798
github.com/google/go-github/v72 v72.0.0
github.com/josephburnett/jd v1.9.2
github.com/mark3labs/mcp-go v0.52.0
Expand Down
4 changes: 2 additions & 2 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,8 @@ github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI=
github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
github.com/flashcatcloud/flashduty-sdk v0.8.1-0.20260510030603-7a1f724ceb79 h1:VpHQKfWBw2hMKScvGvF/u7jUug44qk2ALD/E0v88ohM=
github.com/flashcatcloud/flashduty-sdk v0.8.1-0.20260510030603-7a1f724ceb79/go.mod h1:dG4eJfdZaj4jNBMwEexbfK/3PmcIMhNeJ88L/DcZzUY=
github.com/flashcatcloud/flashduty-sdk v0.8.1-0.20260510062220-7b45b9d90798 h1:FK1Ueq3ROSxCTTKX8rq0m5x3JcbAsoTnLsty5klbMEs=
github.com/flashcatcloud/flashduty-sdk v0.8.1-0.20260510062220-7b45b9d90798/go.mod h1:dG4eJfdZaj4jNBMwEexbfK/3PmcIMhNeJ88L/DcZzUY=
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
github.com/fsnotify/fsnotify v1.8.0 h1:dAwr6QBTBZIkG8roQaJjGof0pp0EeF+tNV7YBP3F/8M=
Expand Down
43 changes: 26 additions & 17 deletions pkg/flashduty/incidents.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ import (

const defaultQueryLimit = 20

const queryIncidentsDescription = `Query incidents by IDs, time range, status, severity, channel, or free-text query. Returns enriched data with names.`
const queryIncidentsDescription = `Query incidents by IDs, time range, status, severity, channel, or free-text query. Returns the incident list with an alerts_total count per incident; for the actual alert objects of one or more incidents, call query_incident_alerts(incident_ids=...).`

// QueryIncidents creates a tool to query incidents with enriched data
func QueryIncidents(getClient GetFlashdutyClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) {
Expand All @@ -34,7 +34,6 @@ func QueryIncidents(getClient GetFlashdutyClientFn, t translations.TranslationHe
WithUntil(),
mcp.WithString("query", mcp.Description("Free-text search across title, labels, and content (Doris full-text). A 24-char hex string is resolved as an incident ID; a 6-char string is resolved as an incident num. Prefer this over picking exact filter values when the user gives a fuzzy keyword."), mcp.MaxLength(200)),
mcp.WithNumber("limit", mcp.Description(LimitDescription), mcp.DefaultNumber(20), mcp.Min(1), mcp.Max(100)),
mcp.WithBoolean("include_alerts", mcp.Description("Whether to include alerts preview (first 20 alerts with total count)."), mcp.DefaultBool(true)),
), func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
ctx, client, err := getClient(ctx)
if err != nil {
Expand All @@ -53,28 +52,29 @@ func QueryIncidents(getClient GetFlashdutyClientFn, t translations.TranslationHe
if err != nil {
return mcp.NewToolResultError(fmt.Sprintf("invalid since: %v", err)), nil
}
endTime, err := timeutil.ParseAny(args["until"])
endTime, err := parseUntilArg(args["until"])
if err != nil {
return mcp.NewToolResultError(fmt.Sprintf("invalid until: %v", err)), nil
}

includeAlerts := true
if v, ok := args["include_alerts"].(bool); ok {
includeAlerts = v
}

if limit <= 0 {
limit = defaultQueryLimit
}

// IncludeAlerts is intentionally not exposed: per-incident alert
// payloads multiply across rows and routinely dominate the context
// window. Callers that want alert details for specific incidents
// should call query_incident_alerts(incident_ids=...) instead, which
// accepts a comma-separated list and keeps the two concerns cleanly
// separated. The alerts_total count on each incident is enough to
// gauge volume from this tool.
input := &sdk.ListIncidentsInput{
Progress: progress,
Severity: severity,
StartTime: startTime,
EndTime: endTime,
Query: query,
Limit: limit,
IncludeAlerts: includeAlerts,
Progress: progress,
Severity: severity,
StartTime: startTime,
EndTime: endTime,
Query: query,
Limit: limit,
}

if channelIdsStr != "" {
Expand Down Expand Up @@ -267,7 +267,7 @@ func CreateIncident(getClient GetFlashdutyClientFn, t translations.TranslationHe
}
}

const updateIncidentDescription = `Update incident title, description, severity, or custom fields. Only provided fields are updated.`
const updateIncidentDescription = `Update incident built-in fields (title, description, severity, impact, root_cause, resolution) and/or custom fields. Only provided fields are updated. Built-in fields are sent in a single round-trip.`

// UpdateIncident creates a tool to update an incident
func UpdateIncident(getClient GetFlashdutyClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) {
Expand All @@ -279,8 +279,11 @@ func UpdateIncident(getClient GetFlashdutyClientFn, t translations.TranslationHe
}),
mcp.WithString("incident_id", mcp.Required(), mcp.Description("The incident ID to update.")),
mcp.WithString("title", mcp.Description("New incident title. Length: 3-200 characters."), mcp.MinLength(3), mcp.MaxLength(200)),
mcp.WithString("description", mcp.Description("New incident description. Max 6144 characters."), mcp.MaxLength(6144)),
mcp.WithString("description", mcp.Description("New incident description. Length: 3-6144 characters."), mcp.MinLength(3), mcp.MaxLength(6144)),
mcp.WithString("severity", mcp.Description("New severity level."), mcp.Enum("Info", "Warning", "Critical")),
mcp.WithString("impact", mcp.Description("Business/user impact statement. Length: 3-6144 characters."), mcp.MinLength(3), mcp.MaxLength(6144)),
mcp.WithString("root_cause", mcp.Description("Root cause of the incident. Length: 3-6144 characters."), mcp.MinLength(3), mcp.MaxLength(6144)),
mcp.WithString("resolution", mcp.Description("How the incident was resolved. Length: 3-6144 characters."), mcp.MinLength(3), mcp.MaxLength(6144)),
mcp.WithString("custom_fields", mcp.Description("JSON object of custom field updates. Format: {\"field_name\": \"value\"}. Field names must match ^[a-z][a-z0-9_]*$. Use query_fields to discover available fields.")),
), func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
ctx, client, err := getClient(ctx)
Expand All @@ -296,13 +299,19 @@ func UpdateIncident(getClient GetFlashdutyClientFn, t translations.TranslationHe
title, _ := OptionalParam[string](request, "title")
description, _ := OptionalParam[string](request, "description")
severity, _ := OptionalParam[string](request, "severity")
impact, _ := OptionalParam[string](request, "impact")
rootCause, _ := OptionalParam[string](request, "root_cause")
resolution, _ := OptionalParam[string](request, "resolution")
customFieldsStr, _ := OptionalParam[string](request, "custom_fields")

input := &sdk.UpdateIncidentInput{
IncidentID: incidentID,
Title: title,
Description: description,
Severity: severity,
Impact: impact,
RootCause: rootCause,
Resolution: resolution,
}

// Parse custom fields JSON if provided
Expand Down
18 changes: 18 additions & 0 deletions pkg/flashduty/time_args.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import (
"time"

"github.com/mark3labs/mcp-go/mcp"

"github.com/flashcatcloud/flashduty-mcp-server/internal/timeutil"
)

// MaxTimeWindow is the backend's hard cap on (until-since) for incident/alert/change
Expand Down Expand Up @@ -41,6 +43,22 @@ func WithUntil(opts ...mcp.PropertyOption) mcp.ToolOption {
return mcp.WithString("until", append([]mcp.PropertyOption{mcp.Description(UntilDescription)}, opts...)...)
}

// parseUntilArg parses an "until" argument, defaulting to "now" when missing
// or empty. UntilDescription advertises this default, but timeutil.ParseAny
// on nil returns 0 (its sentinel for "not provided"), so callers must resolve
// the default explicitly here — otherwise validateTimeWindow rejects the call
// with "both since and until are required", a common LLM failure mode where
// the model supplies only `since` and assumes the documented default kicks in.
func parseUntilArg(v any) (int64, error) {
if v == nil {
return time.Now().Unix(), nil
}
if s, ok := v.(string); ok && s == "" {
return time.Now().Unix(), nil
}
return timeutil.ParseAny(v)
}

// validateTimeWindow enforces the same constraints the backend would, but with
// LLM-actionable error messages. since and until are unix seconds; both must be
// non-zero. Returns nil when the window is valid.
Expand Down
Loading