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
28 changes: 18 additions & 10 deletions pkg/flashduty/changes.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ func QueryChanges(getClient GetFlashdutyClientFn, t translations.TranslationHelp
WithUntil(),
mcp.WithString("type", mcp.Description("Filter by change type.")),
mcp.WithNumber("limit", mcp.Description(LimitDescription), mcp.DefaultNumber(20), mcp.Min(1), mcp.Max(100)),
mcp.WithNumber("page", mcp.Description(PageDescription), mcp.DefaultNumber(1), mcp.Min(1)),
), func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
ctx, client, err := getClient(ctx)
if err != nil {
Expand All @@ -39,11 +40,7 @@ func QueryChanges(getClient GetFlashdutyClientFn, t translations.TranslationHelp
changeIdsStr, _ := OptionalParam[string](request, "change_ids")
channelIdsStr, _ := OptionalParam[string](request, "channel_ids")
changeType, _ := OptionalParam[string](request, "type")
limit, _ := OptionalInt(request, "limit")

if limit <= 0 {
limit = 20
}
limit, page := optionalPaging(request, defaultQueryLimit)

startTime, err := timeutil.ParseAny(args["since"])
if err != nil {
Expand Down Expand Up @@ -71,6 +68,9 @@ func QueryChanges(getClient GetFlashdutyClientFn, t translations.TranslationHelp
Query: changeType,
}
req.Limit = limit
if page > 1 {
req.Page = page
}

if channelIdsStr != "" {
channelIDs := parseCommaSeparatedInts(channelIdsStr)
Expand All @@ -89,8 +89,13 @@ func QueryChanges(getClient GetFlashdutyClientFn, t translations.TranslationHelp
}

changes := resp.Items
// /change/list has no change_ids filter; honor the direct-lookup
// param by filtering the returned page client-side.
// /change/list has no server-side change_ids filter; honor the
// direct-lookup param by filtering the fetched page client-side.
// This is a best-effort single-page lookup — paging doesn't map onto
// it, so report the found count as the total and skip the page hint.
// (Feeding the filtered count with the window's unfiltered total into
// addPageHint would mislead: "Showing 0 of 5000, request page:2",
// inviting the agent to page the whole window chasing a few IDs.)
if changeIdsStr != "" {
wanted := make(map[string]struct{})
for _, id := range parseCommaSeparatedStrings(changeIdsStr) {
Expand All @@ -102,12 +107,15 @@ func QueryChanges(getClient GetFlashdutyClientFn, t translations.TranslationHelp
filtered = append(filtered, ch)
}
}
changes = filtered
return MarshalResult(map[string]any{
"changes": filtered,
"total": len(filtered),
}), nil
}

return MarshalResult(addTruncationHint(map[string]any{
return MarshalResult(addPageHint(map[string]any{
"changes": changes,
"total": resp.Total,
}, len(changes), int(resp.Total))), nil
}, len(changes), int(resp.Total), page, limit)), nil
}
}
14 changes: 12 additions & 2 deletions pkg/flashduty/channels.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ func QueryChannels(getClient GetFlashdutyClientFn, t translations.TranslationHel
}),
mcp.WithString("channel_ids", mcp.Description("Comma-separated channel IDs for direct lookup. Max 1000 IDs.")),
mcp.WithString("name", mcp.Description("Search by channel name (case-insensitive substring match).")),
mcp.WithNumber("limit", mcp.Description(LimitDescription), mcp.DefaultNumber(20), mcp.Min(1), mcp.Max(100)),
mcp.WithNumber("page", mcp.Description(PageDescription), mcp.DefaultNumber(1), mcp.Min(1)),
), func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
ctx, client, err := getClient(ctx)
if err != nil {
Expand All @@ -31,12 +33,20 @@ func QueryChannels(getClient GetFlashdutyClientFn, t translations.TranslationHel

channelIdsStr, _ := OptionalParam[string](request, "channel_ids")
name, _ := OptionalParam[string](request, "name")
limit, page := optionalPaging(request, defaultQueryLimit)

// Map name to the free-text Query (substring match against
// name/description) rather than ChannelName, which is exact-match.
// channel_ids is a filter on the same paginated /channel/list
// endpoint (not a dedicated by-ID lookup), so limit/page apply to it
// too — a >limit channel_ids lookup pages like any other listing.
req := &flashduty.ListChannelsRequest{
Query: name,
}
req.Limit = limit
if page > 1 {
req.Page = page
}

// Parse channel IDs if provided
if channelIdsStr != "" {
Expand All @@ -58,10 +68,10 @@ func QueryChannels(getClient GetFlashdutyClientFn, t translations.TranslationHel
}

total := int(out.Total)
return MarshalResult(addTruncationHint(map[string]any{
return MarshalResult(addPageHint(map[string]any{
"channels": out.Items,
"total": total,
}, len(out.Items), total)), nil
}, len(out.Items), total, page, limit)), nil
}
}

Expand Down
37 changes: 21 additions & 16 deletions pkg/flashduty/incidents.go
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ func QueryIncidents(getClient GetFlashdutyClientFn, t translations.TranslationHe
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.WithString("nums", mcp.Description("Comma-separated short incident ids (num — the 6-char id shown in the UI, e.g. 311510). Matched within the since/until window; the backend caps the list span at ~30 days, so incidents older than that must be looked up by their full incident_id.")),
mcp.WithNumber("limit", mcp.Description(LimitDescription), mcp.DefaultNumber(20), mcp.Min(1), mcp.Max(100)),
mcp.WithNumber("page", mcp.Description(PageDescription), mcp.DefaultNumber(1), mcp.Min(1)),
), func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
ctx, client, err := getClient(ctx)
if err != nil {
Expand All @@ -57,7 +58,7 @@ func QueryIncidents(getClient GetFlashdutyClientFn, t translations.TranslationHe
channelIdsStr, _ := OptionalParam[string](request, "channel_ids")
query, _ := OptionalParam[string](request, "query")
nums, _ := OptionalParam[string](request, "nums")
limit, _ := OptionalInt(request, "limit")
limit, page := optionalPaging(request, defaultQueryLimit)

startTime, err := timeutil.ParseAny(args["since"])
if err != nil {
Expand Down Expand Up @@ -85,10 +86,6 @@ func QueryIncidents(getClient GetFlashdutyClientFn, t translations.TranslationHe
startTime = endTime - int64(DefaultIncidentWindow/time.Second)
}

if limit <= 0 {
limit = defaultQueryLimit
}

// Direct ID lookup uses /incident/list-by-ids (ListByIDs), which does
// not require a time window. Per the tool contract, when incident_ids
// is provided every other filter is ignored.
Expand Down Expand Up @@ -125,6 +122,11 @@ func QueryIncidents(getClient GetFlashdutyClientFn, t translations.TranslationHe
Query: query,
}
req.Limit = limit
// page 1 is the default; only send `p` when advancing so the
// wire payload stays unchanged for callers that don't paginate.
if page > 1 {
req.Page = page
}

if channelIdsStr != "" {
channelIDs := parseCommaSeparatedInts(channelIdsStr)
Expand All @@ -151,10 +153,10 @@ func QueryIncidents(getClient GetFlashdutyClientFn, t translations.TranslationHe
}

total := int(out.Total)
return MarshalResult(addTruncationHint(map[string]any{
return MarshalResult(addPageHint(map[string]any{
"incidents": out.Items,
"total": total,
}, len(out.Items), total)), nil
}, len(out.Items), total, page, limit)), nil
}
}

Expand Down Expand Up @@ -220,7 +222,8 @@ func QueryIncidentAlerts(getClient GetFlashdutyClientFn, t translations.Translat
ReadOnlyHint: ToBoolPtr(true),
}),
mcp.WithString("incident_ids", mcp.Required(), mcp.Description("Comma-separated incident IDs to query alerts for.")),
mcp.WithNumber("limit", mcp.Description("Maximum alerts per incident."), mcp.DefaultNumber(20), mcp.Min(1), mcp.Max(100)),
mcp.WithNumber("limit", mcp.Description("Maximum alerts per incident per page. Default 20, max 100. When an incident has more alerts than returned, its entry carries `truncated:true` and a `hint`."), mcp.DefaultNumber(20), mcp.Min(1), mcp.Max(100)),
mcp.WithNumber("page", mcp.Description("1-based page number, applied to every requested incident. Default 1. When an incident's entry is `truncated`, request `page:2` (then 3, …) to fetch its remaining alerts."), mcp.DefaultNumber(1), mcp.Min(1)),
), func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
ctx, client, err := getClient(ctx)
if err != nil {
Expand All @@ -237,26 +240,28 @@ func QueryIncidentAlerts(getClient GetFlashdutyClientFn, t translations.Translat
return mcp.NewToolResultError("incident_ids must contain at least one valid ID"), nil
}

limit, _ := OptionalInt(request, "limit")
if limit <= 0 {
limit = defaultQueryLimit
}
limit, page := optionalPaging(request, defaultQueryLimit)

// go-flashduty's Incidents.AlertList returns one incident's alerts
// per call, so fan out across the requested IDs.
// per call, so fan out across the requested IDs. The page applies
// uniformly to each incident's alert list.
response := make([]map[string]any, 0, len(incidentIDs))
for _, id := range incidentIDs {
alertReq := &flashduty.ListIncidentAlertsRequest{IncidentID: id}
alertReq.Limit = limit
if page > 1 {
alertReq.Page = page
}
out, _, err := client.New.Incidents.AlertList(ctx, alertReq)
if err != nil {
return mcp.NewToolResultError(fmt.Sprintf("Unable to retrieve alerts for %s: %v", id, err)), nil
}
response = append(response, map[string]any{
total := int(out.Total)
response = append(response, addPageHint(map[string]any{
"incident_id": id,
"alerts": out.Items,
"total": int(out.Total),
})
"total": total,
}, len(out.Items), total, page, limit))
}

return MarshalResult(map[string]any{
Expand Down
66 changes: 59 additions & 7 deletions pkg/flashduty/pagination.go
Original file line number Diff line number Diff line change
@@ -1,30 +1,82 @@
package flashduty

import "fmt"
import (
"fmt"

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

// 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. " +
const LimitDescription = "Maximum number of results per page. Default 20, max 100. " +
"When more results exist than were returned, the response carries " +
"`truncated:true` and a `hint` field with concrete next steps."

// PageDescription is the canonical description for the `page` parameter on
// list-shaped tools. Pairs with `limit`: page N returns results
// [(N-1)*limit, N*limit). Advertised alongside the `truncated`/`hint` fields so
// the LLM knows how to reach results beyond the first page.
const PageDescription = "1-based page number for paging through results beyond `limit`. " +
"Default 1. When the response is `truncated`, request `page:2` (then 3, …) to fetch the rest."

// optionalPaging reads the shared `limit` and `page` parameters that every
// paginated list tool exposes, applying defLimit when limit is absent/invalid.
// Centralizing this keeps the six list handlers' paging contract identical.
func optionalPaging(r mcp.CallToolRequest, defLimit int) (limit, page int) {
limit, _ = OptionalInt(r, "limit")
if limit <= 0 {
limit = defLimit
}
page, _ = OptionalInt(r, "page")
return limit, page
}

// 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.
// total. Use it for full-set / by-ID responses that return everything matching
// in one call (no page parameter). For paginated tools use addPageHint, which
// accounts for the page offset. 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.).",
"Returned %d of %d total. To see more, raise `limit` (max 100) or narrow filters (time window, severity, channel_ids, etc.).",
count, total,
)
}
return res
}

// addPageHint is the paginated counterpart to addTruncationHint. It flags
// `truncated` and names the next page only when the caller has NOT yet paged
// through everything: `count` is the current page's item count and page/limit
// describe the page just fetched (page is 1-based). This distinction matters —
// comparing `total > count` alone would spuriously flag the final partial page
// as truncated and send the agent chasing an empty next page.
//
// No-op on the last page (and on non-paginated callers where seen >= total), so
// happy-path output stays clean. Returns the same map for one-line use.
func addPageHint(res map[string]any, count, total, page, limit int) map[string]any {
if page < 1 {
page = 1
}
if limit < 1 {
limit = count
}
seen := (page-1)*limit + count
if total > seen {
res["truncated"] = true
res["hint"] = fmt.Sprintf(
"Showing %d of %d so far (through page %d). Request `page:%d` for the next page, or raise `limit` (max 100) for bigger pages. Narrowing filters also shrinks the result set.",
seen, total, page, page+1,
)
}
return res
}
Loading
Loading