diff --git a/pkg/flashduty/alerts.go b/pkg/flashduty/alerts.go index e38aa0b..6f5a924 100644 --- a/pkg/flashduty/alerts.go +++ b/pkg/flashduty/alerts.go @@ -33,7 +33,7 @@ func QueryAlerts(getClient GetFlashdutyClientFn, t translations.TranslationHelpe mcp.WithBoolean("ever_muted", mcp.Description("If true, only return alerts that were ever muted by a routing rule.")), mcp.WithString("title", mcp.Description("Keyword search in alert title.")), mcp.WithString("labels", mcp.Description("JSON object of label key-value pairs to match. Format: {\"resource\":\"web-01\",\"region\":\"us-west\"}.")), - mcp.WithNumber("limit", mcp.Description("Maximum number of results to return."), mcp.DefaultNumber(20), mcp.Min(1), mcp.Max(100)), + mcp.WithNumber("limit", mcp.Description(LimitDescription), mcp.DefaultNumber(20), mcp.Min(1), mcp.Max(100)), ), func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { ctx, client, err := getClient(ctx) if err != nil { @@ -118,12 +118,12 @@ func QueryAlerts(getClient GetFlashdutyClientFn, t translations.TranslationHelpe return mcp.NewToolResultError(fmt.Sprintf("Unable to retrieve alerts: %v", err)), nil } - return MarshalResult(map[string]any{ + return MarshalResult(addTruncationHint(map[string]any{ "alerts": output.Alerts, "total": output.Total, "has_next_page": output.HasNextPage, "search_after_ctx": output.SearchAfterCtx, - }), nil + }, len(output.Alerts), output.Total)), nil } } diff --git a/pkg/flashduty/changes.go b/pkg/flashduty/changes.go index b70e786..941b23c 100644 --- a/pkg/flashduty/changes.go +++ b/pkg/flashduty/changes.go @@ -28,7 +28,7 @@ func QueryChanges(getClient GetFlashdutyClientFn, t translations.TranslationHelp WithSince(), WithUntil(), mcp.WithString("type", mcp.Description("Filter by change type.")), - mcp.WithNumber("limit", mcp.Description("Maximum number of results to return."), mcp.DefaultNumber(20), mcp.Min(1), mcp.Max(100)), + mcp.WithNumber("limit", mcp.Description(LimitDescription), mcp.DefaultNumber(20), mcp.Min(1), mcp.Max(100)), ), func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { ctx, client, err := getClient(ctx) if err != nil { @@ -92,9 +92,9 @@ func QueryChanges(getClient GetFlashdutyClientFn, t translations.TranslationHelp return mcp.NewToolResultError(fmt.Sprintf("Unable to retrieve changes: %v", err)), nil } - return MarshalResult(map[string]any{ + return MarshalResult(addTruncationHint(map[string]any{ "changes": output.Changes, "total": output.Total, - }), nil + }, len(output.Changes), output.Total)), nil } } diff --git a/pkg/flashduty/channels.go b/pkg/flashduty/channels.go index ac2f616..5076b63 100644 --- a/pkg/flashduty/channels.go +++ b/pkg/flashduty/channels.go @@ -55,10 +55,10 @@ func QueryChannels(getClient GetFlashdutyClientFn, t translations.TranslationHel return mcp.NewToolResultError(fmt.Sprintf("Unable to retrieve channels: %v", err)), nil } - return MarshalResult(map[string]any{ + return MarshalResult(addTruncationHint(map[string]any{ "channels": output.Channels, "total": output.Total, - }), nil + }, len(output.Channels), output.Total)), nil } } @@ -89,9 +89,9 @@ func QueryEscalationRules(getClient GetFlashdutyClientFn, t translations.Transla return mcp.NewToolResultError(fmt.Sprintf("Unable to query escalation rules: %v", err)), nil } - return MarshalResult(map[string]any{ + return MarshalResult(addTruncationHint(map[string]any{ "rules": output.Rules, "total": output.Total, - }), nil + }, len(output.Rules), output.Total)), nil } } diff --git a/pkg/flashduty/fields.go b/pkg/flashduty/fields.go index 857d75a..79a0fd8 100644 --- a/pkg/flashduty/fields.go +++ b/pkg/flashduty/fields.go @@ -45,9 +45,9 @@ func QueryFields(getClient GetFlashdutyClientFn, t translations.TranslationHelpe return mcp.NewToolResultError(fmt.Sprintf("Unable to retrieve fields: %v", err)), nil } - return MarshalResult(map[string]any{ + return MarshalResult(addTruncationHint(map[string]any{ "fields": output.Fields, "total": output.Total, - }), nil + }, len(output.Fields), output.Total)), nil } } diff --git a/pkg/flashduty/incidents.go b/pkg/flashduty/incidents.go index f4f564d..8b447de 100644 --- a/pkg/flashduty/incidents.go +++ b/pkg/flashduty/incidents.go @@ -33,7 +33,7 @@ func QueryIncidents(getClient GetFlashdutyClientFn, t translations.TranslationHe WithSince(), WithUntil(), mcp.WithString("title", mcp.Description("Keyword search in incident title.")), - mcp.WithNumber("limit", mcp.Description("Maximum number of results to return."), mcp.DefaultNumber(20), mcp.Min(1), mcp.Max(100)), + 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) @@ -104,10 +104,10 @@ func QueryIncidents(getClient GetFlashdutyClientFn, t translations.TranslationHe return mcp.NewToolResultError(fmt.Sprintf("Unable to retrieve incidents: %v", err)), nil } - return MarshalResult(map[string]any{ + return MarshalResult(addTruncationHint(map[string]any{ "incidents": output.Incidents, "total": output.Total, - }), nil + }, len(output.Incidents), output.Total)), nil } } @@ -456,9 +456,9 @@ func ListSimilarIncidents(getClient GetFlashdutyClientFn, t translations.Transla return mcp.NewToolResultError(fmt.Sprintf("Unable to find similar incidents: %v", err)), nil } - return MarshalResult(map[string]any{ + return MarshalResult(addTruncationHint(map[string]any{ "incidents": output.Incidents, "total": output.Total, - }), nil + }, len(output.Incidents), output.Total)), nil } } diff --git a/pkg/flashduty/pagination.go b/pkg/flashduty/pagination.go new file mode 100644 index 0000000..1d8e2a4 --- /dev/null +++ b/pkg/flashduty/pagination.go @@ -0,0 +1,30 @@ +package flashduty + +import "fmt" + +// LimitDescription is the canonical description for the `limit` parameter on +// list-shaped tools. Mentioning `truncated`/`total` up front primes the LLM to +// inspect them before assuming it has the full result. +const LimitDescription = "Maximum number of results to return. Default 20, max 100. " + + "When more results exist than were returned, the response carries " + + "`truncated:true` and a `hint` field with concrete next steps." + +// addTruncationHint stamps `truncated: true` and a human-readable `hint` onto +// list-shaped results when the returned slice is shorter than the backend's +// total. Without these explicit fields the LLM has to remember to compare +// `len(items) < total`, and skipping that check is the most common cause of +// "the LLM only looked at the first 20 incidents and missed the obvious one" +// reports. +// +// No-op when nothing was truncated, so happy-path output stays clean. +// Returns the same map for one-line use. +func addTruncationHint(res map[string]any, count, total int) map[string]any { + if total > count { + res["truncated"] = true + res["hint"] = fmt.Sprintf( + "Returned %d of %d total. To see more: raise `limit` (max 100), narrow `since`/`until`, or add filters (severity, channel_ids, etc.).", + count, total, + ) + } + return res +} diff --git a/pkg/flashduty/statuspage.go b/pkg/flashduty/statuspage.go index e33b1ba..d66aaca 100644 --- a/pkg/flashduty/statuspage.go +++ b/pkg/flashduty/statuspage.go @@ -90,10 +90,10 @@ func ListStatusChanges(getClient GetFlashdutyClientFn, t translations.Translatio return mcp.NewToolResultError(fmt.Sprintf("failed to list status changes: %v", err)), nil } - return MarshalResult(map[string]any{ + return MarshalResult(addTruncationHint(map[string]any{ "changes": output.Changes, "total": output.Total, - }), nil + }, len(output.Changes), output.Total)), nil } } diff --git a/pkg/flashduty/users.go b/pkg/flashduty/users.go index 7ec616e..54f70b0 100644 --- a/pkg/flashduty/users.go +++ b/pkg/flashduty/users.go @@ -56,15 +56,17 @@ func QueryMembers(getClient GetFlashdutyClientFn, t translations.TranslationHelp return mcp.NewToolResultError(fmt.Sprintf("Unable to retrieve members: %v", err)), nil } - members := any(output.Members) + var members any = output.Members + count := len(output.Members) if len(output.PersonInfos) > 0 { members = output.PersonInfos + count = len(output.PersonInfos) } - return MarshalResult(map[string]any{ + return MarshalResult(addTruncationHint(map[string]any{ "members": members, "total": output.Total, - }), nil + }, count, output.Total)), nil } } @@ -117,9 +119,9 @@ func QueryTeams(getClient GetFlashdutyClientFn, t translations.TranslationHelper }), nil } - return MarshalResult(map[string]any{ + return MarshalResult(addTruncationHint(map[string]any{ "teams": output.Teams, "total": output.Total, - }), nil + }, len(output.Teams), output.Total)), nil } }