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 72a288b..b547425 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() { @@ -420,21 +402,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 +588,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 +625,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 +650,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) @@ -722,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"` @@ -735,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 { @@ -746,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 @@ -820,7 +816,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 ff1b712..a0a1706 100644 --- a/go.mod +++ b/go.mod @@ -4,13 +4,14 @@ go 1.25.5 require ( github.com/bluele/gcache v0.0.2 - github.com/flashcatcloud/flashduty-sdk v0.9.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 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,11 +23,9 @@ 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 - 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 3094466..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/flashduty-sdk v0.9.1 h1:vDTkSjAJJD6Ex5r7S+VCxPi4yxSFNw1bU/SfoRCvk+k= -github.com/flashcatcloud/flashduty-sdk v0.9.1/go.mod h1:dG4eJfdZaj4jNBMwEexbfK/3PmcIMhNeJ88L/DcZzUY= +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= @@ -96,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 be37c73..3aa65af 100644 --- a/internal/flashduty/context.go +++ b/internal/flashduty/context.go @@ -7,8 +7,9 @@ import ( "time" "github.com/bluele/gcache" - sdk "github.com/flashcatcloud/flashduty-sdk" + goflashduty "github.com/flashcatcloud/go-flashduty" + "github.com/flashcatcloud/flashduty-mcp-server/pkg/flashduty" "github.com/flashcatcloud/flashduty-mcp-server/pkg/trace" ) @@ -30,28 +31,28 @@ 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 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 } cfg, ok := ConfigFromContext(ctx) @@ -65,31 +66,35 @@ 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) + } + } + + 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)) } - - client, err := sdk.NewClient(cfg.APPKey, opts...) + newClient, err := goflashduty.NewClient(cfg.APPKey, newOpts...) if err != nil { - return ctx, nil, fmt.Errorf("failed to create Flashduty client: %w", err) + return ctx, nil, fmt.Errorf("failed to create go-flashduty client: %w", err) } - _ = clientCache.Set(cacheKey, client) - ctx = contextWithClient(ctx, client) + clients := &flashduty.Clients{New: newClient} + + _ = 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..aecc013 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,7 @@ func NewMCPServer(cfg FlashdutyConfig) (*server.MCPServer, error) { message.Params.ClientInfo.Name, message.Params.ClientInfo.Version, ) - client.SetUserAgent(userAgent) + clients.New.UserAgent = userAgent } if len(cfg.EnabledToolsets) == 0 { @@ -129,7 +128,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..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,36 +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) } } - output, err := client.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 } + 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": output.Changes, - "total": output.Total, - }, len(output.Changes), output.Total)), nil + "changes": changes, + "total": resp.Total, + }, len(changes), int(resp.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..1ba0739 100644 --- a/pkg/flashduty/client.go +++ b/pkg/flashduty/client.go @@ -3,8 +3,15 @@ package flashduty import ( "context" - sdk "github.com/flashcatcloud/flashduty-sdk" + flashduty "github.com/flashcatcloud/go-flashduty" ) -// GetFlashdutyClientFn is a function that returns a flashduty SDK client -type GetFlashdutyClientFn func(context.Context) (context.Context, *sdk.Client, error) +// Clients bundles the Flashduty API clients a tool handler may need. +// +// New is the typed go-flashduty client and backs every tool. +type Clients struct { + New *flashduty.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 4d5cb81..e727142 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,24 +56,19 @@ 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) } -// MarshalResultWithFormat serializes the given value using the specified format -func MarshalResultWithFormat(v any, format OutputFormat) *mcp.CallToolResult { - data, err := sdk.Marshal(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)) } 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/incidents.go b/pkg/flashduty/incidents.go index b34e34d..2221e08 100644 --- a/pkg/flashduty/incidents.go +++ b/pkg/flashduty/incidents.go @@ -6,7 +6,7 @@ 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" @@ -61,56 +61,72 @@ 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 } } -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) { @@ -137,18 +153,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 +210,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 +268,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 +329,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 +355,61 @@ 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, 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, name) + } + + if len(updatedFields) == 0 { + return mcp.NewToolResultError("no fields specified to update"), nil } return MarshalResult(map[string]any{ @@ -382,7 +447,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 +485,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 +524,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: flashduty.Int64(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/marshal_time_test.go b/pkg/flashduty/marshal_time_test.go index 4ab67ec..90c43d5 100644 --- a/pkg/flashduty/marshal_time_test.go +++ b/pkg/flashduty/marshal_time_test.go @@ -6,17 +6,17 @@ import ( "testing" "time" - sdk "github.com/flashcatcloud/flashduty-sdk" + flashduty "github.com/flashcatcloud/go-flashduty" "github.com/mark3labs/mcp-go/mcp" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) -// timeFixture is a small struct exercising both SDK timestamp types the way an -// SDK response object would carry them. +// timeFixture is a small struct exercising both go-flashduty timestamp types +// the way an SDK response object would carry them. type timeFixture struct { - CreatedAt sdk.Timestamp `json:"created_at"` - UpdatedAt sdk.TimestampMilli `json:"updated_at"` + CreatedAt flashduty.Timestamp `json:"created_at"` + UpdatedAt flashduty.TimestampMilli `json:"updated_at"` } // resultText pulls the single text payload out of an MCP CallToolResult. @@ -41,8 +41,8 @@ func TestMarshalResultRendersTimestampsAsRFC3339(t *testing.T) { wantYear := strconv.Itoa(time.Unix(secs, 0).Year()) fixture := timeFixture{ - CreatedAt: sdk.Timestamp(secs), - UpdatedAt: sdk.TimestampMilli(secs * 1000), + CreatedAt: flashduty.Timestamp(secs), + UpdatedAt: flashduty.TimestampMilli(secs * 1000), } formats := []struct { @@ -62,7 +62,7 @@ func TestMarshalResultRendersTimestampsAsRFC3339(t *testing.T) { t.Run(f.name, func(t *testing.T) { t.Parallel() - out := resultText(t, MarshalResultWithFormat(fixture, f.format)) + out := resultText(t, marshalResultWithFormat(fixture, f.format)) // RFC3339 shape: contains the date/time 'T' separator and the year. assert.Contains(t, out, "T", "expected RFC3339 'T' separator in %q", out) @@ -83,7 +83,7 @@ func TestMarshalResultUsesGlobalFormat(t *testing.T) { secs := int64(1748487600) wantYear := strconv.Itoa(time.Unix(secs, 0).Year()) - fixture := timeFixture{CreatedAt: sdk.Timestamp(secs)} + fixture := timeFixture{CreatedAt: flashduty.Timestamp(secs)} out := resultText(t, MarshalResult(fixture)) assert.True(t, strings.Contains(out, wantYear) && strings.Contains(out, "T"), diff --git a/pkg/flashduty/statuspage.go b/pkg/flashduty/statuspage.go index d66aaca..8ed40e3 100644 --- a/pkg/flashduty/statuspage.go +++ b/pkg/flashduty/statuspage.go @@ -2,9 +2,12 @@ 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" @@ -31,18 +34,31 @@ 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{}{} } } - pages, err := client.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 } + // 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), @@ -82,18 +98,23 @@ func ListStatusChanges(getClient GetFlashdutyClientFn, t translations.Translatio return mcp.NewToolResultError("type must be 'incident' or 'maintenance'"), nil } - output, err := client.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 } + total := len(resp.Items) return MarshalResult(addTruncationHint(map[string]any{ - "changes": output.Changes, - "total": output.Total, - }, len(output.Changes), output.Total)), nil + "changes": resp.Items, + "total": total, + }, total, total)), nil } } @@ -134,20 +155,67 @@ 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.` @@ -195,15 +263,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/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 72c9bc4..65aaa90 100644 --- a/pkg/flashduty/templates.go +++ b/pkg/flashduty/templates.go @@ -2,24 +2,27 @@ 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.` 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. @@ -46,19 +49,50 @@ func GetPresetTemplate(getClient GetFlashdutyClientFn, t translations.Translatio return mcp.NewToolResultError(err.Error()), nil } - input := &sdk.GetPresetTemplateInput{ - Channel: channel, + fieldName, ok := 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.` @@ -101,18 +135,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 := templateChannels[channel] + if !ok { + return mcp.NewToolResultError(fmt.Sprintf("unknown channel: %s", channel)), nil } - output, err := client.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 MarshalResult(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 := 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 } } @@ -129,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), @@ -151,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/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/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{