From 8ead9d1835a1b5a49b56a0c75c2af631257e0fd3 Mon Sep 17 00:00:00 2001 From: ysyneu Date: Fri, 29 May 2026 00:03:25 +0800 Subject: [PATCH 1/5] feat: render API timestamps as RFC3339 in tool output Flashduty's API returns time fields as bare Unix integers, which are opaque to an LLM reading tool results. Humanize them to RFC3339 (in the local timezone) in MarshalResultWithFormat so every tool response is readable. Detection is by field name (*_time, *_at, timestamp), excluding ID-like fields; values below 1e9 stay numeric. Drop the unused, humanization-bypassing MarshalledTextResult. --- pkg/flashduty/format.go | 9 +-- pkg/flashduty/timestamps.go | 94 ++++++++++++++++++++++++ pkg/flashduty/timestamps_test.go | 122 +++++++++++++++++++++++++++++++ 3 files changed, 217 insertions(+), 8 deletions(-) create mode 100644 pkg/flashduty/timestamps.go create mode 100644 pkg/flashduty/timestamps_test.go diff --git a/pkg/flashduty/format.go b/pkg/flashduty/format.go index 4d5cb81..43213cd 100644 --- a/pkg/flashduty/format.go +++ b/pkg/flashduty/format.go @@ -41,16 +41,9 @@ func MarshalResult(v any) *mcp.CallToolResult { // MarshalResultWithFormat serializes the given value using the specified format func MarshalResultWithFormat(v any, format OutputFormat) *mcp.CallToolResult { - data, err := sdk.Marshal(v, format) + data, err := sdk.Marshal(humanizeTimestamps(v), format) if err != nil { return mcp.NewToolResultError(fmt.Sprintf("failed to marshal result: %v", err)) } return mcp.NewToolResultText(string(data)) } - -// MarshalledTextResult is the original function that always uses JSON. -// Kept for backward compatibility. New code should use MarshalResult. -func MarshalledTextResult(v any) *mcp.CallToolResult { - data, _ := sdk.Marshal(v, OutputFormatJSON) - return mcp.NewToolResultText(string(data)) -} diff --git a/pkg/flashduty/timestamps.go b/pkg/flashduty/timestamps.go new file mode 100644 index 0000000..41bd3aa --- /dev/null +++ b/pkg/flashduty/timestamps.go @@ -0,0 +1,94 @@ +package flashduty + +import ( + "bytes" + "encoding/json" + "strings" + "time" +) + +// humanizeTimestamps returns a copy of v with Unix-timestamp fields rendered as +// RFC3339 strings in the local timezone, leaving everything else untouched. +// +// Flashduty's API returns time fields as bare Unix integers, which is opaque to +// an LLM reading tool output. RFC3339 is unambiguous, sortable, and the format +// models are most fluent in. The local timezone is the process timezone (the +// sandbox/environment timezone when the server runs inside an agent sandbox). +// +// Detection is by JSON field name: a field ending in "_time" or "_at", or named +// exactly "timestamp", whose value is an integer large enough to be a real +// timestamp (>= 1e9 seconds, i.e. year 2001+). Millisecond values (>= 1e12) are +// detected by magnitude. ID-like fields (*_by, *_id, *_ids) are never touched. +// +// v is round-tripped through JSON into a generic structure so the same walk +// handles both typed SDK structs and the map[string]any payloads tools build by +// hand. On any marshal/decode error it returns v unchanged — humanization is +// best-effort and never blocks output. +func humanizeTimestamps(v any) any { + b, err := json.Marshal(v) + if err != nil { + return v + } + dec := json.NewDecoder(bytes.NewReader(b)) + dec.UseNumber() + var generic any + if err := dec.Decode(&generic); err != nil { + return v + } + return humanizeWalk(generic, "") +} + +func humanizeWalk(v any, key string) any { + switch val := v.(type) { + case map[string]any: + for k, child := range val { + val[k] = humanizeWalk(child, k) + } + return val + case []any: + for i, child := range val { + val[i] = humanizeWalk(child, key) + } + return val + case json.Number: + if isTimestampField(key) { + if s, ok := renderTimestamp(val); ok { + return s + } + } + return val + default: + return val + } +} + +// isTimestampField reports whether a JSON field name denotes an absolute time. +// ID-like suffixes are excluded first so e.g. "timeline_id" / "updated_by" +// never match. +func isTimestampField(key string) bool { + k := strings.ToLower(key) + if strings.HasSuffix(k, "_id") || strings.HasSuffix(k, "_ids") || strings.HasSuffix(k, "_by") { + return false + } + return k == "timestamp" || strings.HasSuffix(k, "_time") || strings.HasSuffix(k, "_at") +} + +// renderTimestamp converts a numeric Unix timestamp to RFC3339 in local time. +// Values below 1e9 are treated as durations/counts, not absolute timestamps, +// and left unconverted; values at/above 1e12 are interpreted as milliseconds. +func renderTimestamp(n json.Number) (string, bool) { + i, err := n.Int64() + if err != nil { + return "", false + } + var t time.Time + switch { + case i >= 1e12: + t = time.UnixMilli(i) + case i >= 1e9: + t = time.Unix(i, 0) + default: + return "", false + } + return t.In(time.Local).Format(time.RFC3339), true +} diff --git a/pkg/flashduty/timestamps_test.go b/pkg/flashduty/timestamps_test.go new file mode 100644 index 0000000..1ed7b38 --- /dev/null +++ b/pkg/flashduty/timestamps_test.go @@ -0,0 +1,122 @@ +package flashduty + +import ( + "strings" + "testing" + "time" + + "github.com/mark3labs/mcp-go/mcp" +) + +// TestMarshalResult_HumanizesTimestamps locks the wiring: every tool result +// routed through MarshalResultWithFormat must have its timestamps humanized, so +// a raw Unix integer never reaches the model. +func TestMarshalResult_HumanizesTimestamps(t *testing.T) { + const ts = 1748419200 + res := MarshalResultWithFormat(map[string]any{"start_time": ts}, OutputFormatJSON) + tc, ok := mcp.AsTextContent(res.Content[0]) + if !ok { + t.Fatalf("expected text content, got %#v", res.Content[0]) + } + if strings.Contains(tc.Text, "1748419200") { + t.Fatalf("raw unix timestamp leaked into tool result: %s", tc.Text) + } + if !strings.Contains(tc.Text, "start_time") { + t.Fatalf("expected start_time key in result: %s", tc.Text) + } +} + +func tsInstant(t *testing.T, v any) int64 { + t.Helper() + s, ok := v.(string) + if !ok { + t.Fatalf("expected RFC3339 string, got %T (%v)", v, v) + } + parsed, err := time.Parse(time.RFC3339, s) + if err != nil { + t.Fatalf("value %q is not RFC3339: %v", s, err) + } + return parsed.Unix() +} + +func TestHumanizeTimestamps_ConvertsSecondsAndMillis(t *testing.T) { + const sec = 1748419200 + m := humanizeTimestamps(map[string]any{ + "start_time": sec, + "created_at": int64(sec) * 1000, + }).(map[string]any) + if inst := tsInstant(t, m["start_time"]); inst != sec { + t.Fatalf("start_time instant = %d, want %d", inst, sec) + } + if inst := tsInstant(t, m["created_at"]); inst != sec { + t.Fatalf("created_at instant = %d, want %d", inst, sec) + } +} + +func TestHumanizeTimestamps_DetectsByFieldName(t *testing.T) { + const ts = 1748419200 + in := map[string]any{ + "ack_time": ts, "close_time": ts, "assigned_at": ts, + "acknowledged_at": ts, "timestamp": ts, "end_time": ts, "trigger_time": ts, + } + m := humanizeTimestamps(in).(map[string]any) + for k := range in { + if inst := tsInstant(t, m[k]); inst != ts { + t.Fatalf("%s instant = %d, want %d", k, inst, ts) + } + } +} + +func TestHumanizeTimestamps_LeavesIDAndDurationFields(t *testing.T) { + in := map[string]any{ + // Large values that WOULD convert by magnitude — proves the field-name + // exclusion (not just the magnitude guard) is what keeps IDs numeric. + "updated_by": int64(1748419200), + "timeline_id": int64(1748419200), + "channel_ids": []any{int64(1748419200)}, + "snooze_time": int64(300), // small => duration, not a 1970 date + "ack_time": 0, // zero => not a timestamp + } + m := humanizeTimestamps(in).(map[string]any) + for k := range in { + if _, isStr := m[k].(string); isStr { + t.Fatalf("%s must not be converted to a date string", k) + } + } +} + +func TestHumanizeTimestamps_RecursesNestedAndSlices(t *testing.T) { + const ts = 1748419200 + in := map[string]any{ + "incidents": []any{ + map[string]any{"start_time": ts, "labels": map[string]any{"close_time": ts}}, + }, + } + m := humanizeTimestamps(in).(map[string]any) + inc := m["incidents"].([]any)[0].(map[string]any) + if inst := tsInstant(t, inc["start_time"]); inst != ts { + t.Fatalf("nested start_time instant = %d, want %d", inst, ts) + } + if inst := tsInstant(t, inc["labels"].(map[string]any)["close_time"]); inst != ts { + t.Fatalf("deeply nested close_time instant = %d, want %d", inst, ts) + } +} + +func TestHumanizeTimestamps_ConvertsTypedStruct(t *testing.T) { + type incident struct { + Title string `json:"title"` + StartTime int64 `json:"start_time"` + UpdatedBy int64 `json:"updated_by"` + } + const ts = 1748419200 + m := humanizeTimestamps(incident{Title: "db down", StartTime: ts, UpdatedBy: 7}).(map[string]any) + if inst := tsInstant(t, m["start_time"]); inst != ts { + t.Fatalf("struct start_time instant = %d, want %d", inst, ts) + } + if _, isStr := m["updated_by"].(string); isStr { + t.Fatalf("struct updated_by must remain numeric") + } + if m["title"] != "db down" { + t.Fatalf("title = %v, want \"db down\"", m["title"]) + } +} From 4f3c61dca235ac2dd3892a60f8f0187c5d72475c Mon Sep 17 00:00:00 2001 From: ysyneu Date: Sat, 30 May 2026 17:39:53 +0800 Subject: [PATCH 2/5] feat: migrate covered tools to go-flashduty SDK Migrate the 20 tools whose endpoints go-flashduty v0.3.0 covers from the hand-written flashduty-sdk onto go-flashduty. A dual client (New = go-flashduty, Legacy = flashduty-sdk) keeps the 4 tools whose endpoints go-flashduty does not cover yet working, each marked TODO to switch once covered: query_changes (/change/list), query_status_pages (/status-page/list), list_status_changes (/status-page/change/active/list), validate_template (/template/preview). - internal/flashduty: dual-client construction (New + Legacy). - pkg/flashduty: covered tools call client.New..; batch ops use go-flashduty batch request fields (Ack/Resolve), per-incident loops for Feed/ AlertList; typed-envelope response unwrapping. - TOON output moved off sdk.Marshal onto github.com/toon-format/toon-go directly. - Dropped humanizeTimestamps() for covered tools (go-flashduty v0.3.0 emits RFC3339 via typed Timestamp); kept for Legacy-backed tools. Verified: go build + vet + go test green; in-process e2e (FLASHDUTY_E2E_DEBUG) 15/15 PASS against api-dev (incident lifecycle, queries, timeline, filters). --- e2e/e2e_test.go | 41 +++--- e2e/fixes_validation_test.go | 14 +- go.mod | 3 +- go.sum | 2 + internal/flashduty/context.go | 81 +++++++---- internal/flashduty/server.go | 8 +- pkg/flashduty/alerts.go | 6 +- pkg/flashduty/changes.go | 6 +- pkg/flashduty/channels.go | 31 +++-- pkg/flashduty/client.go | 20 ++- pkg/flashduty/fields.go | 37 +++-- pkg/flashduty/format.go | 62 +++++++-- pkg/flashduty/incidents.go | 228 +++++++++++++++++++++---------- pkg/flashduty/statuspage.go | 107 ++++++++++++--- pkg/flashduty/templates.go | 51 ++++++- pkg/flashduty/templates_test.go | 4 +- pkg/flashduty/timestamps_test.go | 12 +- pkg/flashduty/users.go | 94 +++++++------ pkg/flashduty/users_test.go | 10 +- 19 files changed, 559 insertions(+), 258 deletions(-) diff --git a/e2e/e2e_test.go b/e2e/e2e_test.go index 72a288b..8bebdc2 100644 --- a/e2e/e2e_test.go +++ b/e2e/e2e_test.go @@ -420,21 +420,24 @@ func TestQueryIncidents(t *testing.T) { t.Log("Querying incidents from the last 7 days...") responseText := callTool(t, mcpClient, "query_incidents", map[string]any{ - "since": strconv.FormatInt(startTime, 10), - "until": strconv.FormatInt(now, 10), - "limit": 10, + "since": strconv.FormatInt(startTime, 10), + "until": strconv.FormatInt(now, 10), + "limit": 10, }) var result struct { Incidents []struct { - IncidentID string `json:"incident_id"` - Title string `json:"title"` - Severity string `json:"severity"` - Progress string `json:"progress"` - ChannelID int64 `json:"channel_id"` - ChannelName string `json:"channel_name,omitempty"` - CreatedAt int64 `json:"created_at"` - AlertsTotal int `json:"alerts_total,omitempty"` + IncidentID string `json:"incident_id"` + Title string `json:"title"` + // go-flashduty renames severity -> incident_severity and renders + // created_at as an RFC3339 string (Timestamp type) instead of a + // Unix integer; alerts_total is now the server-side alert_cnt. + IncidentSeverity string `json:"incident_severity"` + Progress string `json:"progress"` + ChannelID int64 `json:"channel_id"` + ChannelName string `json:"channel_name,omitempty"` + CreatedAt string `json:"created_at"` + AlertCnt int `json:"alert_cnt,omitempty"` } `json:"incidents"` Total int `json:"total"` } @@ -603,15 +606,15 @@ func TestIncidentLifecycle(t *testing.T) { // Step 2: Query the incident to verify it was created t.Log("Querying the created incident...") queryResponseText := callTool(t, mcpClient, "query_incidents", map[string]any{ - "incident_ids": incidentID, + "incident_ids": incidentID, }) var queryResult struct { Incidents []struct { - IncidentID string `json:"incident_id"` - Title string `json:"title"` - Progress string `json:"progress"` - Severity string `json:"severity"` + IncidentID string `json:"incident_id"` + Title string `json:"title"` + Progress string `json:"progress"` + IncidentSeverity string `json:"incident_severity"` } `json:"incidents"` Total int `json:"total"` } @@ -640,7 +643,7 @@ func TestIncidentLifecycle(t *testing.T) { // Step 4: Verify the incident is now in Processing state t.Log("Verifying incident is in Processing state...") queryResponseText = callTool(t, mcpClient, "query_incidents", map[string]any{ - "incident_ids": incidentID, + "incident_ids": incidentID, }) unmarshalToolResponse(t, queryResponseText, &queryResult) @@ -665,7 +668,7 @@ func TestIncidentLifecycle(t *testing.T) { // Step 6: Verify the incident is now Closed t.Log("Verifying incident is Closed...") queryResponseText = callTool(t, mcpClient, "query_incidents", map[string]any{ - "incident_ids": incidentID, + "incident_ids": incidentID, }) unmarshalToolResponse(t, queryResponseText, &queryResult) @@ -820,7 +823,7 @@ func TestUpdateIncident(t *testing.T) { // Verify the update t.Log("Verifying the update...") queryResponseText := callTool(t, mcpClient, "query_incidents", map[string]any{ - "incident_ids": incidentID, + "incident_ids": incidentID, }) var queryResult struct { diff --git a/e2e/fixes_validation_test.go b/e2e/fixes_validation_test.go index a16a534..bcf666d 100644 --- a/e2e/fixes_validation_test.go +++ b/e2e/fixes_validation_test.go @@ -23,9 +23,9 @@ func TestQueryIncidentsChannelFilter(t *testing.T) { startTime := now - 30*24*60*60 allText := callTool(t, mcpClient, "query_incidents", map[string]any{ - "since": strconv.FormatInt(startTime, 10), - "until": strconv.FormatInt(now, 10), - "limit": 100, + "since": strconv.FormatInt(startTime, 10), + "until": strconv.FormatInt(now, 10), + "limit": 100, }) var allResp struct { Incidents []struct { @@ -58,10 +58,10 @@ func TestQueryIncidentsChannelFilter(t *testing.T) { target, maxCount, otherChannelCount) filteredText := callTool(t, mcpClient, "query_incidents", map[string]any{ - "since": strconv.FormatInt(startTime, 10), - "until": strconv.FormatInt(now, 10), - "limit": 100, - "channel_ids": strconv.FormatInt(target, 10), + "since": strconv.FormatInt(startTime, 10), + "until": strconv.FormatInt(now, 10), + "limit": 100, + "channel_ids": strconv.FormatInt(target, 10), }) var filtered struct { Incidents []struct { diff --git a/go.mod b/go.mod index 8d85804..89073c5 100644 --- a/go.mod +++ b/go.mod @@ -5,12 +5,14 @@ go 1.25.5 require ( github.com/bluele/gcache v0.0.2 github.com/flashcatcloud/flashduty-sdk v0.8.1-0.20260510070250-0340ac6a5a33 + github.com/flashcatcloud/go-flashduty v0.3.0 github.com/google/go-github/v72 v72.0.0 github.com/josephburnett/jd v1.9.2 github.com/mark3labs/mcp-go v0.52.0 github.com/spf13/cobra v1.10.2 github.com/spf13/viper v1.20.1 github.com/stretchr/testify v1.10.0 + github.com/toon-format/toon-go v0.0.0-20251202084852-7ca0e27c4e8c ) require ( @@ -22,7 +24,6 @@ require ( github.com/mailru/easyjson v0.7.7 // indirect github.com/rogpeppe/go-internal v1.13.1 // indirect github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 // indirect - github.com/toon-format/toon-go v0.0.0-20251202084852-7ca0e27c4e8c // indirect github.com/yosida95/uritemplate/v3 v3.0.2 // indirect github.com/yudai/golcs v0.0.0-20170316035057-ecda9a501e82 // indirect golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 // indirect diff --git a/go.sum b/go.sum index 1731372..06ee6f5 100644 --- a/go.sum +++ b/go.sum @@ -10,6 +10,8 @@ github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxK github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= github.com/flashcatcloud/flashduty-sdk v0.8.1-0.20260510070250-0340ac6a5a33 h1:HnB++VulEnF+oIsNwKK8QBv4CCdG+ztdFKLScI4bXlc= github.com/flashcatcloud/flashduty-sdk v0.8.1-0.20260510070250-0340ac6a5a33/go.mod h1:dG4eJfdZaj4jNBMwEexbfK/3PmcIMhNeJ88L/DcZzUY= +github.com/flashcatcloud/go-flashduty v0.3.0 h1:DlwkrK/MIkkWfqJoKwvq3fh/8A0A3OUEbAMDIRrkLkI= +github.com/flashcatcloud/go-flashduty v0.3.0/go.mod h1:aA0RtZEs0AYOwwdNKdtVeD8YMOdnmVY1zAlVD+9Ovx8= 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= diff --git a/internal/flashduty/context.go b/internal/flashduty/context.go index be37c73..97d1f30 100644 --- a/internal/flashduty/context.go +++ b/internal/flashduty/context.go @@ -7,8 +7,11 @@ import ( "time" "github.com/bluele/gcache" + goflashduty "github.com/flashcatcloud/go-flashduty" + sdk "github.com/flashcatcloud/flashduty-sdk" + "github.com/flashcatcloud/flashduty-mcp-server/pkg/flashduty" "github.com/flashcatcloud/flashduty-mcp-server/pkg/trace" ) @@ -30,28 +33,30 @@ func ConfigFromContext(ctx context.Context) (FlashdutyConfig, bool) { return cfg, ok } -// clientFromContext returns the Flashduty client from the context. -func clientFromContext(ctx context.Context) (*sdk.Client, bool) { - client, ok := ctx.Value(flashdutyClientKey).(*sdk.Client) - return client, ok +// clientsFromContext returns the Flashduty clients from the context. +func clientsFromContext(ctx context.Context) (*flashduty.Clients, bool) { + clients, ok := ctx.Value(flashdutyClientKey).(*flashduty.Clients) + return clients, ok } -// contextWithClient adds the Flashduty client to the context. -func contextWithClient(ctx context.Context, client *sdk.Client) context.Context { - return context.WithValue(ctx, flashdutyClientKey, client) +// contextWithClients adds the Flashduty clients to the context. +func contextWithClients(ctx context.Context, clients *flashduty.Clients) context.Context { + return context.WithValue(ctx, flashdutyClientKey, clients) } var clientCache = gcache.New(1000). Expiration(time.Hour). Build() -// getClient is a helper function for tool handlers to get a flashduty client. -// It will try to get the client from the context first. If not found, it will create a new one -// based on the config in the context, and cache it in the context for future use in the same request. -// It falls back to the default config if no config is found in the context. -func getClient(ctx context.Context, defaultCfg FlashdutyConfig, version string) (context.Context, *sdk.Client, error) { - if client, ok := clientFromContext(ctx); ok { - return ctx, client, nil +// getClient is a helper for tool handlers to obtain the Flashduty clients. It +// tries the context first; on a miss it builds both the typed go-flashduty +// client (used by every migrated tool) and the legacy flashduty-sdk client +// (kept only for the not-yet-covered endpoints), caches the pair, and stores it +// on the context for reuse within the same request. It falls back to the +// default config when the context carries none. +func getClient(ctx context.Context, defaultCfg FlashdutyConfig, version string) (context.Context, *flashduty.Clients, error) { + if clients, ok := clientsFromContext(ctx); ok { + return ctx, clients, nil } cfg, ok := ConfigFromContext(ctx) @@ -65,31 +70,49 @@ func getClient(ctx context.Context, defaultCfg FlashdutyConfig, version string) // Use APP key and BaseURL as cache key to handle different environments. cacheKey := fmt.Sprintf("%s|%s", cfg.APPKey, cfg.BaseURL) - if client, err := clientCache.Get(cacheKey); err == nil { - return contextWithClient(ctx, client.(*sdk.Client)), client.(*sdk.Client), nil + if cached, err := clientCache.Get(cacheKey); err == nil { + clients := cached.(*flashduty.Clients) + return contextWithClients(ctx, clients), clients, nil } userAgent := fmt.Sprintf("flashduty-mcp-server/%s", version) - opts := []sdk.Option{ - sdk.WithUserAgent(userAgent), - sdk.WithRequestHook(func(req *http.Request) { - if traceCtx := trace.FromContext(req.Context()); traceCtx != nil { - traceCtx.SetHTTPHeaders(req.Header) - } - }), + requestHook := func(req *http.Request) { + if traceCtx := trace.FromContext(req.Context()); traceCtx != nil { + traceCtx.SetHTTPHeaders(req.Header) + } + } + + // Primary client: typed go-flashduty. + newOpts := []goflashduty.Option{ + goflashduty.WithUserAgent(userAgent), + goflashduty.WithRequestHook(requestHook), } if cfg.BaseURL != "" { - opts = append(opts, sdk.WithBaseURL(cfg.BaseURL)) + newOpts = append(newOpts, goflashduty.WithBaseURL(cfg.BaseURL)) + } + newClient, err := goflashduty.NewClient(cfg.APPKey, newOpts...) + if err != nil { + return ctx, nil, fmt.Errorf("failed to create go-flashduty client: %w", err) } - client, err := sdk.NewClient(cfg.APPKey, opts...) + // Legacy client: only the not-yet-covered endpoints use it. + legacyOpts := []sdk.Option{ + sdk.WithUserAgent(userAgent), + sdk.WithRequestHook(requestHook), + } + if cfg.BaseURL != "" { + legacyOpts = append(legacyOpts, sdk.WithBaseURL(cfg.BaseURL)) + } + legacyClient, err := sdk.NewClient(cfg.APPKey, legacyOpts...) if err != nil { - return ctx, nil, fmt.Errorf("failed to create Flashduty client: %w", err) + return ctx, nil, fmt.Errorf("failed to create legacy flashduty client: %w", err) } - _ = clientCache.Set(cacheKey, client) - ctx = contextWithClient(ctx, client) + clients := &flashduty.Clients{New: newClient, Legacy: legacyClient} + + _ = clientCache.Set(cacheKey, clients) + ctx = contextWithClients(ctx, clients) - return ctx, client, nil + return ctx, clients, nil } diff --git a/internal/flashduty/server.go b/internal/flashduty/server.go index a5a22a6..615bad7 100644 --- a/internal/flashduty/server.go +++ b/internal/flashduty/server.go @@ -14,7 +14,6 @@ import ( "syscall" "time" - sdk "github.com/flashcatcloud/flashduty-sdk" "github.com/mark3labs/mcp-go/mcp" "github.com/mark3labs/mcp-go/server" @@ -61,7 +60,7 @@ type FlashdutyConfig struct { func NewMCPServer(cfg FlashdutyConfig) (*server.MCPServer, error) { // When a client send an initialize request, update the user agent to include the client info. beforeInit := func(ctx context.Context, _ any, message *mcp.InitializeRequest) { - _, client, err := getClient(ctx, cfg, cfg.Version) + _, clients, err := getClient(ctx, cfg, cfg.Version) if err != nil { // Cannot return error here, just log it. // For HTTP server, the APP key is per-request, so it might not be available @@ -78,7 +77,8 @@ func NewMCPServer(cfg FlashdutyConfig) (*server.MCPServer, error) { message.Params.ClientInfo.Name, message.Params.ClientInfo.Version, ) - client.SetUserAgent(userAgent) + clients.New.UserAgent = userAgent + clients.Legacy.SetUserAgent(userAgent) } if len(cfg.EnabledToolsets) == 0 { @@ -129,7 +129,7 @@ func NewMCPServer(cfg FlashdutyConfig) (*server.MCPServer, error) { flashdutyServer := server.NewMCPServer("flashduty-mcp-server", cfg.Version, server.WithHooks(hooks)) - getClientFn := func(ctx context.Context) (context.Context, *sdk.Client, error) { + getClientFn := func(ctx context.Context) (context.Context, *flashduty.Clients, error) { return getClient(ctx, cfg, cfg.Version) } diff --git a/pkg/flashduty/alerts.go b/pkg/flashduty/alerts.go index b0b5408..13f295f 100644 --- a/pkg/flashduty/alerts.go +++ b/pkg/flashduty/alerts.go @@ -4,7 +4,7 @@ import ( "context" "fmt" - sdk "github.com/flashcatcloud/flashduty-sdk" + flashduty "github.com/flashcatcloud/go-flashduty" "github.com/mark3labs/mcp-go/mcp" "github.com/mark3labs/mcp-go/server" @@ -33,13 +33,13 @@ func QueryAlertEvents(getClient GetFlashdutyClientFn, t translations.Translation return mcp.NewToolResultError(err.Error()), nil } - output, err := client.ListAlertEvents(ctx, &sdk.ListAlertEventsInput{AlertID: alertID}) + out, _, err := client.New.Alerts.ReadEventList(ctx, &flashduty.AlertEventListRequest{AlertID: alertID}) if err != nil { return mcp.NewToolResultError(fmt.Sprintf("Unable to retrieve alert events: %v", err)), nil } return MarshalResult(map[string]any{ - "alert_events": output.AlertEvents, + "alert_events": out.Items, }), nil } } diff --git a/pkg/flashduty/changes.go b/pkg/flashduty/changes.go index 941b23c..5c8342f 100644 --- a/pkg/flashduty/changes.go +++ b/pkg/flashduty/changes.go @@ -87,12 +87,14 @@ func QueryChanges(getClient GetFlashdutyClientFn, t translations.TranslationHelp } } - output, err := client.ListChanges(ctx, input) + // TODO: 待 go-flashduty 覆盖 /change/list,/template/preview,/status-page/list + // 后切换并删除老 SDK 依赖。/change/list is not yet in go-flashduty. + output, err := client.Legacy.ListChanges(ctx, input) if err != nil { return mcp.NewToolResultError(fmt.Sprintf("Unable to retrieve changes: %v", err)), nil } - return MarshalResult(addTruncationHint(map[string]any{ + return MarshalLegacyResult(addTruncationHint(map[string]any{ "changes": output.Changes, "total": output.Total, }, len(output.Changes), output.Total)), nil diff --git a/pkg/flashduty/channels.go b/pkg/flashduty/channels.go index 5076b63..4a87b0a 100644 --- a/pkg/flashduty/channels.go +++ b/pkg/flashduty/channels.go @@ -4,7 +4,7 @@ import ( "context" "fmt" - sdk "github.com/flashcatcloud/flashduty-sdk" + flashduty "github.com/flashcatcloud/go-flashduty" "github.com/mark3labs/mcp-go/mcp" "github.com/mark3labs/mcp-go/server" @@ -32,8 +32,10 @@ func QueryChannels(getClient GetFlashdutyClientFn, t translations.TranslationHel channelIdsStr, _ := OptionalParam[string](request, "channel_ids") name, _ := OptionalParam[string](request, "name") - input := &sdk.ListChannelsInput{ - Name: name, + // Map name to the free-text Query (substring match against + // name/description) rather than ChannelName, which is exact-match. + req := &flashduty.ListChannelsRequest{ + Query: name, } // Parse channel IDs if provided @@ -47,18 +49,19 @@ func QueryChannels(getClient GetFlashdutyClientFn, t translations.TranslationHel for i, id := range channelIDs { int64IDs[i] = int64(id) } - input.ChannelIDs = int64IDs + req.ChannelIDs = int64IDs } - output, err := client.ListChannels(ctx, input) + out, _, err := client.New.Channels.ChannelList(ctx, req) if err != nil { return mcp.NewToolResultError(fmt.Sprintf("Unable to retrieve channels: %v", err)), nil } + total := int(out.Total) return MarshalResult(addTruncationHint(map[string]any{ - "channels": output.Channels, - "total": output.Total, - }, len(output.Channels), output.Total)), nil + "channels": out.Items, + "total": total, + }, len(out.Items), total)), nil } } @@ -84,14 +87,18 @@ func QueryEscalationRules(getClient GetFlashdutyClientFn, t translations.Transla return mcp.NewToolResultError(err.Error()), nil } - output, err := client.ListEscalationRules(ctx, int64(channelID)) + out, _, err := client.New.Channels.ChannelEscalateRuleList(ctx, &flashduty.ChannelScopedListRequest{ + ChannelID: int64(channelID), + }) if err != nil { return mcp.NewToolResultError(fmt.Sprintf("Unable to query escalation rules: %v", err)), nil } + // go-flashduty returns the full rule set without a separate total. + total := len(out.Items) return MarshalResult(addTruncationHint(map[string]any{ - "rules": output.Rules, - "total": output.Total, - }, len(output.Rules), output.Total)), nil + "rules": out.Items, + "total": total, + }, total, total)), nil } } diff --git a/pkg/flashduty/client.go b/pkg/flashduty/client.go index 447b473..a8b4624 100644 --- a/pkg/flashduty/client.go +++ b/pkg/flashduty/client.go @@ -3,8 +3,24 @@ package flashduty import ( "context" + flashduty "github.com/flashcatcloud/go-flashduty" + sdk "github.com/flashcatcloud/flashduty-sdk" ) -// GetFlashdutyClientFn is a function that returns a flashduty SDK client -type GetFlashdutyClientFn func(context.Context) (context.Context, *sdk.Client, error) +// Clients bundles the two Flashduty API clients a tool handler may need. +// +// New is the typed go-flashduty client and backs every migrated tool. Legacy +// is the hand-written flashduty-sdk client, kept only for the handful of tools +// whose endpoints go-flashduty does not cover yet (query_changes, +// validate_template, query_status_pages). +// +// TODO: drop Legacy and the flashduty-sdk dependency once go-flashduty covers +// /change/list, /template/preview, and /status-page/list. +type Clients struct { + New *flashduty.Client + Legacy *sdk.Client +} + +// GetFlashdutyClientFn returns the Flashduty clients for the current request. +type GetFlashdutyClientFn func(context.Context) (context.Context, *Clients, error) diff --git a/pkg/flashduty/fields.go b/pkg/flashduty/fields.go index 79a0fd8..23a6b60 100644 --- a/pkg/flashduty/fields.go +++ b/pkg/flashduty/fields.go @@ -4,7 +4,7 @@ import ( "context" "fmt" - sdk "github.com/flashcatcloud/flashduty-sdk" + flashduty "github.com/flashcatcloud/go-flashduty" "github.com/mark3labs/mcp-go/mcp" "github.com/mark3labs/mcp-go/server" @@ -32,22 +32,39 @@ func QueryFields(getClient GetFlashdutyClientFn, t translations.TranslationHelpe fieldIdsStr, _ := OptionalParam[string](request, "field_ids") fieldName, _ := OptionalParam[string](request, "field_name") - input := &sdk.ListFieldsInput{ - FieldName: fieldName, - } - + // Direct ID lookup: go-flashduty exposes only single-field /field/info, + // so fan out across the requested IDs. if fieldIdsStr != "" { - input.FieldIDs = parseCommaSeparatedStrings(fieldIdsStr) + fieldIDs := parseCommaSeparatedStrings(fieldIdsStr) + if len(fieldIDs) == 0 { + return mcp.NewToolResultError("field_ids must contain at least one valid ID when specified"), nil + } + fields := make([]*flashduty.FieldItem, 0, len(fieldIDs)) + for _, id := range fieldIDs { + item, _, err := client.New.AlertEnrichment.FieldReadInfo(ctx, &flashduty.FieldInfoRequest{FieldID: id}) + if err != nil { + return mcp.NewToolResultError(fmt.Sprintf("Unable to retrieve field %s: %v", id, err)), nil + } + fields = append(fields, item) + } + return MarshalResult(map[string]any{ + "fields": fields, + "total": len(fields), + }), nil } - output, err := client.ListFields(ctx, input) + // Name search maps to the Query regex filter (matches field_name and + // display_name); an exact name matches literally. + out, _, err := client.New.AlertEnrichment.FieldReadList(ctx, &flashduty.FieldListRequest{Query: fieldName}) if err != nil { return mcp.NewToolResultError(fmt.Sprintf("Unable to retrieve fields: %v", err)), nil } + // /field/list returns all matching fields without pagination. + total := len(out.Items) return MarshalResult(addTruncationHint(map[string]any{ - "fields": output.Fields, - "total": output.Total, - }, len(output.Fields), output.Total)), nil + "fields": out.Items, + "total": total, + }, total, total)), nil } } diff --git a/pkg/flashduty/format.go b/pkg/flashduty/format.go index 43213cd..75877e9 100644 --- a/pkg/flashduty/format.go +++ b/pkg/flashduty/format.go @@ -1,27 +1,50 @@ package flashduty import ( + "encoding/json" "fmt" + "strings" + + toon "github.com/toon-format/toon-go" - sdk "github.com/flashcatcloud/flashduty-sdk" "github.com/mark3labs/mcp-go/mcp" ) -// OutputFormat is a type alias for the SDK's OutputFormat. -type OutputFormat = sdk.OutputFormat +// OutputFormat defines the serialization format for tool results. +type OutputFormat string const ( // OutputFormatJSON uses standard JSON serialization (default) - OutputFormatJSON = sdk.OutputFormatJSON + OutputFormatJSON OutputFormat = "json" // OutputFormatTOON uses Token-Oriented Object Notation for reduced token usage - OutputFormatTOON = sdk.OutputFormatTOON + OutputFormatTOON OutputFormat = "toon" ) // ParseOutputFormat converts a string to OutputFormat, defaulting to JSON. -var ParseOutputFormat = sdk.ParseOutputFormat +func ParseOutputFormat(s string) OutputFormat { + switch strings.ToLower(strings.TrimSpace(s)) { + case "toon": + return OutputFormatTOON + default: + return OutputFormatJSON + } +} + +// String returns the string representation of OutputFormat. +func (f OutputFormat) String() string { return string(f) } + +// marshal serializes v using the given format. +func marshal(v any, format OutputFormat) ([]byte, error) { + switch format { + case OutputFormatTOON: + return toon.Marshal(v) + default: + return json.Marshal(v) + } +} // outputFormat is the current output format setting (package-level for simplicity) -var outputFormat OutputFormat = OutputFormatJSON +var outputFormat = OutputFormatJSON // SetOutputFormat sets the global output format func SetOutputFormat(format OutputFormat) { @@ -33,15 +56,28 @@ func GetOutputFormat() OutputFormat { return outputFormat } -// MarshalResult serializes the given value according to the current output format -// and returns it as a text result for MCP tool response. +// MarshalResult serializes the given value according to the current output +// format and returns it as a text result for an MCP tool response. +// +// Values come from go-flashduty, whose Timestamp/TimestampMilli types already +// render absolute instants as RFC3339, so no post-processing is needed. func MarshalResult(v any) *mcp.CallToolResult { - return MarshalResultWithFormat(v, outputFormat) + return marshalResultWithFormat(v, outputFormat) +} + +// MarshalLegacyResult is MarshalResult for the few tools still backed by the +// hand-written flashduty-sdk (query_changes, validate_template, +// query_status_pages). Those return bare Unix-integer time fields, so this +// variant runs humanizeTimestamps to render them as RFC3339 before encoding. +// +// TODO: delete once go-flashduty covers /change/list, /template/preview, and +// /status-page/list and the pending tools migrate to the typed Timestamp. +func MarshalLegacyResult(v any) *mcp.CallToolResult { + return marshalResultWithFormat(humanizeTimestamps(v), outputFormat) } -// MarshalResultWithFormat serializes the given value using the specified format -func MarshalResultWithFormat(v any, format OutputFormat) *mcp.CallToolResult { - data, err := sdk.Marshal(humanizeTimestamps(v), format) +func marshalResultWithFormat(v any, format OutputFormat) *mcp.CallToolResult { + data, err := marshal(v, format) if err != nil { return mcp.NewToolResultError(fmt.Sprintf("failed to marshal result: %v", err)) } diff --git a/pkg/flashduty/incidents.go b/pkg/flashduty/incidents.go index b34e34d..0897b7b 100644 --- a/pkg/flashduty/incidents.go +++ b/pkg/flashduty/incidents.go @@ -7,6 +7,7 @@ import ( "strings" sdk "github.com/flashcatcloud/flashduty-sdk" + flashduty "github.com/flashcatcloud/go-flashduty" "github.com/mark3labs/mcp-go/mcp" "github.com/mark3labs/mcp-go/server" @@ -61,52 +62,68 @@ func QueryIncidents(getClient GetFlashdutyClientFn, t translations.TranslationHe 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. + if incidentIdsStr != "" { + incidentIDs := parseCommaSeparatedStrings(incidentIdsStr) + if len(incidentIDs) == 0 { + return mcp.NewToolResultError("incident_ids must contain at least one valid ID when specified"), nil + } + out, _, err := client.New.Incidents.ListByIDs(ctx, &flashduty.ListIncidentsByIDsRequest{ + IncidentIDs: incidentIDs, + }) + if err != nil { + return mcp.NewToolResultError(fmt.Sprintf("Unable to retrieve incidents: %v", err)), nil + } + total := int(out.Total) + return MarshalResult(addTruncationHint(map[string]any{ + "incidents": out.Items, + "total": total, + }, len(out.Items), total)), nil + } + // 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, + // separated. The alert_cnt count on each incident is enough to gauge + // volume from this tool. + req := &flashduty.ListIncidentsRequest{ + Progress: progress, + IncidentSeverity: severity, + StartTime: startTime, + EndTime: endTime, + Query: query, } + req.Limit = limit if channelIdsStr != "" { channelIDs := parseCommaSeparatedInts(channelIdsStr) if len(channelIDs) == 0 { return mcp.NewToolResultError("channel_ids must contain at least one valid ID when specified"), nil } - input.ChannelIDs = make([]int64, len(channelIDs)) + req.ChannelIDs = make([]int64, len(channelIDs)) for i, id := range channelIDs { - input.ChannelIDs[i] = int64(id) + req.ChannelIDs[i] = int64(id) } } - if incidentIdsStr != "" { - incidentIDs := parseCommaSeparatedStrings(incidentIdsStr) - if len(incidentIDs) == 0 { - return mcp.NewToolResultError("incident_ids must contain at least one valid ID when specified"), nil - } - input.IncidentIDs = incidentIDs - } else if err := validateTimeWindow(startTime, endTime); err != nil { + if err := validateTimeWindow(startTime, endTime); err != nil { return mcp.NewToolResultError(err.Error()), nil } - output, err := client.ListIncidents(ctx, input) + out, _, err := client.New.Incidents.List(ctx, req) if err != nil { return mcp.NewToolResultError(fmt.Sprintf("Unable to retrieve incidents: %v", err)), nil } + total := int(out.Total) return MarshalResult(addTruncationHint(map[string]any{ - "incidents": output.Incidents, - "total": output.Total, - }, len(output.Incidents), output.Total)), nil + "incidents": out.Items, + "total": total, + }, len(out.Items), total)), nil } } @@ -137,18 +154,21 @@ func QueryIncidentTimeline(getClient GetFlashdutyClientFn, t translations.Transl return mcp.NewToolResultError("incident_ids must contain at least one valid ID"), nil } - results, err := client.GetIncidentTimelines(ctx, incidentIDs) - if err != nil { - return mcp.NewToolResultError(fmt.Sprintf("Unable to retrieve timeline: %v", err)), nil - } - - // Build response matching expected JSON shape - response := make([]map[string]any, 0, len(results)) - for _, r := range results { + // go-flashduty's Incidents.Feed returns one incident's timeline per + // call, so fan out across the requested IDs. Match the legacy + // asc/limit defaults the old SDK used for timeline fetches. + response := make([]map[string]any, 0, len(incidentIDs)) + for _, id := range incidentIDs { + feedReq := &flashduty.ListIncidentFeedRequest{IncidentID: id, Asc: true} + feedReq.Limit = 100 + out, _, err := client.New.Incidents.Feed(ctx, feedReq) + if err != nil { + return mcp.NewToolResultError(fmt.Sprintf("Unable to retrieve timeline for %s: %v", id, err)), nil + } response = append(response, map[string]any{ - "incident_id": r.IncidentID, - "timeline": r.Timeline, - "total": r.Total, + "incident_id": id, + "timeline": out.Items, + "total": len(out.Items), }) } @@ -191,18 +211,20 @@ func QueryIncidentAlerts(getClient GetFlashdutyClientFn, t translations.Translat limit = defaultQueryLimit } - results, err := client.ListIncidentAlerts(ctx, incidentIDs, limit) - if err != nil { - return mcp.NewToolResultError(fmt.Sprintf("Unable to retrieve alerts: %v", err)), nil - } - - // Build response matching expected JSON shape - response := make([]map[string]any, 0, len(results)) - for _, r := range results { + // go-flashduty's Incidents.AlertList returns one incident's alerts + // per call, so fan out across the requested IDs. + response := make([]map[string]any, 0, len(incidentIDs)) + for _, id := range incidentIDs { + alertReq := &flashduty.ListIncidentAlertsRequest{IncidentID: id} + alertReq.Limit = limit + 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{ - "incident_id": r.IncidentID, - "alerts": r.Alerts, - "total": r.Total, + "incident_id": id, + "alerts": out.Items, + "total": int(out.Total), }) } @@ -247,23 +269,27 @@ func CreateIncident(getClient GetFlashdutyClientFn, t translations.TranslationHe description, _ := OptionalParam[string](request, "description") assignedToStr, _ := OptionalParam[string](request, "assigned_to") - input := &sdk.CreateIncidentInput{ - Title: title, - Severity: severity, - ChannelID: int64(channelID), - Description: description, + req := &flashduty.CreateIncidentRequest{ + Title: title, + IncidentSeverity: severity, + ChannelID: int64(channelID), + Description: description, } if assignedToStr != "" { - input.AssignedTo = parseCommaSeparatedInts(assignedToStr) + personIDs := parseCommaSeparatedInts(assignedToStr) + req.AssignedTo.PersonIDs = make([]int64, len(personIDs)) + for i, id := range personIDs { + req.AssignedTo.PersonIDs[i] = int64(id) + } } - result, err := client.CreateIncident(ctx, input) + out, _, err := client.New.Incidents.Create(ctx, req) if err != nil { return mcp.NewToolResultError(fmt.Sprintf("Unable to create incident: %v", err)), nil } - return MarshalResult(result), nil + return MarshalResult(out), nil } } @@ -304,23 +330,14 @@ func UpdateIncident(getClient GetFlashdutyClientFn, t translations.TranslationHe 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 + // Parse custom fields JSON up front so a bad payload fails before any + // write hits the backend. + var customFields map[string]any if customFieldsStr != "" { customFieldsStr = strings.TrimSpace(customFieldsStr) if customFieldsStr == "" { return mcp.NewToolResultError("custom_fields must be a valid JSON object, not empty"), nil } - var customFields map[string]any if err := json.Unmarshal([]byte(customFieldsStr), &customFields); err != nil { return mcp.NewToolResultError(fmt.Sprintf("custom_fields must be a valid JSON object: %v", err)), nil } @@ -339,12 +356,69 @@ func UpdateIncident(getClient GetFlashdutyClientFn, t translations.TranslationHe } } } - input.CustomFields = customFields } - updatedFields, err := client.UpdateIncident(ctx, input) - if err != nil { - return mcp.NewToolResultError(fmt.Sprintf("Unable to update incident: %v", err)), nil + updatedFields := make([]string, 0) + + // Built-in fields go through one /incident/reset call. The backend + // ignores empty strings, so only set the fields the caller provided + // and record their canonical names for the response. + resetReq := &flashduty.UpdateIncidentFieldsRequest{IncidentID: incidentID} + if title != "" { + resetReq.Title = title + updatedFields = append(updatedFields, "title") + } + if description != "" { + resetReq.Description = description + updatedFields = append(updatedFields, "description") + } + if severity != "" { + resetReq.IncidentSeverity = severity + updatedFields = append(updatedFields, "severity") + } + if impact != "" { + resetReq.Impact = impact + updatedFields = append(updatedFields, "impact") + } + if rootCause != "" { + resetReq.RootCause = rootCause + updatedFields = append(updatedFields, "root_cause") + } + if resolution != "" { + resetReq.Resolution = resolution + updatedFields = append(updatedFields, "resolution") + } + + if len(updatedFields) > 0 { + if _, err := client.New.Incidents.Reset(ctx, resetReq); err != nil { + return mcp.NewToolResultError(fmt.Sprintf("Unable to update incident: %v", err)), nil + } + } + + // Custom fields are one /incident/field/reset call each. The backend + // accepts an arbitrary JSON value for field_value, but go-flashduty's + // ResetIncidentFieldRequest.FieldValue is generated as map[string]any, + // which cannot carry scalar values (the common case). Until that type + // is fixed upstream, route custom-field writes through the legacy SDK, + // which sends the raw value as the API expects. Pass ONLY the custom + // fields so the legacy call skips /incident/reset (built-ins already + // went through the typed client above) and only hits + // /incident/field/reset per field. + // TODO: switch to client.New.Incidents.FieldReset once go-flashduty + // types field_value as `any`. + if len(customFields) > 0 { + customNames, err := client.Legacy.UpdateIncident(ctx, &sdk.UpdateIncidentInput{ + IncidentID: incidentID, + CustomFields: customFields, + }) + if err != nil { + return mcp.NewToolResultError(fmt.Sprintf("Unable to update custom fields: %v", err)), nil + } + updatedFields = append(updatedFields, customNames...) + } + + if len(updatedFields) == 0 { + return mcp.NewToolResultError("no fields specified to update"), nil } return MarshalResult(map[string]any{ @@ -382,7 +456,7 @@ func AckIncident(getClient GetFlashdutyClientFn, t translations.TranslationHelpe return mcp.NewToolResultError("incident_ids must contain at least one valid ID"), nil } - if err := client.AckIncidents(ctx, incidentIDs); err != nil { + if _, err := client.New.Incidents.Ack(ctx, &flashduty.AckIncidentRequest{IncidentIDs: incidentIDs}); err != nil { return mcp.NewToolResultError(fmt.Sprintf("Unable to acknowledge incidents: %v", err)), nil } @@ -420,7 +494,7 @@ func CloseIncident(getClient GetFlashdutyClientFn, t translations.TranslationHel return mcp.NewToolResultError("incident_ids must contain at least one valid ID"), nil } - if err := client.CloseIncidents(ctx, incidentIDs); err != nil { + if _, err := client.New.Incidents.Resolve(ctx, &flashduty.ResolveIncidentRequest{IncidentIDs: incidentIDs}); err != nil { return mcp.NewToolResultError(fmt.Sprintf("Unable to close incidents: %v", err)), nil } @@ -459,14 +533,20 @@ func ListSimilarIncidents(getClient GetFlashdutyClientFn, t translations.Transla limit = defaultQueryLimit } - output, err := client.ListSimilarIncidents(ctx, incidentID, limit) + out, _, err := client.New.Incidents.PastList(ctx, &flashduty.ListPastIncidentsRequest{ + IncidentID: incidentID, + Limit: int64(limit), + }) if err != nil { return mcp.NewToolResultError(fmt.Sprintf("Unable to find similar incidents: %v", err)), nil } + // PastList returns the full similar set without a separate total, so + // the count is the slice length. + total := len(out.Items) return MarshalResult(addTruncationHint(map[string]any{ - "incidents": output.Incidents, - "total": output.Total, - }, len(output.Incidents), output.Total)), nil + "incidents": out.Items, + "total": total, + }, total, total)), nil } } diff --git a/pkg/flashduty/statuspage.go b/pkg/flashduty/statuspage.go index d66aaca..5fb9bc6 100644 --- a/pkg/flashduty/statuspage.go +++ b/pkg/flashduty/statuspage.go @@ -2,9 +2,13 @@ package flashduty import ( "context" + "encoding/json" "fmt" + "strings" + "time" sdk "github.com/flashcatcloud/flashduty-sdk" + flashduty "github.com/flashcatcloud/go-flashduty" "github.com/mark3labs/mcp-go/mcp" "github.com/mark3labs/mcp-go/server" @@ -38,12 +42,14 @@ func QueryStatusPages(getClient GetFlashdutyClientFn, t translations.Translation } } - pages, err := client.ListStatusPages(ctx, pageIDs) + // TODO: 待 go-flashduty 覆盖 /change/list,/template/preview,/status-page/list + // 后切换并删除老 SDK 依赖。/status-page/list is not yet in go-flashduty. + pages, err := client.Legacy.ListStatusPages(ctx, pageIDs) if err != nil { return mcp.NewToolResultError(fmt.Sprintf("failed to list status pages: %v", err)), nil } - return MarshalResult(map[string]any{ + return MarshalLegacyResult(map[string]any{ "pages": pages, "total": len(pages), }), nil @@ -82,7 +88,13 @@ func ListStatusChanges(getClient GetFlashdutyClientFn, t translations.Translatio return mcp.NewToolResultError("type must be 'incident' or 'maintenance'"), nil } - output, err := client.ListStatusChanges(ctx, &sdk.ListStatusChangesInput{ + // This tool lists *active* changes via /status-page/change/active/list, + // which go-flashduty does not expose (its ChangeList hits the general + // /status-page/change/list, which requires a single mandatory status + // and a time window — different semantics). Keep the legacy SDK until + // go-flashduty adds the active-list endpoint. + // TODO: 待 go-flashduty 覆盖 /status-page/change/active/list 后切换并删除老 SDK 依赖。 + output, err := client.Legacy.ListStatusChanges(ctx, &sdk.ListStatusChangesInput{ PageID: int64(pageID), ChangeType: changeType, }) @@ -90,7 +102,7 @@ func ListStatusChanges(getClient GetFlashdutyClientFn, t translations.Translatio return mcp.NewToolResultError(fmt.Sprintf("failed to list status changes: %v", err)), nil } - return MarshalResult(addTruncationHint(map[string]any{ + return MarshalLegacyResult(addTruncationHint(map[string]any{ "changes": output.Changes, "total": output.Total, }, len(output.Changes), output.Total)), nil @@ -134,22 +146,69 @@ func CreateStatusIncident(getClient GetFlashdutyClientFn, t translations.Transla affectedComponents, _ := OptionalParam[string](request, "affected_components") notifySubscribers, _ := OptionalParam[bool](request, "notify_subscribers") - data, err := client.CreateStatusIncident(ctx, &sdk.CreateStatusIncidentInput{ - PageID: int64(pageID), - Title: title, - Message: message, - Status: status, - AffectedComponents: affectedComponents, - NotifySubscribers: notifySubscribers, + if status == "" { + status = "investigating" + } + + // The initial update mirrors the incident: same status, the message + // (or the title when no message), and the affected components. + update := flashduty.CreateStatusPageChangeRequestUpdatesItem{ + AtSeconds: time.Now().Unix(), + Status: status, + } + if message != "" { + update.Description = message + } + update.ComponentChanges = parseAffectedComponents(affectedComponents) + + description := message + if description == "" { + description = title + } + + out, _, err := client.New.StatusPages.ChangeCreate(ctx, &flashduty.CreateStatusPageChangeRequest{ + PageID: int64(pageID), + Title: title, + Type: "incident", + Status: status, + Description: description, + Updates: []flashduty.CreateStatusPageChangeRequestUpdatesItem{update}, + NotifySubscribers: notifySubscribers, }) if err != nil { return mcp.NewToolResultError(fmt.Sprintf("failed to create status incident: %v", err)), nil } - return MarshalResult(data), nil + return MarshalResult(out), nil } } +// parseAffectedComponents parses the "id1:degraded,id2:partial_outage" syntax +// the create_status_incident tool accepts. A bare id (no ":status") defaults to +// partial_outage, matching the legacy behavior. +func parseAffectedComponents(s string) []flashduty.CreateStatusPageChangeRequestUpdatesItemComponentChangesItem { + if s == "" { + return nil + } + var changes []flashduty.CreateStatusPageChangeRequestUpdatesItemComponentChangesItem + for _, part := range parseCommaSeparatedStrings(s) { + kv := strings.SplitN(part, ":", 2) + switch { + case len(kv) == 2: + changes = append(changes, flashduty.CreateStatusPageChangeRequestUpdatesItemComponentChangesItem{ + ComponentID: strings.TrimSpace(kv[0]), + Status: strings.TrimSpace(kv[1]), + }) + case len(kv) == 1 && kv[0] != "": + changes = append(changes, flashduty.CreateStatusPageChangeRequestUpdatesItemComponentChangesItem{ + ComponentID: strings.TrimSpace(kv[0]), + Status: "partial_outage", + }) + } + } + return changes +} + const createChangeTimelineDescription = `Add a timeline update to a status page incident or maintenance. Update status and affected components.` // CreateChangeTimeline creates a tool to add timeline entry to status change @@ -195,15 +254,21 @@ func CreateChangeTimeline(getClient GetFlashdutyClientFn, t translations.Transla return mcp.NewToolResultError(fmt.Sprintf("invalid at: %v", err)), nil } - err = client.CreateChangeTimeline(ctx, &sdk.CreateChangeTimelineInput{ - PageID: int64(pageID), - ChangeID: int64(changeID), - Message: message, - AtSeconds: atSeconds, - Status: status, - ComponentChanges: componentChanges, - }) - if err != nil { + req := &flashduty.CreateStatusPageChangeTimelineRequest{ + PageID: int64(pageID), + ChangeID: int64(changeID), + Description: message, + AtSeconds: atSeconds, + Status: status, + } + + if componentChanges != "" { + if err := json.Unmarshal([]byte(componentChanges), &req.ComponentChanges); err != nil { + return mcp.NewToolResultError(fmt.Sprintf("component_changes must be a valid JSON array: %v", err)), nil + } + } + + if _, _, err := client.New.StatusPages.ChangeTimelineCreate(ctx, req); err != nil { return mcp.NewToolResultError(fmt.Sprintf("failed to create timeline: %v", err)), nil } diff --git a/pkg/flashduty/templates.go b/pkg/flashduty/templates.go index 72c9bc4..80b390f 100644 --- a/pkg/flashduty/templates.go +++ b/pkg/flashduty/templates.go @@ -2,16 +2,22 @@ package flashduty import ( "context" + "encoding/json" "fmt" "slices" sdk "github.com/flashcatcloud/flashduty-sdk" + flashduty "github.com/flashcatcloud/go-flashduty" "github.com/mark3labs/mcp-go/mcp" "github.com/mark3labs/mcp-go/server" "github.com/flashcatcloud/flashduty-mcp-server/pkg/translations" ) +// presetTemplateID addresses the built-in preset notification template in +// go-flashduty's /template/info endpoint. +const presetTemplateID = "000000000000000000000001" + // --- Tool 1: get_preset_template --- const getPresetTemplateDescription = `Fetch the preset (default) notification template for a specific channel. Returns the Go template code used as the starting point for customization.` @@ -46,19 +52,50 @@ func GetPresetTemplate(getClient GetFlashdutyClientFn, t translations.Translatio return mcp.NewToolResultError(err.Error()), nil } - input := &sdk.GetPresetTemplateInput{ - Channel: channel, + fieldName, ok := sdk.TemplateChannels[channel] + if !ok { + return mcp.NewToolResultError(fmt.Sprintf("unknown channel: %s", channel)), nil } - output, err := client.GetPresetTemplate(ctx, input) + item, _, err := client.New.NotificationTemplates.ReadInfo(ctx, &flashduty.TemplateIDRequest{ + TemplateID: presetTemplateID, + }) if err != nil { return mcp.NewToolResultError(fmt.Sprintf("Unable to fetch preset template: %v", err)), nil } - return MarshalResult(output), nil + // ReadInfo returns every channel's template on one TemplateItem; + // pluck the requested channel's code by its JSON field name. + templateCode := templateCodeForChannel(item, fieldName) + if templateCode == "" { + return mcp.NewToolResultError(fmt.Sprintf("no preset template found for channel: %s", channel)), nil + } + + return MarshalResult(map[string]any{ + "channel": channel, + "field_name": fieldName, + "template_code": templateCode, + }), nil } } +// templateCodeForChannel extracts the per-channel template source from a +// TemplateItem by the channel's JSON field name (e.g. "dingtalk", "email"). +func templateCodeForChannel(item *flashduty.TemplateItem, fieldName string) string { + b, err := json.Marshal(item) + if err != nil { + return "" + } + var fields map[string]any + if err := json.Unmarshal(b, &fields); err != nil { + return "" + } + if v, ok := fields[fieldName].(string); ok { + return v + } + return "" +} + // --- Tool 2: validate_template --- const validateTemplateDescription = `Validate a notification template by parsing it and rendering with incident data. Returns the rendered preview, validation status, and size information. Supports both mock data (default) and real incident preview via incident_id.` @@ -107,12 +144,14 @@ func ValidateTemplate(getClient GetFlashdutyClientFn, t translations.Translation IncidentID: incidentID, } - output, err := client.ValidateTemplate(ctx, input) + // TODO: 待 go-flashduty 覆盖 /change/list,/template/preview,/status-page/list + // 后切换并删除老 SDK 依赖。/template/preview is not yet in go-flashduty. + output, err := client.Legacy.ValidateTemplate(ctx, input) if err != nil { return mcp.NewToolResultError(fmt.Sprintf("Unable to validate template: %v", err)), nil } - return MarshalResult(output), nil + return MarshalLegacyResult(output), nil } } diff --git a/pkg/flashduty/templates_test.go b/pkg/flashduty/templates_test.go index 5dfb1dc..5b28678 100644 --- a/pkg/flashduty/templates_test.go +++ b/pkg/flashduty/templates_test.go @@ -5,15 +5,13 @@ import ( "encoding/json" "testing" - sdk "github.com/flashcatcloud/flashduty-sdk" - "github.com/flashcatcloud/flashduty-mcp-server/pkg/translations" ) func TestGetPresetTemplateSchemaDoesNotExposeLocale(t *testing.T) { t.Parallel() - tool, _ := GetPresetTemplate(func(ctx context.Context) (context.Context, *sdk.Client, error) { + tool, _ := GetPresetTemplate(func(ctx context.Context) (context.Context, *Clients, error) { return ctx, nil, nil }, translations.NullTranslationHelper) diff --git a/pkg/flashduty/timestamps_test.go b/pkg/flashduty/timestamps_test.go index 1ed7b38..3d2fef0 100644 --- a/pkg/flashduty/timestamps_test.go +++ b/pkg/flashduty/timestamps_test.go @@ -8,12 +8,14 @@ import ( "github.com/mark3labs/mcp-go/mcp" ) -// TestMarshalResult_HumanizesTimestamps locks the wiring: every tool result -// routed through MarshalResultWithFormat must have its timestamps humanized, so -// a raw Unix integer never reaches the model. -func TestMarshalResult_HumanizesTimestamps(t *testing.T) { +// TestMarshalLegacyResult_HumanizesTimestamps locks the wiring for the +// legacy/pending tools: results routed through MarshalLegacyResult must have +// their raw Unix-integer timestamps humanized, so a bare epoch never reaches +// the model. (Migrated tools use go-flashduty's Timestamp type, which already +// renders RFC3339, and go through MarshalResult without this post-processing.) +func TestMarshalLegacyResult_HumanizesTimestamps(t *testing.T) { const ts = 1748419200 - res := MarshalResultWithFormat(map[string]any{"start_time": ts}, OutputFormatJSON) + res := MarshalLegacyResult(map[string]any{"start_time": ts}) tc, ok := mcp.AsTextContent(res.Content[0]) if !ok { t.Fatalf("expected text content, got %#v", res.Content[0]) diff --git a/pkg/flashduty/users.go b/pkg/flashduty/users.go index 54f70b0..b8d418c 100644 --- a/pkg/flashduty/users.go +++ b/pkg/flashduty/users.go @@ -4,7 +4,7 @@ import ( "context" "fmt" - sdk "github.com/flashcatcloud/flashduty-sdk" + flashduty "github.com/flashcatcloud/go-flashduty" "github.com/mark3labs/mcp-go/mcp" "github.com/mark3labs/mcp-go/server" @@ -34,39 +34,48 @@ func QueryMembers(getClient GetFlashdutyClientFn, t translations.TranslationHelp name, _ := OptionalParam[string](request, "name") email, _ := OptionalParam[string](request, "email") - input := &sdk.ListMembersInput{ - Name: name, - Email: email, - } - + // Direct ID lookup uses /member/infos (PersonInfos), which returns + // profiles without a separate total. if personIdsStr != "" { - personIDs := parseCommaSeparatedInts(personIdsStr) + ids := parseCommaSeparatedInts(personIdsStr) + personIDs := make([]uint64, 0, len(ids)) + for _, id := range ids { + if id < 0 { + continue + } + personIDs = append(personIDs, uint64(id)) + } if len(personIDs) == 0 { return mcp.NewToolResultError("person_ids must contain at least one valid ID when specified"), nil } - int64IDs := make([]int64, len(personIDs)) - for i, id := range personIDs { - int64IDs[i] = int64(id) + out, _, err := client.New.Members.PersonInfos(ctx, &flashduty.PersonInfosRequest{PersonIDs: personIDs}) + if err != nil { + return mcp.NewToolResultError(fmt.Sprintf("Unable to retrieve members: %v", err)), nil } - input.PersonIDs = int64IDs + count := len(out.Items) + return MarshalResult(addTruncationHint(map[string]any{ + "members": out.Items, + "total": count, + }, count, count)), nil } - output, err := client.ListMembers(ctx, input) + // Name/email search uses /member/list. go-flashduty exposes a single + // free-text Query (no dedicated email field), so fold name/email into + // it, preferring name when both are supplied. + query := name + if query == "" { + query = email + } + out, _, err := client.New.Members.MemberList(ctx, &flashduty.MemberListRequest{Query: query}) if err != nil { return mcp.NewToolResultError(fmt.Sprintf("Unable to retrieve members: %v", err)), nil } - var members any = output.Members - count := len(output.Members) - if len(output.PersonInfos) > 0 { - members = output.PersonInfos - count = len(output.PersonInfos) - } - + total := int(out.Total) return MarshalResult(addTruncationHint(map[string]any{ - "members": members, - "total": output.Total, - }, count, output.Total)), nil + "members": out.Items, + "total": total, + }, len(out.Items), total)), nil } } @@ -91,37 +100,38 @@ func QueryTeams(getClient GetFlashdutyClientFn, t translations.TranslationHelper teamIdsStr, _ := OptionalParam[string](request, "team_ids") name, _ := OptionalParam[string](request, "name") - input := &sdk.ListTeamsInput{ - Name: name, - } - + // Direct ID lookup uses /team/infos (ReadInfos) and preserves the + // historical `items`-only response shape. if teamIdsStr != "" { - teamIDs := parseCommaSeparatedInts(teamIdsStr) + ids := parseCommaSeparatedInts(teamIdsStr) + teamIDs := make([]uint64, 0, len(ids)) + for _, id := range ids { + if id < 0 { + continue + } + teamIDs = append(teamIDs, uint64(id)) + } if len(teamIDs) == 0 { return mcp.NewToolResultError("team_ids must contain at least one valid ID when specified"), nil } - int64IDs := make([]int64, len(teamIDs)) - for i, id := range teamIDs { - int64IDs[i] = int64(id) + out, _, err := client.New.Teams.ReadInfos(ctx, &flashduty.TeamInfosRequest{TeamIDs: teamIDs}) + if err != nil { + return mcp.NewToolResultError(fmt.Sprintf("Unable to retrieve teams: %v", err)), nil } - input.TeamIDs = int64IDs + return MarshalResult(map[string]any{ + "items": out.Items, + }), nil } - output, err := client.ListTeams(ctx, input) + out, _, err := client.New.Teams.ReadList(ctx, &flashduty.TeamListRequest{Query: name}) if err != nil { return mcp.NewToolResultError(fmt.Sprintf("Unable to retrieve teams: %v", err)), nil } - // Preserve the historical direct-lookup shape for team_ids queries. - if len(input.TeamIDs) > 0 { - return MarshalResult(map[string]any{ - "items": output.Teams, - }), nil - } - + total := int(out.Total) return MarshalResult(addTruncationHint(map[string]any{ - "teams": output.Teams, - "total": output.Total, - }, len(output.Teams), output.Total)), nil + "teams": out.Items, + "total": total, + }, len(out.Items), total)), nil } } diff --git a/pkg/flashduty/users_test.go b/pkg/flashduty/users_test.go index 41f202a..ecd4526 100644 --- a/pkg/flashduty/users_test.go +++ b/pkg/flashduty/users_test.go @@ -7,7 +7,7 @@ import ( "net/http/httptest" "testing" - sdk "github.com/flashcatcloud/flashduty-sdk" + flashduty "github.com/flashcatcloud/go-flashduty" "github.com/mark3labs/mcp-go/mcp" "github.com/flashcatcloud/flashduty-mcp-server/pkg/translations" @@ -34,13 +34,13 @@ func TestQueryTeamsByIDsPreservesLegacyItemsShape(t *testing.T) { })) defer ts.Close() - client, err := sdk.NewClient("test-key", sdk.WithBaseURL(ts.URL)) + client, err := flashduty.NewClient("test-key", flashduty.WithBaseURL(ts.URL)) if err != nil { - t.Fatalf("new sdk client: %v", err) + t.Fatalf("new go-flashduty client: %v", err) } - _, handler := QueryTeams(func(ctx context.Context) (context.Context, *sdk.Client, error) { - return ctx, client, nil + _, handler := QueryTeams(func(ctx context.Context) (context.Context, *Clients, error) { + return ctx, &Clients{New: client}, nil }, translations.NullTranslationHelper) result, err := handler(context.Background(), mcp.CallToolRequest{ From 6a795c65bfc407f8a5b68acc2d1876dac5290bfd Mon Sep 17 00:00:00 2001 From: ysyneu Date: Sun, 31 May 2026 11:02:55 +0800 Subject: [PATCH 3/5] feat: migrate query_changes, query_status_pages, validate_template onto go-flashduty Bump go-flashduty to v0.4.0 (documents /change/list, /status-page/list, /template/preview) and migrate the last three unblocked tools off the hand-written flashduty-sdk (client.Legacy) onto the generated client (client.New): - query_changes: client.Legacy.ListChanges -> client.New.Changes.List (ListChangeRequest/ListChangeResponse). change_ids has no server-side filter on /change/list, so the direct-lookup param is honored by filtering the returned page client-side; switched to MarshalResult since ChangeItem uses the typed Timestamp (RFC3339) instead of bare Unix ints. - query_status_pages: client.Legacy.ListStatusPages -> client.New.StatusPages.ReadPageList. The new endpoint lists all pages and takes no filter param, so the optional page_ids filter is applied client-side over Items. - validate_template: client.Legacy.ValidateTemplate -> client.New.NotificationTemplates.ReadPreview (PreviewTemplateRequest/ Response). The raw endpoint only renders + reports parse errors, so the size-limit validation (rendered_size/size_limit/errors/warnings, telegram/teams_app special cases) that the legacy SDK folded in is reproduced in the handler, keeping the output shape identical. Stays on Legacy (flashduty-sdk dependency retained): - list_status_changes (ListStatusChanges) -> /status-page/change/active/list, still a documented gap; go-flashduty's ChangeList hits the general /status-page/change/list with different (status+window) semantics. - update_incident custom-field-names path (UpdateIncident) -> shape-divergent; /incident/reset does not carry custom fields. Verified: go build, go vet, go test, golangci-lint, gofmt all clean; e2e (in-process vs api-dev) 15/15 pass incl. TestQueryChanges. --- go.mod | 2 +- go.sum | 4 +-- pkg/flashduty/changes.go | 45 ++++++++++++++++++----------- pkg/flashduty/statuspage.go | 23 +++++++++++---- pkg/flashduty/templates.go | 57 +++++++++++++++++++++++++++++++------ 5 files changed, 97 insertions(+), 34 deletions(-) diff --git a/go.mod b/go.mod index 89073c5..d1b3629 100644 --- a/go.mod +++ b/go.mod @@ -5,7 +5,7 @@ go 1.25.5 require ( github.com/bluele/gcache v0.0.2 github.com/flashcatcloud/flashduty-sdk v0.8.1-0.20260510070250-0340ac6a5a33 - github.com/flashcatcloud/go-flashduty v0.3.0 + github.com/flashcatcloud/go-flashduty v0.4.0 github.com/google/go-github/v72 v72.0.0 github.com/josephburnett/jd v1.9.2 github.com/mark3labs/mcp-go v0.52.0 diff --git a/go.sum b/go.sum index 06ee6f5..4cacfca 100644 --- a/go.sum +++ b/go.sum @@ -10,8 +10,8 @@ github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxK github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= github.com/flashcatcloud/flashduty-sdk v0.8.1-0.20260510070250-0340ac6a5a33 h1:HnB++VulEnF+oIsNwKK8QBv4CCdG+ztdFKLScI4bXlc= github.com/flashcatcloud/flashduty-sdk v0.8.1-0.20260510070250-0340ac6a5a33/go.mod h1:dG4eJfdZaj4jNBMwEexbfK/3PmcIMhNeJ88L/DcZzUY= -github.com/flashcatcloud/go-flashduty v0.3.0 h1:DlwkrK/MIkkWfqJoKwvq3fh/8A0A3OUEbAMDIRrkLkI= -github.com/flashcatcloud/go-flashduty v0.3.0/go.mod h1:aA0RtZEs0AYOwwdNKdtVeD8YMOdnmVY1zAlVD+9Ovx8= +github.com/flashcatcloud/go-flashduty v0.4.0 h1:J+gUJB3TrRWFT2Wy3u0YeYznqFwh/5UhKTc8aZV9rEs= +github.com/flashcatcloud/go-flashduty v0.4.0/go.mod h1:aA0RtZEs0AYOwwdNKdtVeD8YMOdnmVY1zAlVD+9Ovx8= 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= diff --git a/pkg/flashduty/changes.go b/pkg/flashduty/changes.go index 5c8342f..e025abf 100644 --- a/pkg/flashduty/changes.go +++ b/pkg/flashduty/changes.go @@ -5,7 +5,7 @@ import ( "fmt" "time" - sdk "github.com/flashcatcloud/flashduty-sdk" + flashduty "github.com/flashcatcloud/go-flashduty" "github.com/mark3labs/mcp-go/mcp" "github.com/mark3labs/mcp-go/server" @@ -65,38 +65,49 @@ func QueryChanges(getClient GetFlashdutyClientFn, t translations.TranslationHelp return mcp.NewToolResultError(err.Error()), nil } - input := &sdk.ListChangesInput{ + req := &flashduty.ListChangeRequest{ StartTime: startTime, EndTime: endTime, - Type: changeType, - Limit: limit, - } - - if changeIdsStr != "" { - input.ChangeIDs = parseCommaSeparatedStrings(changeIdsStr) + Query: changeType, } + req.Limit = limit if channelIdsStr != "" { channelIDs := parseCommaSeparatedInts(channelIdsStr) if len(channelIDs) == 0 { return mcp.NewToolResultError("channel_ids must contain at least one valid ID when specified"), nil } - input.ChannelIDs = make([]int64, len(channelIDs)) + req.ChannelIDs = make([]int64, len(channelIDs)) for i, id := range channelIDs { - input.ChannelIDs[i] = int64(id) + req.ChannelIDs[i] = int64(id) } } - // TODO: 待 go-flashduty 覆盖 /change/list,/template/preview,/status-page/list - // 后切换并删除老 SDK 依赖。/change/list is not yet in go-flashduty. - output, err := client.Legacy.ListChanges(ctx, input) + resp, _, err := client.New.Changes.List(ctx, req) if err != nil { return mcp.NewToolResultError(fmt.Sprintf("Unable to retrieve changes: %v", err)), nil } - return MarshalLegacyResult(addTruncationHint(map[string]any{ - "changes": output.Changes, - "total": output.Total, - }, len(output.Changes), output.Total)), nil + changes := resp.Items + // /change/list has no change_ids filter; honor the direct-lookup + // param by filtering the returned page client-side. + if changeIdsStr != "" { + wanted := make(map[string]struct{}) + for _, id := range parseCommaSeparatedStrings(changeIdsStr) { + wanted[id] = struct{}{} + } + filtered := changes[:0] + for _, ch := range changes { + if _, ok := wanted[ch.ChangeID]; ok { + filtered = append(filtered, ch) + } + } + changes = filtered + } + + return MarshalResult(addTruncationHint(map[string]any{ + "changes": changes, + "total": resp.Total, + }, len(changes), int(resp.Total))), nil } } diff --git a/pkg/flashduty/statuspage.go b/pkg/flashduty/statuspage.go index 5fb9bc6..0f24f68 100644 --- a/pkg/flashduty/statuspage.go +++ b/pkg/flashduty/statuspage.go @@ -35,21 +35,32 @@ func QueryStatusPages(getClient GetFlashdutyClientFn, t translations.Translation pageIdsStr, _ := OptionalParam[string](request, "page_ids") - var pageIDs []int64 + wanted := make(map[int64]struct{}) if pageIdsStr != "" { for _, id := range parseCommaSeparatedInts(pageIdsStr) { - pageIDs = append(pageIDs, int64(id)) + wanted[int64(id)] = struct{}{} } } - // TODO: 待 go-flashduty 覆盖 /change/list,/template/preview,/status-page/list - // 后切换并删除老 SDK 依赖。/status-page/list is not yet in go-flashduty. - pages, err := client.Legacy.ListStatusPages(ctx, pageIDs) + resp, _, err := client.New.StatusPages.ReadPageList(ctx) if err != nil { return mcp.NewToolResultError(fmt.Sprintf("failed to list status pages: %v", err)), nil } - return MarshalLegacyResult(map[string]any{ + // ReadPageList returns every page; honor the optional page_ids + // filter client-side since the endpoint takes no filter param. + pages := resp.Items + if len(wanted) > 0 { + filtered := pages[:0] + for _, p := range pages { + if _, ok := wanted[p.PageID]; ok { + filtered = append(filtered, p) + } + } + pages = filtered + } + + return MarshalResult(map[string]any{ "pages": pages, "total": len(pages), }), nil diff --git a/pkg/flashduty/templates.go b/pkg/flashduty/templates.go index 80b390f..20cbd79 100644 --- a/pkg/flashduty/templates.go +++ b/pkg/flashduty/templates.go @@ -138,20 +138,61 @@ func ValidateTemplate(getClient GetFlashdutyClientFn, t translations.Translation incidentID, _ := OptionalParam[string](request, "incident_id") - input := &sdk.ValidateTemplateInput{ - Channel: channel, - TemplateCode: templateCode, - IncidentID: incidentID, + fieldName, ok := sdk.TemplateChannels[channel] + if !ok { + return mcp.NewToolResultError(fmt.Sprintf("unknown channel: %s", channel)), nil } - // TODO: 待 go-flashduty 覆盖 /change/list,/template/preview,/status-page/list - // 后切换并删除老 SDK 依赖。/template/preview is not yet in go-flashduty. - output, err := client.Legacy.ValidateTemplate(ctx, input) + // /template/preview renders the template; the wire `type` is the + // channel identifier itself (e.g. "dingtalk"). + out, _, err := client.New.NotificationTemplates.ReadPreview(ctx, &flashduty.PreviewTemplateRequest{ + Content: templateCode, + IncidentID: incidentID, + Type: channel, + }) if err != nil { return mcp.NewToolResultError(fmt.Sprintf("Unable to validate template: %v", err)), nil } - return MarshalLegacyResult(output), nil + // The raw endpoint only renders + reports parse errors. Size-limit + // validation (errors/warnings, rendered_size, size_limit) is tool + // logic the legacy SDK used to fold in; reproduce it here so the + // output shape stays identical post-migration. + renderedPreview := out.Content + renderedSize := len(renderedPreview) + sizeLimit := sdk.ChannelSizeLimits[channel] + + errs := []string{} + warnings := []string{} + if !out.Success { + errs = append(errs, out.Message) + } + if sizeLimit > 0 { + if renderedSize > sizeLimit { + sizeWarning := fmt.Sprintf("Rendered output is %d bytes, exceeding the %d byte limit for %s.", renderedSize, sizeLimit, channel) + switch channel { + case "telegram": + sizeWarning += " CRITICAL: Telegram will silently drop this message." + case "teams_app": + sizeWarning += " Teams will return an error for this message." + } + errs = append(errs, sizeWarning) + } else if renderedSize > int(float64(sizeLimit)*0.8) { + warnings = append(warnings, fmt.Sprintf("Rendered output is %d/%d bytes (%.0f%% of limit).", renderedSize, sizeLimit, float64(renderedSize)/float64(sizeLimit)*100)) + } + } + + return MarshalResult(map[string]any{ + "channel": channel, + "field_name": fieldName, + "template_code": templateCode, + "success": out.Success && len(errs) == 0, + "rendered_preview": renderedPreview, + "rendered_size": renderedSize, + "size_limit": sizeLimit, + "errors": errs, + "warnings": warnings, + }), nil } } From add8fc2e9e22a0c32dd7bdb83c695bda62286a64 Mon Sep 17 00:00:00 2001 From: ysyneu Date: Sun, 31 May 2026 18:43:01 +0800 Subject: [PATCH 4/5] feat: deprecate legacy flashduty-sdk, fully migrate to go-flashduty The MCP server was dual-client (typed go-flashduty + hand-written flashduty-sdk). The remaining three legacy-backed tools are now on go-flashduty v0.5.1, so the legacy SDK and its dual-client scaffolding are removed entirely. - statuspage list_status_changes: Legacy.ListStatusChanges -> StatusPages.ChangeActiveList (/status-page/change/active/list; no list-level total, count = len(items)). - templates: vendor the static template metadata (channels, size limits, variable and function catalogs) into pkg/flashduty/templatemeta.go; the data is client-side authoring reference, not an API surface, so it lives in-repo. - incidents update_incident custom fields: Legacy.UpdateIncident -> Incidents.FieldReset, unblocked by go-flashduty v0.5.1 typing field_value as any (was map[string]any). - Remove Clients.Legacy, the legacy client construction in context.go, the Legacy.SetUserAgent call in server.go, and the dead getAPIClient e2e helper. - Drop MarshalLegacyResult + humanizeTimestamps: they were the legacy-only stopgap for bare Unix-int time fields; go-flashduty's self-describing Timestamp type renders RFC3339 on the typed path (the code's own TODO predicted this removal). - go mod tidy drops github.com/flashcatcloud/flashduty-sdk. build/vet/test/lint all green; zero remaining flashduty-sdk/client.Legacy refs. --- e2e/README.md | 1 - e2e/e2e_test.go | 18 --- go.mod | 4 +- go.sum | 8 +- internal/flashduty/context.go | 26 +--- internal/flashduty/server.go | 1 - pkg/flashduty/client.go | 15 +-- pkg/flashduty/format.go | 11 -- pkg/flashduty/incidents.go | 29 ++--- pkg/flashduty/statuspage.go | 26 ++-- pkg/flashduty/templatemeta.go | 200 +++++++++++++++++++++++++++++++ pkg/flashduty/templates.go | 19 ++- pkg/flashduty/timestamps.go | 94 --------------- pkg/flashduty/timestamps_test.go | 124 ------------------- 14 files changed, 240 insertions(+), 336 deletions(-) create mode 100644 pkg/flashduty/templatemeta.go delete mode 100644 pkg/flashduty/timestamps.go delete mode 100644 pkg/flashduty/timestamps_test.go diff --git a/e2e/README.md b/e2e/README.md index bc8da45..bbd587a 100644 --- a/e2e/README.md +++ b/e2e/README.md @@ -98,4 +98,3 @@ When adding new tests: 2. Use `callTool()` helper for making tool calls 3. Use `unmarshalToolResponse()` for parsing responses 4. For tests that create resources, ensure proper cleanup in `t.Cleanup()` -5. Consider using the native API client (`getAPIClient()`) for verification diff --git a/e2e/e2e_test.go b/e2e/e2e_test.go index 8bebdc2..eac2c70 100644 --- a/e2e/e2e_test.go +++ b/e2e/e2e_test.go @@ -13,7 +13,6 @@ import ( "testing" "time" - sdk "github.com/flashcatcloud/flashduty-sdk" mcpClient "github.com/mark3labs/mcp-go/client" "github.com/mark3labs/mcp-go/mcp" "github.com/stretchr/testify/require" @@ -57,23 +56,6 @@ func getE2EBaseURL() string { return baseURL } -// getAPIClient creates a native Flashduty SDK client for verification purposes -func getAPIClient(t *testing.T) *sdk.Client { - appKey := getE2EAppKey(t) - baseURL := getE2EBaseURL() - - opts := []sdk.Option{ - sdk.WithUserAgent("e2e-test-client/1.0.0"), - } - if baseURL != "" { - opts = append(opts, sdk.WithBaseURL(baseURL)) - } - client, err := sdk.NewClient(appKey, opts...) - require.NoError(t, err, "expected to create Flashduty SDK client") - - return client -} - // ensureDockerImageBuilt makes sure the Docker image is built only once across all tests func ensureDockerImageBuilt(t *testing.T) { buildOnce.Do(func() { diff --git a/go.mod b/go.mod index d1b3629..4469f05 100644 --- a/go.mod +++ b/go.mod @@ -4,8 +4,7 @@ go 1.25.5 require ( github.com/bluele/gcache v0.0.2 - github.com/flashcatcloud/flashduty-sdk v0.8.1-0.20260510070250-0340ac6a5a33 - github.com/flashcatcloud/go-flashduty v0.4.0 + github.com/flashcatcloud/go-flashduty v0.5.1 github.com/google/go-github/v72 v72.0.0 github.com/josephburnett/jd v1.9.2 github.com/mark3labs/mcp-go v0.52.0 @@ -27,7 +26,6 @@ require ( github.com/yosida95/uritemplate/v3 v3.0.2 // indirect github.com/yudai/golcs v0.0.0-20170316035057-ecda9a501e82 // indirect golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 // indirect - golang.org/x/sync v0.19.0 // indirect gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect gopkg.in/yaml.v2 v2.4.0 // indirect ) diff --git a/go.sum b/go.sum index 4cacfca..f084e0c 100644 --- a/go.sum +++ b/go.sum @@ -8,10 +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.20260510070250-0340ac6a5a33 h1:HnB++VulEnF+oIsNwKK8QBv4CCdG+ztdFKLScI4bXlc= -github.com/flashcatcloud/flashduty-sdk v0.8.1-0.20260510070250-0340ac6a5a33/go.mod h1:dG4eJfdZaj4jNBMwEexbfK/3PmcIMhNeJ88L/DcZzUY= -github.com/flashcatcloud/go-flashduty v0.4.0 h1:J+gUJB3TrRWFT2Wy3u0YeYznqFwh/5UhKTc8aZV9rEs= -github.com/flashcatcloud/go-flashduty v0.4.0/go.mod h1:aA0RtZEs0AYOwwdNKdtVeD8YMOdnmVY1zAlVD+9Ovx8= +github.com/flashcatcloud/go-flashduty v0.5.1 h1:bLPRnTKdZOT+IPtJFHRS36TPLftLDBCUZqxtGSNg9ys= +github.com/flashcatcloud/go-flashduty v0.5.1/go.mod h1:aA0RtZEs0AYOwwdNKdtVeD8YMOdnmVY1zAlVD+9Ovx8= 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= @@ -98,8 +96,6 @@ go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN8 go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 h1:2dVuKD2vS7b0QIHQbpyTISPd0LeHDbnYEryqj5Q1ug8= golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56/go.mod h1:M4RDyNAINzryxdtnbRXRL/OHtkFuWGRjvuhBJpk2IlY= -golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= -golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik= golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= golang.org/x/text v0.26.0 h1:P42AVeLghgTYr4+xUnTRKDMqpar+PtX7KWuNQL21L8M= diff --git a/internal/flashduty/context.go b/internal/flashduty/context.go index 97d1f30..3aa65af 100644 --- a/internal/flashduty/context.go +++ b/internal/flashduty/context.go @@ -9,8 +9,6 @@ import ( "github.com/bluele/gcache" goflashduty "github.com/flashcatcloud/go-flashduty" - sdk "github.com/flashcatcloud/flashduty-sdk" - "github.com/flashcatcloud/flashduty-mcp-server/pkg/flashduty" "github.com/flashcatcloud/flashduty-mcp-server/pkg/trace" ) @@ -49,11 +47,9 @@ var clientCache = gcache.New(1000). Build() // getClient is a helper for tool handlers to obtain the Flashduty clients. It -// tries the context first; on a miss it builds both the typed go-flashduty -// client (used by every migrated tool) and the legacy flashduty-sdk client -// (kept only for the not-yet-covered endpoints), caches the pair, and stores it -// on the context for reuse within the same request. It falls back to the -// default config when the context carries none. +// tries the context first; on a miss it builds the typed go-flashduty client, +// caches it, and stores it on the context for reuse within the same request. It +// falls back to the default config when the context carries none. func getClient(ctx context.Context, defaultCfg FlashdutyConfig, version string) (context.Context, *flashduty.Clients, error) { if clients, ok := clientsFromContext(ctx); ok { return ctx, clients, nil @@ -83,7 +79,6 @@ func getClient(ctx context.Context, defaultCfg FlashdutyConfig, version string) } } - // Primary client: typed go-flashduty. newOpts := []goflashduty.Option{ goflashduty.WithUserAgent(userAgent), goflashduty.WithRequestHook(requestHook), @@ -96,20 +91,7 @@ func getClient(ctx context.Context, defaultCfg FlashdutyConfig, version string) return ctx, nil, fmt.Errorf("failed to create go-flashduty client: %w", err) } - // Legacy client: only the not-yet-covered endpoints use it. - legacyOpts := []sdk.Option{ - sdk.WithUserAgent(userAgent), - sdk.WithRequestHook(requestHook), - } - if cfg.BaseURL != "" { - legacyOpts = append(legacyOpts, sdk.WithBaseURL(cfg.BaseURL)) - } - legacyClient, err := sdk.NewClient(cfg.APPKey, legacyOpts...) - if err != nil { - return ctx, nil, fmt.Errorf("failed to create legacy flashduty client: %w", err) - } - - clients := &flashduty.Clients{New: newClient, Legacy: legacyClient} + clients := &flashduty.Clients{New: newClient} _ = clientCache.Set(cacheKey, clients) ctx = contextWithClients(ctx, clients) diff --git a/internal/flashduty/server.go b/internal/flashduty/server.go index 615bad7..aecc013 100644 --- a/internal/flashduty/server.go +++ b/internal/flashduty/server.go @@ -78,7 +78,6 @@ func NewMCPServer(cfg FlashdutyConfig) (*server.MCPServer, error) { message.Params.ClientInfo.Version, ) clients.New.UserAgent = userAgent - clients.Legacy.SetUserAgent(userAgent) } if len(cfg.EnabledToolsets) == 0 { diff --git a/pkg/flashduty/client.go b/pkg/flashduty/client.go index a8b4624..1ba0739 100644 --- a/pkg/flashduty/client.go +++ b/pkg/flashduty/client.go @@ -4,22 +4,13 @@ import ( "context" flashduty "github.com/flashcatcloud/go-flashduty" - - sdk "github.com/flashcatcloud/flashduty-sdk" ) -// Clients bundles the two Flashduty API clients a tool handler may need. -// -// New is the typed go-flashduty client and backs every migrated tool. Legacy -// is the hand-written flashduty-sdk client, kept only for the handful of tools -// whose endpoints go-flashduty does not cover yet (query_changes, -// validate_template, query_status_pages). +// Clients bundles the Flashduty API clients a tool handler may need. // -// TODO: drop Legacy and the flashduty-sdk dependency once go-flashduty covers -// /change/list, /template/preview, and /status-page/list. +// New is the typed go-flashduty client and backs every tool. type Clients struct { - New *flashduty.Client - Legacy *sdk.Client + New *flashduty.Client } // GetFlashdutyClientFn returns the Flashduty clients for the current request. diff --git a/pkg/flashduty/format.go b/pkg/flashduty/format.go index 75877e9..e727142 100644 --- a/pkg/flashduty/format.go +++ b/pkg/flashduty/format.go @@ -65,17 +65,6 @@ func MarshalResult(v any) *mcp.CallToolResult { return marshalResultWithFormat(v, outputFormat) } -// MarshalLegacyResult is MarshalResult for the few tools still backed by the -// hand-written flashduty-sdk (query_changes, validate_template, -// query_status_pages). Those return bare Unix-integer time fields, so this -// variant runs humanizeTimestamps to render them as RFC3339 before encoding. -// -// TODO: delete once go-flashduty covers /change/list, /template/preview, and -// /status-page/list and the pending tools migrate to the typed Timestamp. -func MarshalLegacyResult(v any) *mcp.CallToolResult { - return marshalResultWithFormat(humanizeTimestamps(v), outputFormat) -} - func marshalResultWithFormat(v any, format OutputFormat) *mcp.CallToolResult { data, err := marshal(v, format) if err != nil { diff --git a/pkg/flashduty/incidents.go b/pkg/flashduty/incidents.go index 0897b7b..277f432 100644 --- a/pkg/flashduty/incidents.go +++ b/pkg/flashduty/incidents.go @@ -6,7 +6,6 @@ import ( "fmt" "strings" - sdk "github.com/flashcatcloud/flashduty-sdk" flashduty "github.com/flashcatcloud/go-flashduty" "github.com/mark3labs/mcp-go/mcp" "github.com/mark3labs/mcp-go/server" @@ -396,25 +395,17 @@ func UpdateIncident(getClient GetFlashdutyClientFn, t translations.TranslationHe } // Custom fields are one /incident/field/reset call each. The backend - // accepts an arbitrary JSON value for field_value, but go-flashduty's - // ResetIncidentFieldRequest.FieldValue is generated as map[string]any, - // which cannot carry scalar values (the common case). Until that type - // is fixed upstream, route custom-field writes through the legacy SDK, - // which sends the raw value as the API expects. Pass ONLY the custom - // fields so the legacy call skips /incident/reset (built-ins already - // went through the typed client above) and only hits - // /incident/field/reset per field. - // TODO: switch to client.New.Incidents.FieldReset once go-flashduty - // types field_value as `any`. - if len(customFields) > 0 { - customNames, err := client.Legacy.UpdateIncident(ctx, &sdk.UpdateIncidentInput{ - IncidentID: incidentID, - CustomFields: customFields, - }) - if err != nil { + // accepts an arbitrary JSON value for field_value, sent as the raw + // value the API expects. + for name, value := range customFields { + if _, err := client.New.Incidents.FieldReset(ctx, &flashduty.ResetIncidentFieldRequest{ + IncidentID: incidentID, + FieldName: name, + FieldValue: value, + }); err != nil { return mcp.NewToolResultError(fmt.Sprintf("Unable to update custom fields: %v", err)), nil } - updatedFields = append(updatedFields, customNames...) + updatedFields = append(updatedFields, name) } if len(updatedFields) == 0 { @@ -535,7 +526,7 @@ func ListSimilarIncidents(getClient GetFlashdutyClientFn, t translations.Transla out, _, err := client.New.Incidents.PastList(ctx, &flashduty.ListPastIncidentsRequest{ IncidentID: incidentID, - Limit: int64(limit), + Limit: flashduty.Int64(int64(limit)), }) if err != nil { return mcp.NewToolResultError(fmt.Sprintf("Unable to find similar incidents: %v", err)), nil diff --git a/pkg/flashduty/statuspage.go b/pkg/flashduty/statuspage.go index 0f24f68..8ed40e3 100644 --- a/pkg/flashduty/statuspage.go +++ b/pkg/flashduty/statuspage.go @@ -7,7 +7,6 @@ import ( "strings" "time" - sdk "github.com/flashcatcloud/flashduty-sdk" flashduty "github.com/flashcatcloud/go-flashduty" "github.com/mark3labs/mcp-go/mcp" "github.com/mark3labs/mcp-go/server" @@ -99,24 +98,23 @@ func ListStatusChanges(getClient GetFlashdutyClientFn, t translations.Translatio return mcp.NewToolResultError("type must be 'incident' or 'maintenance'"), nil } - // This tool lists *active* changes via /status-page/change/active/list, - // which go-flashduty does not expose (its ChangeList hits the general - // /status-page/change/list, which requires a single mandatory status - // and a time window — different semantics). Keep the legacy SDK until - // go-flashduty adds the active-list endpoint. - // TODO: 待 go-flashduty 覆盖 /status-page/change/active/list 后切换并删除老 SDK 依赖。 - output, err := client.Legacy.ListStatusChanges(ctx, &sdk.ListStatusChangesInput{ - PageID: int64(pageID), - ChangeType: changeType, + // Lists *active* (in-progress, non-terminal) changes via + // /status-page/change/active/list. The endpoint returns every active + // event of the requested type in one shot — no pagination and no + // list-level total — so the count is simply len(resp.Items). + resp, _, err := client.New.StatusPages.ChangeActiveList(ctx, &flashduty.StatusPagesChangeActiveListRequest{ + PageID: int64(pageID), + Type: changeType, }) if err != nil { return mcp.NewToolResultError(fmt.Sprintf("failed to list status changes: %v", err)), nil } - return MarshalLegacyResult(addTruncationHint(map[string]any{ - "changes": output.Changes, - "total": output.Total, - }, len(output.Changes), output.Total)), nil + total := len(resp.Items) + return MarshalResult(addTruncationHint(map[string]any{ + "changes": resp.Items, + "total": total, + }, total, total)), nil } } diff --git a/pkg/flashduty/templatemeta.go b/pkg/flashduty/templatemeta.go new file mode 100644 index 0000000..122bfda --- /dev/null +++ b/pkg/flashduty/templatemeta.go @@ -0,0 +1,200 @@ +package flashduty + +import "slices" + +// Notification-template authoring reference data. +// +// This catalog (channels, size limits, variables, functions) describes the +// Flashduty template engine's capabilities. It is client-side reference data, +// not an API surface, so the generated go-flashduty SDK does not carry it — the +// MCP server owns it directly. Platform-side additions require a server release. +// +// The static data below is vendored verbatim from the legacy flashduty-sdk +// (templates.go / types.go); values, struct field json/toon tags, and the +// channel/limit/variable/function catalogs are preserved exactly. + +// templateChannels maps channel identifiers to TemplateItem field names. +var templateChannels = map[string]string{ + "dingtalk": "dingtalk", + "dingtalk_app": "dingtalk_app", + "feishu": "feishu", + "feishu_app": "feishu_app", + "wecom": "wecom", + "wecom_app": "wecom_app", + "slack": "slack", + "slack_app": "slack_app", + "telegram": "telegram", + "teams_app": "teams_app", + "email": "email", + "sms": "sms", + "zoom": "zoom", +} + +// channelSizeLimits defines the maximum rendered size per channel. +// 0 means no enforced limit. +var channelSizeLimits = map[string]int{ + "dingtalk": 4000, + "dingtalk_app": 0, + "feishu": 4000, + "feishu_app": 0, + "wecom": 4000, + "wecom_app": 0, + "slack": 15000, + "slack_app": 15000, + "telegram": 4096, + "teams_app": 28000, + "email": 0, + "sms": 0, + "zoom": 0, +} + +// channelEnumValues returns all valid notification channel identifiers, sorted. +func channelEnumValues() []string { + channels := make([]string, 0, len(templateChannels)) + for k := range templateChannels { + channels = append(channels, k) + } + slices.Sort(channels) + return channels +} + +// templateVariable describes a variable available in notification templates. +type templateVariable struct { + Name string `json:"name" toon:"name"` + Type string `json:"type" toon:"type"` + Description string `json:"description" toon:"description"` + Example string `json:"example,omitempty" toon:"example,omitempty"` + Category string `json:"category" toon:"category"` +} + +// templateFunction describes a function available in notification templates. +type templateFunction struct { + Name string `json:"name" toon:"name"` + Syntax string `json:"syntax" toon:"syntax"` + Description string `json:"description" toon:"description"` +} + +// templateVariables returns a copy of the available template variables. +func templateVariables() []templateVariable { + result := make([]templateVariable, len(templateVariableCatalog)) + copy(result, templateVariableCatalog) + return result +} + +// templateCustomFunctions returns a copy of the custom Flashduty template functions. +func templateCustomFunctions() []templateFunction { + result := make([]templateFunction, len(templateCustomFunctionCatalog)) + copy(result, templateCustomFunctionCatalog) + return result +} + +// templateSprigFunctions returns a copy of the commonly used Sprig template functions. +func templateSprigFunctions() []templateFunction { + result := make([]templateFunction, len(templateSprigFunctionCatalog)) + copy(result, templateSprigFunctionCatalog) + return result +} + +// --- Static Data --- + +var templateVariableCatalog = []templateVariable{ + // Core fields + {".Title", "string", "Incident title", "Order Message Failed", "core"}, + {".Description", "string", "Incident description", "Send order message failed too many times", "core"}, + {".Num", "string", "Short incident number", "ABC123", "core"}, + {".ID", "string", "Incident ID", "6321aad26c12104586a88916", "core"}, + {".IncidentSeverity", "string", "Severity level: Critical, Warning, Info, Ok", "Critical", "core"}, + {".IncidentStatus", "string", "Status code: Critical, Warning, Info, Ok", "Critical", "core"}, + {".Progress", "string", "Handling progress: Triggered, Processing, Closed", "Triggered", "core"}, + {".DetailUrl", "string", "Link to incident detail page", "https://console.flashcat.com/incident/detail/...", "core"}, + + // Time fields + {".StartTime", "int64", "Unix timestamp - incident start", "", "time"}, + {".LastTime", "int64", "Unix timestamp - last update", "", "time"}, + {".AckTime", "int64", "Unix timestamp - acknowledgement (0 if not acked)", "", "time"}, + {".CloseTime", "int64", "Unix timestamp - closure (0 if not closed)", "", "time"}, + {".SnoozedBefore", "int64", "Unix timestamp - snooze expiry", "", "time"}, + + // People fields + {".Creator", "*PersonItem", "Incident creator: {PersonID, PersonName, Email}", "", "people"}, + {".Closer", "*PersonItem", "Person who closed the incident", "", "people"}, + {".Owner", "*PersonItem", "Current incident owner", "", "people"}, + {".Responders", "[]*Responder", "List of responders: {PersonID, PersonName, Email, AssignedAt, AcknowledgedAt}", "", "people"}, + {".AssignedTo", "*AssignedTo", "Assignment info: {EscalateRuleID, EscalateRuleName, LayerIdx, Type}", "", "people"}, + + // Alert aggregation + {".AlertCnt", "int64", "Total associated alerts count", "10", "alerts"}, + {".ActiveAlertCnt", "int64", "Active (non-resolved) alerts count", "9", "alerts"}, + {".AlertEventCnt", "int64", "Total alert events count", "30", "alerts"}, + {".Alerts", "[]*AlertItem", "Alert list: {Title, Description, AlertSeverity, AlertStatus, StartTime, LastTime, EndTime, Labels}", "", "alerts"}, + + // Labels and custom data + {".Labels", "map[string]string", "Alert label key-value pairs. Access via .Labels.key or index .Labels \"dotted.key\"", "", "labels"}, + {".Fields", "map[string]interface{}", "Custom incident fields", "", "labels"}, + {".Images", "[]Image", "Associated images: {Src, Alt}", "", "labels"}, + + // Context fields + {".ChannelName", "string", "Collaboration space name", "Order system", "context"}, + {".ChannelID", "int64", "Collaboration space ID", "", "context"}, + {".AccountName", "string", "Account/organization name", "Flashduty", "context"}, + {".AccountLocale", "string", "Locale: zh-CN or en-US", "zh-CN", "context"}, + {".AccountTimeZone", "string", "Account timezone", "", "context"}, + + // Notification fields + {".FireType", "string", "Notification type: fire (initial) or refire (recurring)", "fire", "notification"}, + {".FireTimes", "int64", "Number of times notified", "", "notification"}, + {".IsFlapping", "bool", "Whether in flapping state", "true", "notification"}, + {".IsInStorm", "bool", "Whether in alert storm", "false", "notification"}, + {".Flapping", "*Flapping", "Flapping config: {MaxChanges, InMinutes, MuteMinutes}", "", "notification"}, + {".GroupMethod", "string", "Grouping method: n (none), p (by rule), i (intelligent)", "i", "notification"}, + + // Post-incident fields + {".Impact", "string", "Impact description", "", "post_incident"}, + {".RootCause", "string", "Root cause", "", "post_incident"}, + {".Resolution", "string", "Resolution description", "", "post_incident"}, + {".AISummary", "string", "AI-generated incident summary", "", "post_incident"}, +} + +var templateCustomFunctionCatalog = []templateFunction{ + {"date", `{{date "2006-01-02 15:04:05" .StartTime}}`, "Format Unix timestamp using Go time layout"}, + {"ago", `{{ago .StartTime}}`, "Human-readable duration since timestamp (e.g., '2 hours ago')"}, + {"toHtml", `{{toHtml .Title}}`, "HTML-escape special characters; accepts multiple args, uses first non-empty"}, + {"fireReason", `{{fireReason .}}`, "Returns notification type prefix: [REFIRE], [ESCALATE], etc."}, + {"colorSeverity", `{{colorSeverity .IncidentSeverity}}`, "Severity with markup for chat platforms"}, + {"colorBySeverity", `{{colorBySeverity .IncidentSeverity "text"}}`, "Color any text using severity-based color"}, + {"serverityToColor", `{{serverityToColor .IncidentSeverity}}`, "Returns hex color: #C80000 (Critical), #FA7D00 (Warning), #FABE00 (Info), #008800 (Ok)"}, + {"toSeverity", `{{toSeverity .IncidentSeverity}}`, "Severity to localized display string"}, + {"joinAlertLabels", `{{joinAlertLabels . "resource" ", "}}`, "Deduplicate and join a label's values from all alerts"}, + {"alertLabels", `{{alertLabels . "resource"}}`, "Return deduplicated label values as array"}, + {"maxAlertLabel", `{{maxAlertLabel . "trigger_value"}}`, "Max value of a label across alerts"}, + {"minAlertLabel", `{{minAlertLabel . "trigger_value"}}`, "Min value of a label across alerts"}, + {"in", `{{in $k "resource" "body_text"}}`, "Check if value is in a set of values"}, + {"mdToHtml", `{{mdToHtml .Description}}`, "Convert Markdown to sanitized HTML"}, + {"transferImage", `{{transferImage $root $v.Src}}`, "Upload image to Feishu (Feishu App only)"}, + {"imageSrcToURL", `{{imageSrcToURL $root $v.Src}}`, "Convert image key to accessible URL (DingTalk, Slack)"}, + {"imageAltToURL", `{{imageAltToURL $root $v.Alt}}`, "Get image URL by alt text"}, + {"jsonGet", `{{jsonGet .Labels.rule_note "detail_url"}}`, "Parse JSON string and extract via gjson path syntax"}, + {"index", `{{index .Labels "dotted.key"}}`, "Access map keys containing dots"}, +} + +var templateSprigFunctionCatalog = []templateFunction{ + {"trim", `{{trim .Title}}`, "Remove leading/trailing whitespace"}, + {"upper", `{{upper .IncidentSeverity}}`, "Convert to uppercase"}, + {"lower", `{{lower .IncidentSeverity}}`, "Convert to lowercase"}, + {"replace", `{{replace "old" "new" .Title}}`, "Replace all occurrences"}, + {"contains", `{{contains "error" .Title}}`, "Check if string contains substring"}, + {"default", `{{default "N/A" .Description}}`, "Return default value if empty"}, + {"ternary", `{{ternary "yes" "no" .IsFlapping}}`, "Ternary operator"}, + {"add", `{{add .AlertCnt 1}}`, "Add numbers"}, + {"sub", `{{sub .AlertCnt 1}}`, "Subtract numbers"}, + {"len", `{{len .Responders}}`, "Length of list/map/string"}, + {"list", `{{list "a" "b" "c"}}`, "Create a list"}, + {"dict", `{{dict "key" "value"}}`, "Create a dictionary"}, + {"hasKey", `{{hasKey .Labels "resource"}}`, "Check if map has key"}, + {"keys", `{{keys .Labels}}`, "Get map keys"}, + {"values", `{{values .Labels}}`, "Get map values"}, + {"empty", `{{empty .Description}}`, "Check if value is empty/zero"}, + {"coalesce", `{{coalesce .Description "No description"}}`, "Return first non-empty value"}, + {"toString", `{{toString .AlertCnt}}`, "Convert to string"}, + {"toInt64", `{{toInt64 "123"}}`, "Convert to int64"}, +} diff --git a/pkg/flashduty/templates.go b/pkg/flashduty/templates.go index 20cbd79..65aaa90 100644 --- a/pkg/flashduty/templates.go +++ b/pkg/flashduty/templates.go @@ -4,9 +4,7 @@ import ( "context" "encoding/json" "fmt" - "slices" - sdk "github.com/flashcatcloud/flashduty-sdk" flashduty "github.com/flashcatcloud/go-flashduty" "github.com/mark3labs/mcp-go/mcp" "github.com/mark3labs/mcp-go/server" @@ -23,9 +21,8 @@ const presetTemplateID = "000000000000000000000001" const getPresetTemplateDescription = `Fetch the preset (default) notification template for a specific channel. Returns the Go template code used as the starting point for customization.` func sortedChannelEnumValues() []string { - channels := append([]string(nil), sdk.ChannelEnumValues()...) - slices.Sort(channels) - return channels + // channelEnumValues already returns a sorted, freshly-allocated slice. + return channelEnumValues() } // GetPresetTemplate creates a tool to fetch the preset template for a channel. @@ -52,7 +49,7 @@ func GetPresetTemplate(getClient GetFlashdutyClientFn, t translations.Translatio return mcp.NewToolResultError(err.Error()), nil } - fieldName, ok := sdk.TemplateChannels[channel] + fieldName, ok := templateChannels[channel] if !ok { return mcp.NewToolResultError(fmt.Sprintf("unknown channel: %s", channel)), nil } @@ -138,7 +135,7 @@ func ValidateTemplate(getClient GetFlashdutyClientFn, t translations.Translation incidentID, _ := OptionalParam[string](request, "incident_id") - fieldName, ok := sdk.TemplateChannels[channel] + fieldName, ok := templateChannels[channel] if !ok { return mcp.NewToolResultError(fmt.Sprintf("unknown channel: %s", channel)), nil } @@ -160,7 +157,7 @@ func ValidateTemplate(getClient GetFlashdutyClientFn, t translations.Translation // output shape stays identical post-migration. renderedPreview := out.Content renderedSize := len(renderedPreview) - sizeLimit := sdk.ChannelSizeLimits[channel] + sizeLimit := channelSizeLimits[channel] errs := []string{} warnings := []string{} @@ -209,7 +206,7 @@ func ListTemplateVariables(_ GetFlashdutyClientFn, t translations.TranslationHel ReadOnlyHint: ToBoolPtr(true), }), ), func(_ context.Context, _ mcp.CallToolRequest) (*mcp.CallToolResult, error) { - variables := sdk.TemplateVariables() + variables := templateVariables() return MarshalResult(map[string]any{ "variables": variables, "total": len(variables), @@ -231,8 +228,8 @@ func ListTemplateFunctions(_ GetFlashdutyClientFn, t translations.TranslationHel }), ), func(_ context.Context, _ mcp.CallToolRequest) (*mcp.CallToolResult, error) { return MarshalResult(map[string]any{ - "custom_functions": sdk.TemplateCustomFunctions(), - "sprig_functions": sdk.TemplateSprigFunctions(), + "custom_functions": templateCustomFunctions(), + "sprig_functions": templateSprigFunctions(), }), nil } } diff --git a/pkg/flashduty/timestamps.go b/pkg/flashduty/timestamps.go deleted file mode 100644 index 41bd3aa..0000000 --- a/pkg/flashduty/timestamps.go +++ /dev/null @@ -1,94 +0,0 @@ -package flashduty - -import ( - "bytes" - "encoding/json" - "strings" - "time" -) - -// humanizeTimestamps returns a copy of v with Unix-timestamp fields rendered as -// RFC3339 strings in the local timezone, leaving everything else untouched. -// -// Flashduty's API returns time fields as bare Unix integers, which is opaque to -// an LLM reading tool output. RFC3339 is unambiguous, sortable, and the format -// models are most fluent in. The local timezone is the process timezone (the -// sandbox/environment timezone when the server runs inside an agent sandbox). -// -// Detection is by JSON field name: a field ending in "_time" or "_at", or named -// exactly "timestamp", whose value is an integer large enough to be a real -// timestamp (>= 1e9 seconds, i.e. year 2001+). Millisecond values (>= 1e12) are -// detected by magnitude. ID-like fields (*_by, *_id, *_ids) are never touched. -// -// v is round-tripped through JSON into a generic structure so the same walk -// handles both typed SDK structs and the map[string]any payloads tools build by -// hand. On any marshal/decode error it returns v unchanged — humanization is -// best-effort and never blocks output. -func humanizeTimestamps(v any) any { - b, err := json.Marshal(v) - if err != nil { - return v - } - dec := json.NewDecoder(bytes.NewReader(b)) - dec.UseNumber() - var generic any - if err := dec.Decode(&generic); err != nil { - return v - } - return humanizeWalk(generic, "") -} - -func humanizeWalk(v any, key string) any { - switch val := v.(type) { - case map[string]any: - for k, child := range val { - val[k] = humanizeWalk(child, k) - } - return val - case []any: - for i, child := range val { - val[i] = humanizeWalk(child, key) - } - return val - case json.Number: - if isTimestampField(key) { - if s, ok := renderTimestamp(val); ok { - return s - } - } - return val - default: - return val - } -} - -// isTimestampField reports whether a JSON field name denotes an absolute time. -// ID-like suffixes are excluded first so e.g. "timeline_id" / "updated_by" -// never match. -func isTimestampField(key string) bool { - k := strings.ToLower(key) - if strings.HasSuffix(k, "_id") || strings.HasSuffix(k, "_ids") || strings.HasSuffix(k, "_by") { - return false - } - return k == "timestamp" || strings.HasSuffix(k, "_time") || strings.HasSuffix(k, "_at") -} - -// renderTimestamp converts a numeric Unix timestamp to RFC3339 in local time. -// Values below 1e9 are treated as durations/counts, not absolute timestamps, -// and left unconverted; values at/above 1e12 are interpreted as milliseconds. -func renderTimestamp(n json.Number) (string, bool) { - i, err := n.Int64() - if err != nil { - return "", false - } - var t time.Time - switch { - case i >= 1e12: - t = time.UnixMilli(i) - case i >= 1e9: - t = time.Unix(i, 0) - default: - return "", false - } - return t.In(time.Local).Format(time.RFC3339), true -} diff --git a/pkg/flashduty/timestamps_test.go b/pkg/flashduty/timestamps_test.go deleted file mode 100644 index 3d2fef0..0000000 --- a/pkg/flashduty/timestamps_test.go +++ /dev/null @@ -1,124 +0,0 @@ -package flashduty - -import ( - "strings" - "testing" - "time" - - "github.com/mark3labs/mcp-go/mcp" -) - -// TestMarshalLegacyResult_HumanizesTimestamps locks the wiring for the -// legacy/pending tools: results routed through MarshalLegacyResult must have -// their raw Unix-integer timestamps humanized, so a bare epoch never reaches -// the model. (Migrated tools use go-flashduty's Timestamp type, which already -// renders RFC3339, and go through MarshalResult without this post-processing.) -func TestMarshalLegacyResult_HumanizesTimestamps(t *testing.T) { - const ts = 1748419200 - res := MarshalLegacyResult(map[string]any{"start_time": ts}) - tc, ok := mcp.AsTextContent(res.Content[0]) - if !ok { - t.Fatalf("expected text content, got %#v", res.Content[0]) - } - if strings.Contains(tc.Text, "1748419200") { - t.Fatalf("raw unix timestamp leaked into tool result: %s", tc.Text) - } - if !strings.Contains(tc.Text, "start_time") { - t.Fatalf("expected start_time key in result: %s", tc.Text) - } -} - -func tsInstant(t *testing.T, v any) int64 { - t.Helper() - s, ok := v.(string) - if !ok { - t.Fatalf("expected RFC3339 string, got %T (%v)", v, v) - } - parsed, err := time.Parse(time.RFC3339, s) - if err != nil { - t.Fatalf("value %q is not RFC3339: %v", s, err) - } - return parsed.Unix() -} - -func TestHumanizeTimestamps_ConvertsSecondsAndMillis(t *testing.T) { - const sec = 1748419200 - m := humanizeTimestamps(map[string]any{ - "start_time": sec, - "created_at": int64(sec) * 1000, - }).(map[string]any) - if inst := tsInstant(t, m["start_time"]); inst != sec { - t.Fatalf("start_time instant = %d, want %d", inst, sec) - } - if inst := tsInstant(t, m["created_at"]); inst != sec { - t.Fatalf("created_at instant = %d, want %d", inst, sec) - } -} - -func TestHumanizeTimestamps_DetectsByFieldName(t *testing.T) { - const ts = 1748419200 - in := map[string]any{ - "ack_time": ts, "close_time": ts, "assigned_at": ts, - "acknowledged_at": ts, "timestamp": ts, "end_time": ts, "trigger_time": ts, - } - m := humanizeTimestamps(in).(map[string]any) - for k := range in { - if inst := tsInstant(t, m[k]); inst != ts { - t.Fatalf("%s instant = %d, want %d", k, inst, ts) - } - } -} - -func TestHumanizeTimestamps_LeavesIDAndDurationFields(t *testing.T) { - in := map[string]any{ - // Large values that WOULD convert by magnitude — proves the field-name - // exclusion (not just the magnitude guard) is what keeps IDs numeric. - "updated_by": int64(1748419200), - "timeline_id": int64(1748419200), - "channel_ids": []any{int64(1748419200)}, - "snooze_time": int64(300), // small => duration, not a 1970 date - "ack_time": 0, // zero => not a timestamp - } - m := humanizeTimestamps(in).(map[string]any) - for k := range in { - if _, isStr := m[k].(string); isStr { - t.Fatalf("%s must not be converted to a date string", k) - } - } -} - -func TestHumanizeTimestamps_RecursesNestedAndSlices(t *testing.T) { - const ts = 1748419200 - in := map[string]any{ - "incidents": []any{ - map[string]any{"start_time": ts, "labels": map[string]any{"close_time": ts}}, - }, - } - m := humanizeTimestamps(in).(map[string]any) - inc := m["incidents"].([]any)[0].(map[string]any) - if inst := tsInstant(t, inc["start_time"]); inst != ts { - t.Fatalf("nested start_time instant = %d, want %d", inst, ts) - } - if inst := tsInstant(t, inc["labels"].(map[string]any)["close_time"]); inst != ts { - t.Fatalf("deeply nested close_time instant = %d, want %d", inst, ts) - } -} - -func TestHumanizeTimestamps_ConvertsTypedStruct(t *testing.T) { - type incident struct { - Title string `json:"title"` - StartTime int64 `json:"start_time"` - UpdatedBy int64 `json:"updated_by"` - } - const ts = 1748419200 - m := humanizeTimestamps(incident{Title: "db down", StartTime: ts, UpdatedBy: 7}).(map[string]any) - if inst := tsInstant(t, m["start_time"]); inst != ts { - t.Fatalf("struct start_time instant = %d, want %d", inst, ts) - } - if _, isStr := m["updated_by"].(string); isStr { - t.Fatalf("struct updated_by must remain numeric") - } - if m["title"] != "db down" { - t.Fatalf("title = %v, want \"db down\"", m["title"]) - } -} From 0e11e76f85012e13597d87b69f77e41378310bf4 Mon Sep 17 00:00:00 2001 From: ysyneu Date: Sun, 31 May 2026 19:51:28 +0800 Subject: [PATCH 5/5] fix(mcp): pin go-flashduty v0.5.2 + assert migrated timeline shape - go-flashduty v0.5.2: named string enums implement fmt.Stringer, fixing --output-format toon for timeline/feed event Type fields. - query_incident_timeline now returns the raw IncidentFeedItem shape (created_at RFC3339, numeric creator_id) instead of the legacy enriched shape. Update the e2e test to assert this contract (it previously declared the old {timestamp int64} shape and only checked non-empty, masking the change) and document creator_id resolution via query_members in the tool description. --- e2e/e2e_test.go | 15 +++++++++++++-- go.mod | 2 +- go.sum | 4 ++-- pkg/flashduty/incidents.go | 2 +- 4 files changed, 17 insertions(+), 6 deletions(-) diff --git a/e2e/e2e_test.go b/e2e/e2e_test.go index eac2c70..b547425 100644 --- a/e2e/e2e_test.go +++ b/e2e/e2e_test.go @@ -707,7 +707,8 @@ func TestIncidentQueryByTimeline(t *testing.T) { IncidentID string `json:"incident_id"` Timeline []struct { Type string `json:"type"` - Timestamp int64 `json:"timestamp"` + CreatedAt string `json:"created_at"` + CreatorID int64 `json:"creator_id"` Detail any `json:"detail,omitempty"` } `json:"timeline"` Total int `json:"total"` @@ -720,7 +721,8 @@ func TestIncidentQueryByTimeline(t *testing.T) { var timeline []struct { Type string `json:"type"` - Timestamp int64 `json:"timestamp"` + CreatedAt string `json:"created_at"` + CreatorID int64 `json:"creator_id"` Detail any `json:"detail,omitempty"` } for _, r := range timelineResult.Results { @@ -731,6 +733,15 @@ func TestIncidentQueryByTimeline(t *testing.T) { } require.NotEmpty(t, timeline, "expected at least one timeline event") + // Assert the migrated shape (raw go-flashduty IncidentFeedItem): each event + // carries created_at as an RFC3339 string (TimestampMilli), not a raw epoch + // int, and a numeric creator_id. This pins the post-migration contract so a + // future shape drift cannot pass silently. + for _, event := range timeline { + require.NotEmpty(t, event.CreatedAt, "timeline event missing created_at") + require.Contains(t, event.CreatedAt, "T", "created_at must be RFC3339, got %q", event.CreatedAt) + } + t.Logf("Found %d timeline events", len(timeline)) // Verify the first event is the creation event diff --git a/go.mod b/go.mod index 4469f05..a0a1706 100644 --- a/go.mod +++ b/go.mod @@ -4,7 +4,7 @@ go 1.25.5 require ( github.com/bluele/gcache v0.0.2 - github.com/flashcatcloud/go-flashduty v0.5.1 + github.com/flashcatcloud/go-flashduty v0.5.2 github.com/google/go-github/v72 v72.0.0 github.com/josephburnett/jd v1.9.2 github.com/mark3labs/mcp-go v0.52.0 diff --git a/go.sum b/go.sum index f084e0c..453e404 100644 --- a/go.sum +++ b/go.sum @@ -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/go-flashduty v0.5.1 h1:bLPRnTKdZOT+IPtJFHRS36TPLftLDBCUZqxtGSNg9ys= -github.com/flashcatcloud/go-flashduty v0.5.1/go.mod h1:aA0RtZEs0AYOwwdNKdtVeD8YMOdnmVY1zAlVD+9Ovx8= +github.com/flashcatcloud/go-flashduty v0.5.2 h1:mYg/M0jqkil30WTLdICVtTJVGxEIGmae/3zBpRkwLRQ= +github.com/flashcatcloud/go-flashduty v0.5.2/go.mod h1:aA0RtZEs0AYOwwdNKdtVeD8YMOdnmVY1zAlVD+9Ovx8= 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= diff --git a/pkg/flashduty/incidents.go b/pkg/flashduty/incidents.go index 277f432..2221e08 100644 --- a/pkg/flashduty/incidents.go +++ b/pkg/flashduty/incidents.go @@ -126,7 +126,7 @@ func QueryIncidents(getClient GetFlashdutyClientFn, t translations.TranslationHe } } -const queryIncidentTimelineDescription = `Query timeline events for incidents. Returns events like created, assigned, acknowledged, resolved, notifications.` +const queryIncidentTimelineDescription = `Query timeline events for incidents. Returns events like created, assigned, acknowledged, resolved, notifications. Each event includes created_at (RFC3339) and creator_id (the actor's numeric ID, 0 = system); resolve creator_id to a display name with query_members when you need the actor's name.` // QueryIncidentTimeline creates a tool to query incident timeline func QueryIncidentTimeline(getClient GetFlashdutyClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) {