From 1a0f36dbe4a775c626991b9fb9506ef2a526b220 Mon Sep 17 00:00:00 2001 From: debidong <1953531014@qq.com> Date: Wed, 8 Apr 2026 16:14:47 +0800 Subject: [PATCH 1/4] refactor: replace inline API client with flashduty-sdk Replace the entire duplicate API client layer in pkg/flashduty/ with the flashduty-sdk (v0.3.0). Tool handlers are now thin wrappers that parse MCP params, call SDK methods, and format results. Deleted: - pkg/flashduty/types.go (280 lines) -- all types now in SDK - pkg/flashduty/enrichment.go (696 lines) -- all enrichment now SDK-internal - Client struct, makeRequest, parseResponse, handleAPIError from client.go Rewritten (22 tool handlers across 7 files): - incidents.go, users.go, channels.go, changes.go, statuspage.go, fields.go, templates.go -- all delegate to SDK methods Updated: - client.go: reduced to GetFlashdutyClientFn type alias - context.go: creates sdk.Client with WithRequestHook for trace context - format.go: delegates serialization to sdk.Marshal - e2e tests: use sdk.NewClient directly Net change: -2,392 lines. toon-go and golang.org/x/sync demoted to indirect deps. logrus removed entirely. --- e2e/e2e_test.go | 15 +- go.mod | 5 +- go.sum | 2 + internal/flashduty/context.go | 30 +- internal/flashduty/server.go | 4 +- pkg/flashduty/changes.go | 127 +------ pkg/flashduty/channels.go | 386 +------------------ pkg/flashduty/client.go | 261 +------------ pkg/flashduty/enrichment.go | 696 ---------------------------------- pkg/flashduty/fields.go | 77 +--- pkg/flashduty/format.go | 47 +-- pkg/flashduty/incidents.go | 504 +++--------------------- pkg/flashduty/server.go | 34 ++ pkg/flashduty/statuspage.go | 241 ++---------- pkg/flashduty/templates.go | 161 ++++++++ pkg/flashduty/tools.go | 12 +- pkg/flashduty/types.go | 279 -------------- pkg/flashduty/users.go | 165 ++------ 18 files changed, 409 insertions(+), 2637 deletions(-) delete mode 100644 pkg/flashduty/enrichment.go create mode 100644 pkg/flashduty/templates.go delete mode 100644 pkg/flashduty/types.go diff --git a/e2e/e2e_test.go b/e2e/e2e_test.go index e15e4f9..53204c0 100644 --- a/e2e/e2e_test.go +++ b/e2e/e2e_test.go @@ -16,6 +16,7 @@ import ( "github.com/mark3labs/mcp-go/mcp" "github.com/stretchr/testify/require" + sdk "github.com/flashcatcloud/flashduty-sdk" "github.com/flashcatcloud/flashduty-mcp-server/internal/flashduty" pkgflashduty "github.com/flashcatcloud/flashduty-mcp-server/pkg/flashduty" "github.com/flashcatcloud/flashduty-mcp-server/pkg/translations" @@ -55,13 +56,19 @@ func getE2EBaseURL() string { return baseURL } -// getAPIClient creates a native Flashduty API client for verification purposes -func getAPIClient(t *testing.T) *pkgflashduty.Client { +// getAPIClient creates a native Flashduty SDK client for verification purposes +func getAPIClient(t *testing.T) *sdk.Client { appKey := getE2EAppKey(t) baseURL := getE2EBaseURL() - client, err := pkgflashduty.NewClient(appKey, baseURL, "e2e-test-client/1.0.0") - require.NoError(t, err, "expected to create Flashduty API client") + 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 } diff --git a/go.mod b/go.mod index 300e074..6c67ee3 100644 --- a/go.mod +++ b/go.mod @@ -4,14 +4,13 @@ go 1.24.4 require ( github.com/bluele/gcache v0.0.2 + github.com/flashcatcloud/flashduty-sdk 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.45.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 - golang.org/x/sync v0.19.0 ) require ( @@ -24,10 +23,12 @@ require ( github.com/josharian/intern v1.0.0 // indirect github.com/mailru/easyjson v0.7.7 // indirect github.com/rogpeppe/go-internal v1.13.1 // indirect + github.com/toon-format/toon-go v0.0.0-20251202084852-7ca0e27c4e8c // indirect github.com/wk8/go-ordered-map/v2 v2.1.8 // 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 d965b5c..c5c134b 100644 --- a/go.sum +++ b/go.sum @@ -10,6 +10,8 @@ github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSs github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/flashcatcloud/flashduty-sdk v0.3.0 h1:jx7j6o+wFDIjTQaP5NtxWoAYIq6qtmIOQCZtG9OueV8= +github.com/flashcatcloud/flashduty-sdk v0.3.0/go.mod h1:dG4eJfdZaj4jNBMwEexbfK/3PmcIMhNeJ88L/DcZzUY= github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/fsnotify/fsnotify v1.8.0 h1:dAwr6QBTBZIkG8roQaJjGof0pp0EeF+tNV7YBP3F/8M= diff --git a/internal/flashduty/context.go b/internal/flashduty/context.go index b054d52..7a513cb 100644 --- a/internal/flashduty/context.go +++ b/internal/flashduty/context.go @@ -3,11 +3,13 @@ package flashduty import ( "context" "fmt" + "net/http" "time" "github.com/bluele/gcache" - "github.com/flashcatcloud/flashduty-mcp-server/pkg/flashduty" + sdk "github.com/flashcatcloud/flashduty-sdk" + "github.com/flashcatcloud/flashduty-mcp-server/pkg/trace" ) type contextKey string @@ -29,13 +31,13 @@ func ConfigFromContext(ctx context.Context) (FlashdutyConfig, bool) { } // clientFromContext returns the Flashduty client from the context. -func clientFromContext(ctx context.Context) (*flashduty.Client, bool) { - client, ok := ctx.Value(flashdutyClientKey).(*flashduty.Client) +func clientFromContext(ctx context.Context) (*sdk.Client, bool) { + client, ok := ctx.Value(flashdutyClientKey).(*sdk.Client) return client, ok } // contextWithClient adds the Flashduty client to the context. -func contextWithClient(ctx context.Context, client *flashduty.Client) context.Context { +func contextWithClient(ctx context.Context, client *sdk.Client) context.Context { return context.WithValue(ctx, flashdutyClientKey, client) } @@ -43,11 +45,11 @@ var clientCache = gcache.New(1000). Expiration(time.Hour). Build() -// getClientFromContext is a helper function for tool handlers to get a flashduty client. +// 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, *flashduty.Client, error) { +func getClient(ctx context.Context, defaultCfg FlashdutyConfig, version string) (context.Context, *sdk.Client, error) { if client, ok := clientFromContext(ctx); ok { return ctx, client, nil } @@ -64,12 +66,24 @@ 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.(*flashduty.Client)), client.(*flashduty.Client), nil + return contextWithClient(ctx, client.(*sdk.Client)), client.(*sdk.Client), nil } userAgent := fmt.Sprintf("flashduty-mcp-server/%s", version) - client, err := flashduty.NewClient(cfg.APPKey, cfg.BaseURL, userAgent) + opts := []sdk.Option{ + sdk.WithUserAgent(userAgent), + sdk.WithRequestHook(func(req *http.Request) { + if traceCtx := trace.FromContext(req.Context()); traceCtx != nil { + traceCtx.SetHTTPHeaders(req.Header) + } + }), + } + if cfg.BaseURL != "" { + opts = append(opts, sdk.WithBaseURL(cfg.BaseURL)) + } + + client, err := sdk.NewClient(cfg.APPKey, opts...) if err != nil { return ctx, nil, fmt.Errorf("failed to create Flashduty client: %w", err) } diff --git a/internal/flashduty/server.go b/internal/flashduty/server.go index e5bef23..3efa405 100644 --- a/internal/flashduty/server.go +++ b/internal/flashduty/server.go @@ -17,6 +17,8 @@ import ( "github.com/mark3labs/mcp-go/mcp" "github.com/mark3labs/mcp-go/server" + sdk "github.com/flashcatcloud/flashduty-sdk" + pkgerrors "github.com/flashcatcloud/flashduty-mcp-server/pkg/errors" "github.com/flashcatcloud/flashduty-mcp-server/pkg/flashduty" mcplog "github.com/flashcatcloud/flashduty-mcp-server/pkg/log" @@ -128,7 +130,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, *flashduty.Client, error) { + getClientFn := func(ctx context.Context) (context.Context, *sdk.Client, error) { return getClient(ctx, cfg, cfg.Version) } diff --git a/pkg/flashduty/changes.go b/pkg/flashduty/changes.go index 35a89f4..49773ab 100644 --- a/pkg/flashduty/changes.go +++ b/pkg/flashduty/changes.go @@ -3,11 +3,10 @@ package flashduty import ( "context" "fmt" - "net/http" + sdk "github.com/flashcatcloud/flashduty-sdk" "github.com/mark3labs/mcp-go/mcp" "github.com/mark3labs/mcp-go/server" - "golang.org/x/sync/errgroup" "github.com/flashcatcloud/flashduty-mcp-server/pkg/translations" ) @@ -45,128 +44,26 @@ func QueryChanges(getClient GetFlashdutyClientFn, t translations.TranslationHelp limit = 20 } - requestBody := map[string]interface{}{ - "p": 1, - "limit": limit, + input := &sdk.ListChangesInput{ + ChannelID: int64(channelID), + StartTime: int64(startTime), + EndTime: int64(endTime), + Type: changeType, + Limit: limit, } if changeIdsStr != "" { - changeIDs := parseCommaSeparatedStrings(changeIdsStr) - requestBody["change_ids"] = changeIDs - } - if channelID > 0 { - requestBody["channel_id"] = channelID - } - if startTime > 0 { - requestBody["start_time"] = startTime - } - if endTime > 0 { - requestBody["end_time"] = endTime - } - if changeType != "" { - requestBody["type"] = changeType + input.ChangeIDs = parseCommaSeparatedStrings(changeIdsStr) } - resp, err := client.makeRequest(ctx, "POST", "/change/list", requestBody) + output, err := client.ListChanges(ctx, input) if err != nil { - return nil, fmt.Errorf("failed to query changes: %w", err) - } - defer func() { _ = resp.Body.Close() }() - - if resp.StatusCode != http.StatusOK { - return mcp.NewToolResultError(handleAPIError(resp).Error()), nil - } - - var result struct { - Error *DutyError `json:"error,omitempty"` - Data *struct { - Items []struct { - ChangeID string `json:"change_id"` - Title string `json:"title"` - Description string `json:"description,omitempty"` - Type string `json:"type,omitempty"` - Status string `json:"status,omitempty"` - ChannelID int64 `json:"channel_id,omitempty"` - CreatorID int64 `json:"creator_id,omitempty"` - StartTime int64 `json:"start_time,omitempty"` - EndTime int64 `json:"end_time,omitempty"` - Labels map[string]string `json:"labels,omitempty"` - } `json:"items"` - Total int `json:"total"` - } `json:"data,omitempty"` - } - if err := parseResponse(resp, &result); err != nil { - return nil, err - } - if result.Error != nil { - return mcp.NewToolResultError(fmt.Sprintf("API error: %s - %s", result.Error.Code, result.Error.Message)), nil - } - - if result.Data == nil || len(result.Data.Items) == 0 { - return MarshalResult(map[string]any{ - "changes": []Change{}, - "total": 0, - }), nil - } - - // Collect IDs for enrichment - channelIDs := make([]int64, 0) - personIDs := make([]int64, 0) - for _, item := range result.Data.Items { - if item.ChannelID != 0 { - channelIDs = append(channelIDs, item.ChannelID) - } - if item.CreatorID != 0 { - personIDs = append(personIDs, item.CreatorID) - } - } - - // Fetch enrichment data concurrently - var channelMap map[int64]ChannelInfo - var personMap map[int64]PersonInfo - g, gctx := errgroup.WithContext(ctx) - - g.Go(func() error { - channelMap, _ = client.fetchChannelInfos(gctx, channelIDs) - return nil - }) - - g.Go(func() error { - personMap, _ = client.fetchPersonInfos(gctx, personIDs) - return nil - }) - - _ = g.Wait() // Ignore errors for enrichment as it's best-effort - - // Build enriched changes - changes := make([]Change, 0, len(result.Data.Items)) - for _, item := range result.Data.Items { - change := Change{ - ChangeID: item.ChangeID, - Title: item.Title, - Description: item.Description, - Type: item.Type, - Status: item.Status, - ChannelID: item.ChannelID, - CreatorID: item.CreatorID, - StartTime: item.StartTime, - EndTime: item.EndTime, - Labels: item.Labels, - } - - if ch, ok := channelMap[item.ChannelID]; ok { - change.ChannelName = ch.ChannelName - } - if p, ok := personMap[item.CreatorID]; ok { - change.CreatorName = p.PersonName - } - - changes = append(changes, change) + return mcp.NewToolResultError(fmt.Sprintf("Unable to retrieve changes: %v", err)), nil } return MarshalResult(map[string]any{ - "changes": changes, - "total": result.Data.Total, + "changes": output.Changes, + "total": output.Total, }), nil } } diff --git a/pkg/flashduty/channels.go b/pkg/flashduty/channels.go index e5a1cf1..2b16d4b 100644 --- a/pkg/flashduty/channels.go +++ b/pkg/flashduty/channels.go @@ -3,13 +3,10 @@ package flashduty import ( "context" "fmt" - "log/slog" - "net/http" - "strings" + sdk "github.com/flashcatcloud/flashduty-sdk" "github.com/mark3labs/mcp-go/mcp" "github.com/mark3labs/mcp-go/server" - "golang.org/x/sync/errgroup" "github.com/flashcatcloud/flashduty-mcp-server/pkg/translations" ) @@ -35,7 +32,11 @@ func QueryChannels(getClient GetFlashdutyClientFn, t translations.TranslationHel channelIdsStr, _ := OptionalParam[string](request, "channel_ids") name, _ := OptionalParam[string](request, "name") - // Query by channel IDs + input := &sdk.ListChannelsInput{ + Name: name, + } + + // Parse channel IDs if provided if channelIdsStr != "" { channelIDs := parseCommaSeparatedInts(channelIdsStr) if len(channelIDs) == 0 { @@ -46,134 +47,23 @@ func QueryChannels(getClient GetFlashdutyClientFn, t translations.TranslationHel for i, id := range channelIDs { int64IDs[i] = int64(id) } - - channelMap, err := client.fetchChannelInfos(ctx, int64IDs) - if err != nil { - return mcp.NewToolResultError(fmt.Sprintf("Unable to retrieve channels: %v", err)), nil - } - - channels := make([]ChannelInfo, 0, len(channelMap)) - for _, ch := range channelMap { - channels = append(channels, ch) - } - - // Enrich channels with team and creator names - enrichedChannels, err := client.enrichChannels(ctx, channels) - if err != nil { - return mcp.NewToolResultError(fmt.Sprintf("Failed to fetch team and creator details for channels: %v", err)), nil - } - - return MarshalResult(map[string]any{ - "channels": enrichedChannels, - "total": len(enrichedChannels), - }), nil - } - - // List all channels - resp, err := client.makeRequest(ctx, "POST", "/channel/list", map[string]interface{}{}) - if err != nil { - return nil, fmt.Errorf("unable to list channels: %w", err) - } - defer func() { _ = resp.Body.Close() }() - - if resp.StatusCode != http.StatusOK { - return mcp.NewToolResultError(handleAPIError(resp).Error()), nil - } - - var result struct { - Error *DutyError `json:"error,omitempty"` - Data *struct { - Items []struct { - ChannelID int64 `json:"channel_id"` - ChannelName string `json:"channel_name"` - TeamID int64 `json:"team_id,omitempty"` - CreatorID int64 `json:"creator_id,omitempty"` - } `json:"items"` - } `json:"data,omitempty"` - } - if err := parseResponse(resp, &result); err != nil { - return nil, err - } - if result.Error != nil { - return mcp.NewToolResultError(fmt.Sprintf("API error: %s - %s", result.Error.Code, result.Error.Message)), nil - } - - channels := []ChannelInfo{} - if result.Data != nil { - for _, ch := range result.Data.Items { - // Filter by name if provided (case-insensitive substring match) - if name != "" && !strings.Contains(strings.ToLower(ch.ChannelName), strings.ToLower(name)) { - continue - } - channels = append(channels, ChannelInfo{ - ChannelID: ch.ChannelID, - ChannelName: ch.ChannelName, - TeamID: ch.TeamID, - CreatorID: ch.CreatorID, - }) - } + input.ChannelIDs = int64IDs } - // Enrich channels with team and creator names - enrichedChannels, err := client.enrichChannels(ctx, channels) + output, err := client.ListChannels(ctx, input) if err != nil { - return mcp.NewToolResultError(fmt.Sprintf("Failed to fetch team and creator details for channels: %v", err)), nil + return mcp.NewToolResultError(fmt.Sprintf("Unable to retrieve channels: %v", err)), nil } return MarshalResult(map[string]any{ - "channels": enrichedChannels, - "total": len(enrichedChannels), + "channels": output.Channels, + "total": output.Total, }), nil } } const queryEscalationRulesDescription = `Query escalation rules for a channel. Returns complete rules with notification layers, targets (persons/teams/schedules), webhooks, time filters and alert filters.` -// rawEscalationRule represents the raw API response structure for escalation rules -type rawEscalationRule struct { - RuleID string `json:"rule_id"` - RuleName string `json:"rule_name"` - Description string `json:"description,omitempty"` - ChannelID int64 `json:"channel_id"` - Status string `json:"status"` - Priority int `json:"priority"` - AggrWindow int `json:"aggr_window"` - Layers []struct { - MaxTimes int `json:"max_times"` - NotifyStep float64 `json:"notify_step"` - EscalateWindow int `json:"escalate_window"` - ForceEscalate bool `json:"force_escalate"` - Target *struct { - PersonIDs []int64 `json:"person_ids,omitempty"` - TeamIDs []int64 `json:"team_ids,omitempty"` - ScheduleToRoleIDs map[int64][]int64 `json:"schedule_to_role_ids,omitempty"` - By *struct { - FollowPreference bool `json:"follow_preference"` - Critical []string `json:"critical,omitempty"` - Warning []string `json:"warning,omitempty"` - Info []string `json:"info,omitempty"` - } `json:"by,omitempty"` - Webhooks []struct { - Type string `json:"type"` - Settings map[string]any `json:"settings,omitempty"` - } `json:"webhooks,omitempty"` - } `json:"target,omitempty"` - } `json:"layers,omitempty"` - TimeFilters []struct { - Start string `json:"start"` - End string `json:"end"` - Repeat []int `json:"repeat,omitempty"` - CalID string `json:"cal_id,omitempty"` - IsOff bool `json:"is_off,omitempty"` - } `json:"time_filters,omitempty"` - // Filters is []AndFilters where AndFilters is []*Filter - Filters [][]struct { - Key string `json:"key"` - Oper string `json:"oper"` - Vals []string `json:"vals"` - } `json:"filters,omitempty"` -} - // QueryEscalationRules creates a tool to query escalation rules func QueryEscalationRules(getClient GetFlashdutyClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { return mcp.NewTool("query_escalation_rules", @@ -194,260 +84,14 @@ func QueryEscalationRules(getClient GetFlashdutyClientFn, t translations.Transla return mcp.NewToolResultError(err.Error()), nil } - requestBody := map[string]interface{}{ - "channel_id": channelID, - } - - resp, err := client.makeRequest(ctx, "POST", "/channel/escalate/rule/list", requestBody) + output, err := client.ListEscalationRules(ctx, int64(channelID)) if err != nil { - return nil, fmt.Errorf("unable to query escalation rules: %w", err) - } - defer func() { _ = resp.Body.Close() }() - - if resp.StatusCode != http.StatusOK { - return mcp.NewToolResultError(handleAPIError(resp).Error()), nil - } - - var result struct { - Error *DutyError `json:"error,omitempty"` - Data *struct { - Items []rawEscalationRule `json:"items"` - } `json:"data,omitempty"` - } - if err := parseResponse(resp, &result); err != nil { - return nil, err - } - if result.Error != nil { - return mcp.NewToolResultError(fmt.Sprintf("API error: %s - %s", result.Error.Code, result.Error.Message)), nil - } - - rules := []EscalationRule{} - if result.Data == nil || len(result.Data.Items) == 0 { - return MarshalResult(map[string]any{ - "rules": rules, - "total": 0, - }), nil - } - - // Collect all IDs for enrichment - personIDs := make([]int64, 0) - teamIDs := make([]int64, 0) - scheduleIDs := make([]int64, 0) - - for _, r := range result.Data.Items { - for _, l := range r.Layers { - if l.Target == nil { - continue - } - for _, pid := range l.Target.PersonIDs { - if pid != 0 { - personIDs = append(personIDs, pid) - } - } - for _, tid := range l.Target.TeamIDs { - if tid != 0 { - teamIDs = append(teamIDs, tid) - } - } - for sid := range l.Target.ScheduleToRoleIDs { - if sid != 0 { - scheduleIDs = append(scheduleIDs, sid) - } - } - } - } - - // Fetch enrichment info concurrently (graceful degradation on errors) - var personMap map[int64]PersonInfo - var teamMap map[int64]TeamInfo - var scheduleMap map[int64]ScheduleInfo - var channelMap map[int64]ChannelInfo - - g, gctx := errgroup.WithContext(ctx) - - g.Go(func() error { - var err error - personMap, err = client.fetchPersonInfos(gctx, personIDs) - if err != nil { - personMap = make(map[int64]PersonInfo) - } - return nil - }) - - g.Go(func() error { - var err error - teamMap, err = client.fetchTeamInfos(gctx, teamIDs) - if err != nil { - teamMap = make(map[int64]TeamInfo) - } - return nil - }) - - g.Go(func() error { - var err error - scheduleMap, err = client.fetchScheduleInfos(gctx, scheduleIDs) - if err != nil { - // Log error for debugging, but continue with empty map (graceful degradation) - slog.Warn("failed to fetch schedule infos", "error", err, "schedule_ids", scheduleIDs) - scheduleMap = make(map[int64]ScheduleInfo) - } - return nil - }) - - g.Go(func() error { - var err error - channelMap, err = client.fetchChannelInfos(gctx, []int64{int64(channelID)}) - if err != nil { - channelMap = make(map[int64]ChannelInfo) - } - return nil - }) - - _ = g.Wait() - - // Build enriched rules - for _, r := range result.Data.Items { - rule := EscalationRule{ - RuleID: r.RuleID, - RuleName: r.RuleName, - Description: r.Description, - ChannelID: r.ChannelID, - Status: r.Status, - Priority: r.Priority, - AggrWindow: r.AggrWindow, - } - - // Enrich channel name - if ch, ok := channelMap[r.ChannelID]; ok { - rule.ChannelName = ch.ChannelName - } - - // Build time filters - if len(r.TimeFilters) > 0 { - rule.TimeFilters = make([]TimeFilter, 0, len(r.TimeFilters)) - for _, tf := range r.TimeFilters { - rule.TimeFilters = append(rule.TimeFilters, TimeFilter{ - Start: tf.Start, - End: tf.End, - Repeat: tf.Repeat, - CalID: tf.CalID, - IsOff: tf.IsOff, - }) - } - } - - // Build alert filters (OR groups of AND conditions) - if len(r.Filters) > 0 { - rule.Filters = make(AlertFilters, 0, len(r.Filters)) - for _, andGroup := range r.Filters { - group := make(AlertFilterGroup, 0, len(andGroup)) - for _, cond := range andGroup { - group = append(group, AlertCondition{ - Key: cond.Key, - Oper: cond.Oper, - Vals: cond.Vals, - }) - } - rule.Filters = append(rule.Filters, group) - } - } - - // Build layers - if len(r.Layers) > 0 { - rule.Layers = make([]EscalationLayer, 0, len(r.Layers)) - for idx, l := range r.Layers { - layer := EscalationLayer{ - LayerIdx: idx, - Timeout: l.EscalateWindow, - NotifyInterval: l.NotifyStep, - MaxTimes: l.MaxTimes, - ForceEscalate: l.ForceEscalate, - } - - if l.Target != nil { - target := &EscalationTarget{} - - // Build persons list with names - if len(l.Target.PersonIDs) > 0 { - target.Persons = make([]PersonTarget, 0, len(l.Target.PersonIDs)) - for _, pid := range l.Target.PersonIDs { - pt := PersonTarget{PersonID: pid} - if p, ok := personMap[pid]; ok { - pt.PersonName = p.PersonName - pt.Email = p.Email - } - target.Persons = append(target.Persons, pt) - } - } - - // Build teams list with names - if len(l.Target.TeamIDs) > 0 { - target.Teams = make([]TeamTarget, 0, len(l.Target.TeamIDs)) - for _, tid := range l.Target.TeamIDs { - tt := TeamTarget{TeamID: tid} - if team, ok := teamMap[tid]; ok { - tt.TeamName = team.TeamName - } - target.Teams = append(target.Teams, tt) - } - } - - // Build schedules list with names - if len(l.Target.ScheduleToRoleIDs) > 0 { - target.Schedules = make([]ScheduleTarget, 0, len(l.Target.ScheduleToRoleIDs)) - for sid, roleIDs := range l.Target.ScheduleToRoleIDs { - st := ScheduleTarget{ - ScheduleID: sid, - RoleIDs: roleIDs, - } - if s, ok := scheduleMap[sid]; ok { - st.ScheduleName = s.ScheduleName - } - target.Schedules = append(target.Schedules, st) - } - } - - // Build notify by (direct message configuration) - if l.Target.By != nil { - target.NotifyBy = &NotifyBy{ - FollowPreference: l.Target.By.FollowPreference, - Critical: l.Target.By.Critical, - Warning: l.Target.By.Warning, - Info: l.Target.By.Info, - } - } - - // Build webhooks - if len(l.Target.Webhooks) > 0 { - target.Webhooks = make([]WebhookConfig, 0, len(l.Target.Webhooks)) - for _, wh := range l.Target.Webhooks { - whConfig := WebhookConfig{ - Type: wh.Type, - Settings: wh.Settings, - } - // Extract alias from settings if available - if wh.Settings != nil { - if alias, ok := wh.Settings["alias"].(string); ok { - whConfig.Alias = alias - } - } - target.Webhooks = append(target.Webhooks, whConfig) - } - } - - layer.Target = target - } - - rule.Layers = append(rule.Layers, layer) - } - } - - rules = append(rules, rule) + return mcp.NewToolResultError(fmt.Sprintf("Unable to query escalation rules: %v", err)), nil } return MarshalResult(map[string]any{ - "rules": rules, - "total": len(rules), + "rules": output.Rules, + "total": output.Total, }), nil } } diff --git a/pkg/flashduty/client.go b/pkg/flashduty/client.go index fff47a2..447b473 100644 --- a/pkg/flashduty/client.go +++ b/pkg/flashduty/client.go @@ -1,265 +1,10 @@ package flashduty import ( - "bytes" "context" - "encoding/json" - "fmt" - "io" - "log/slog" - "net/http" - "net/url" - "strings" - "time" - mcplog "github.com/flashcatcloud/flashduty-mcp-server/pkg/log" - "github.com/flashcatcloud/flashduty-mcp-server/pkg/trace" + sdk "github.com/flashcatcloud/flashduty-sdk" ) -const ( - // maxResponseBodySize limits the response body size to prevent OOM attacks (10MB) - maxResponseBodySize = 10 * 1024 * 1024 -) - -// Client represents a Flashduty API client -type Client struct { - httpClient *http.Client - baseURL *url.URL - appKey string - userAgent string -} - -// GetFlashdutyClientFn is a function that returns a flashduty client -type GetFlashdutyClientFn func(context.Context) (context.Context, *Client, error) - -// NewClient creates a new Flashduty API client -func NewClient(appKey, baseURL, userAgent string) (*Client, error) { - if appKey == "" { - return nil, fmt.Errorf("APP key is required") - } - - if baseURL == "" { - baseURL = "https://api.flashcat.cloud" - } - - parsedURL, err := url.Parse(baseURL) - if err != nil { - return nil, fmt.Errorf("invalid base URL: %w", err) - } - - return &Client{ - httpClient: &http.Client{ - Timeout: 30 * time.Second, - }, - baseURL: parsedURL, - appKey: appKey, - userAgent: userAgent, - }, nil -} - -// SetUserAgent sets the user agent for the client -func (c *Client) SetUserAgent(userAgent string) { - c.userAgent = userAgent -} - -// makeRequest makes an HTTP request to the Flashduty API -func (c *Client) makeRequest(ctx context.Context, method, path string, body interface{}) (*http.Response, error) { - var reqBody io.Reader - var reqBodyBytes []byte - - if body != nil { - var err error - reqBodyBytes, err = json.Marshal(body) - if err != nil { - return nil, fmt.Errorf("invalid request body: unable to serialize to JSON: %w", err) - } - reqBody = bytes.NewBuffer(reqBodyBytes) - } - - // Parse path to handle query parameters correctly - parsedPath, err := url.Parse(strings.TrimPrefix(path, "/")) - if err != nil { - return nil, fmt.Errorf("failed to parse path: %w", err) - } - - // Construct full URL with app_key query parameter - fullURL := c.baseURL.ResolveReference(parsedPath) - query := fullURL.Query() - query.Set("app_key", c.appKey) - fullURL.RawQuery = query.Encode() - - // Extract trace context for logging and propagation - traceCtx := trace.FromContext(ctx) - - // Log request with trace_id first (after msg in output) - logAttrs := []any{} - if traceCtx != nil { - logAttrs = append(logAttrs, "trace_id", traceCtx.TraceID) - } - logAttrs = append(logAttrs, "method", method, "url", sanitizeURL(fullURL), "body", mcplog.TruncateBodyDefault(string(reqBodyBytes))) - slog.Info("duty request", logAttrs...) - - req, err := http.NewRequestWithContext(ctx, method, fullURL.String(), reqBody) - if err != nil { - return nil, fmt.Errorf("failed to create request: %w", err) - } - - // Set headers - if body != nil { - req.Header.Set("Content-Type", "application/json") - } - req.Header.Set("Accept", "application/json") - if c.userAgent != "" { - req.Header.Set("User-Agent", c.userAgent) - } - - // Propagate trace context to downstream service - if traceCtx != nil { - traceCtx.SetHTTPHeaders(req.Header) - } - - resp, err := c.httpClient.Do(req) - if err != nil { - // Sanitize error to avoid leaking app_key in logs - return nil, fmt.Errorf("failed to make request to %s %s: %v", method, sanitizeURL(fullURL), sanitizeError(err)) - } - - return resp, nil -} - -// sanitizeURL removes sensitive query parameters from URL for safe logging -func sanitizeURL(u *url.URL) string { - sanitized := *u - q := sanitized.Query() - if q.Has("app_key") { - q.Set("app_key", "[REDACTED]") - sanitized.RawQuery = q.Encode() - } - return sanitized.String() -} - -// sanitizeError removes potential URL with sensitive data from error messages -func sanitizeError(err error) string { - errStr := err.Error() - idx := strings.Index(errStr, "app_key=") - if idx == -1 { - return errStr - } - - endIdx := strings.IndexAny(errStr[idx:], "& ") - if endIdx == -1 { - return errStr[:idx] + "app_key=[REDACTED]" - } - return errStr[:idx] + "app_key=[REDACTED]" + errStr[idx+endIdx:] -} - -// parseResponse parses the HTTP response into the given interface. -// Note: caller is responsible for closing resp.Body. -func parseResponse(resp *http.Response, v interface{}) error { - body, err := io.ReadAll(io.LimitReader(resp.Body, maxResponseBodySize)) - if err != nil { - return fmt.Errorf("failed to read response body: %w", err) - } - - // Build log attributes with trace context - logAttrs := []any{} - if traceCtx := trace.FromContext(resp.Request.Context()); traceCtx != nil { - logAttrs = append(logAttrs, "trace_id", traceCtx.TraceID) - } - logAttrs = append(logAttrs, "status", resp.StatusCode, "body", mcplog.TruncateBodyDefault(string(body))) - - requestID := resp.Header.Get("Flashcat-Request-Id") - - if resp.StatusCode >= 500 { - slog.Error("duty response", logAttrs...) - return fmt.Errorf("API server error (HTTP %d, request_id: %s): %s", resp.StatusCode, requestID, string(body)) - } - - if resp.StatusCode >= 400 { - slog.Warn("duty response", logAttrs...) - return fmt.Errorf("API client error (HTTP %d, request_id: %s): %s", resp.StatusCode, requestID, string(body)) - } - - slog.Info("duty response", logAttrs...) - - if v != nil { - if err := json.Unmarshal(body, v); err != nil { - return fmt.Errorf("invalid API response: failed to parse JSON (response size: %d bytes, request_id: %s): %w", len(body), requestID, err) - } - } - - return nil -} - -// handleAPIError reads the response body and returns a detailed error message. -// This function should be called when resp.StatusCode != http.StatusOK. -// It returns the full response body which contains request_id for debugging. -func handleAPIError(resp *http.Response) error { - body, err := io.ReadAll(io.LimitReader(resp.Body, maxResponseBodySize)) - if err != nil { - return fmt.Errorf("API request failed (HTTP %d): unable to read response body: %v", resp.StatusCode, err) - } - - // Build log attributes with trace context - logAttrs := []any{} - if traceCtx := trace.FromContext(resp.Request.Context()); traceCtx != nil { - logAttrs = append(logAttrs, "trace_id", traceCtx.TraceID) - } - logAttrs = append(logAttrs, "status", resp.StatusCode, "body", mcplog.TruncateBodyDefault(string(body))) - - requestID := resp.Header.Get("Flashcat-Request-Id") - - if resp.StatusCode >= 500 { - slog.Error("duty error", logAttrs...) - return fmt.Errorf("API server error (HTTP %d, request_id: %s): %s", resp.StatusCode, requestID, string(body)) - } - - slog.Warn("duty error", logAttrs...) - return fmt.Errorf("API client error (HTTP %d, request_id: %s): %s", resp.StatusCode, requestID, string(body)) -} - -// FlashdutyResponse represents the standard Flashduty API response structure -type FlashdutyResponse struct { - Error *DutyError `json:"error,omitempty"` - Data interface{} `json:"data,omitempty"` -} - -// DutyError represents Flashduty API error -type DutyError struct { - Code string `json:"code"` - Message string `json:"message"` -} - -// MemberListResponse represents the response for member list API -type MemberListResponse struct { - Error *DutyError `json:"error,omitempty"` - Data *struct { - P int `json:"p"` - Limit int `json:"limit"` - Total int `json:"total"` - Items []MemberItem `json:"items"` - } `json:"data,omitempty"` -} - -// MemberItem represents a member item as defined in the OpenAPI spec -type MemberItem struct { - MemberID int `json:"member_id"` - MemberName string `json:"member_name"` - Phone string `json:"phone,omitempty"` - PhoneVerified bool `json:"phone_verified,omitempty"` - Email string `json:"email,omitempty"` - EmailVerified bool `json:"email_verified,omitempty"` - AccountRoleIDs []int `json:"account_role_ids,omitempty"` - TimeZone string `json:"time_zone,omitempty"` - Locale string `json:"locale,omitempty"` - Status string `json:"status"` - CreatedAt int64 `json:"created_at"` - UpdatedAt int64 `json:"updated_at"` - RefID string `json:"ref_id,omitempty"` -} - -// MemberItemShort represents a short member item for invite response -type MemberItemShort struct { - MemberID int `json:"MemberID"` - MemberName string `json:"MemberName"` -} +// GetFlashdutyClientFn is a function that returns a flashduty SDK client +type GetFlashdutyClientFn func(context.Context) (context.Context, *sdk.Client, error) diff --git a/pkg/flashduty/enrichment.go b/pkg/flashduty/enrichment.go deleted file mode 100644 index dbb2786..0000000 --- a/pkg/flashduty/enrichment.go +++ /dev/null @@ -1,696 +0,0 @@ -package flashduty - -import ( - "context" - "fmt" - "net/http" - - "golang.org/x/sync/errgroup" -) - -// RawTimelineItem represents raw timeline data from API -type RawTimelineItem struct { - Type string `json:"type"` - CreatedAt int64 `json:"created_at"` - PersonID int64 `json:"person_id,omitempty"` - Detail map[string]any `json:"detail,omitempty"` -} - -// fetchIncidentTimeline fetches timeline for a single incident -func (c *Client) fetchIncidentTimeline(ctx context.Context, incidentID string) ([]RawTimelineItem, error) { - requestBody := map[string]interface{}{ - "incident_id": incidentID, - "limit": 100, - "asc": true, - } - - resp, err := c.makeRequest(ctx, "POST", "/incident/feed", requestBody) - if err != nil { - return nil, fmt.Errorf("unable to fetch timeline: %w", err) - } - defer func() { _ = resp.Body.Close() }() - - if resp.StatusCode != http.StatusOK { - return nil, handleAPIError(resp) - } - - var result struct { - Error *DutyError `json:"error,omitempty"` - Data *struct { - Items []RawTimelineItem `json:"items"` - } `json:"data,omitempty"` - } - if err := parseResponse(resp, &result); err != nil { - return nil, err - } - if result.Error != nil { - return nil, fmt.Errorf("API error: %s - %s", result.Error.Code, result.Error.Message) - } - - if result.Data == nil { - return nil, nil - } - - return result.Data.Items, nil -} - -// fetchIncidentAlerts fetches alerts for a single incident -func (c *Client) fetchIncidentAlerts(ctx context.Context, incidentID string, limit int) ([]AlertPreview, int, error) { - requestBody := map[string]interface{}{ - "incident_id": incidentID, - "p": 1, - "limit": limit, - } - - resp, err := c.makeRequest(ctx, "POST", "/incident/alert/list", requestBody) - if err != nil { - return nil, 0, fmt.Errorf("unable to fetch alerts: %w", err) - } - defer func() { _ = resp.Body.Close() }() - - if resp.StatusCode != http.StatusOK { - return nil, 0, handleAPIError(resp) - } - - var result struct { - Error *DutyError `json:"error,omitempty"` - Data *struct { - Total int `json:"total"` - Items []struct { - AlertID string `json:"alert_id"` - Title string `json:"title"` - Severity string `json:"severity"` - Status string `json:"status"` - TriggerTime int64 `json:"trigger_time"` - Labels map[string]string `json:"labels,omitempty"` - } `json:"items"` - } `json:"data,omitempty"` - } - if err := parseResponse(resp, &result); err != nil { - return nil, 0, err - } - if result.Error != nil { - return nil, 0, fmt.Errorf("API error: %s - %s", result.Error.Code, result.Error.Message) - } - - if result.Data == nil { - return nil, 0, nil - } - - alerts := make([]AlertPreview, 0, len(result.Data.Items)) - for _, item := range result.Data.Items { - alerts = append(alerts, AlertPreview{ - AlertID: item.AlertID, - Title: item.Title, - Severity: item.Severity, - Status: item.Status, - StartTime: item.TriggerTime, - Labels: item.Labels, - }) - } - return alerts, result.Data.Total, nil -} - -// fetchPersonInfos fetches person information by IDs -func (c *Client) fetchPersonInfos(ctx context.Context, personIDs []int64) (map[int64]PersonInfo, error) { - if len(personIDs) == 0 { - return make(map[int64]PersonInfo), nil - } - - // Deduplicate person IDs - idSet := make(map[int64]struct{}) - for _, id := range personIDs { - if id != 0 { - idSet[id] = struct{}{} - } - } - uniqueIDs := make([]int64, 0, len(idSet)) - for id := range idSet { - uniqueIDs = append(uniqueIDs, id) - } - - if len(uniqueIDs) == 0 { - return make(map[int64]PersonInfo), nil - } - - requestBody := map[string]interface{}{ - "person_ids": uniqueIDs, - } - - resp, err := c.makeRequest(ctx, "POST", "/person/infos", requestBody) - if err != nil { - return nil, fmt.Errorf("unable to fetch person information: %w", err) - } - defer func() { _ = resp.Body.Close() }() - - if resp.StatusCode != http.StatusOK { - return nil, handleAPIError(resp) - } - - var result struct { - Error *DutyError `json:"error,omitempty"` - Data *struct { - Items []struct { - PersonID int64 `json:"person_id"` - PersonName string `json:"person_name"` - Email string `json:"email,omitempty"` - Avatar string `json:"avatar,omitempty"` - As string `json:"as,omitempty"` - } `json:"items"` - } `json:"data,omitempty"` - } - if err := parseResponse(resp, &result); err != nil { - return nil, err - } - if result.Error != nil { - return nil, fmt.Errorf("API error: %s - %s", result.Error.Code, result.Error.Message) - } - - personMap := make(map[int64]PersonInfo) - if result.Data != nil { - for _, item := range result.Data.Items { - personMap[item.PersonID] = PersonInfo{ - PersonID: item.PersonID, - PersonName: item.PersonName, - Email: item.Email, - Avatar: item.Avatar, - As: item.As, - } - } - } - return personMap, nil -} - -// fetchTeamInfos fetches team information by IDs -func (c *Client) fetchTeamInfos(ctx context.Context, teamIDs []int64) (map[int64]TeamInfo, error) { - if len(teamIDs) == 0 { - return make(map[int64]TeamInfo), nil - } - - // Deduplicate team IDs - idSet := make(map[int64]struct{}) - for _, id := range teamIDs { - if id != 0 { - idSet[id] = struct{}{} - } - } - uniqueIDs := make([]int64, 0, len(idSet)) - for id := range idSet { - uniqueIDs = append(uniqueIDs, id) - } - - if len(uniqueIDs) == 0 { - return make(map[int64]TeamInfo), nil - } - - requestBody := map[string]interface{}{ - "team_ids": uniqueIDs, - } - - resp, err := c.makeRequest(ctx, "POST", "/team/infos", requestBody) - if err != nil { - return nil, fmt.Errorf("unable to fetch team information: %w", err) - } - defer func() { _ = resp.Body.Close() }() - - if resp.StatusCode != http.StatusOK { - return nil, handleAPIError(resp) - } - - var result struct { - Error *DutyError `json:"error,omitempty"` - Data *struct { - Items []struct { - TeamID int64 `json:"team_id"` - TeamName string `json:"team_name"` - } `json:"items"` - } `json:"data,omitempty"` - } - if err := parseResponse(resp, &result); err != nil { - return nil, err - } - if result.Error != nil { - return nil, fmt.Errorf("API error: %s - %s", result.Error.Code, result.Error.Message) - } - - teamMap := make(map[int64]TeamInfo) - if result.Data != nil { - for _, item := range result.Data.Items { - teamMap[item.TeamID] = TeamInfo{ - TeamID: item.TeamID, - TeamName: item.TeamName, - } - } - } - return teamMap, nil -} - -// fetchScheduleInfos fetches schedule information by IDs -func (c *Client) fetchScheduleInfos(ctx context.Context, scheduleIDs []int64) (map[int64]ScheduleInfo, error) { - if len(scheduleIDs) == 0 { - return make(map[int64]ScheduleInfo), nil - } - - // Deduplicate schedule IDs - idSet := make(map[int64]struct{}) - for _, id := range scheduleIDs { - if id != 0 { - idSet[id] = struct{}{} - } - } - uniqueIDs := make([]int64, 0, len(idSet)) - for id := range idSet { - uniqueIDs = append(uniqueIDs, id) - } - - if len(uniqueIDs) == 0 { - return make(map[int64]ScheduleInfo), nil - } - - requestBody := map[string]interface{}{ - "schedule_ids": uniqueIDs, - } - - resp, err := c.makeRequest(ctx, "POST", "/schedule/infos", requestBody) - if err != nil { - return nil, fmt.Errorf("unable to fetch schedule information: %w", err) - } - defer func() { _ = resp.Body.Close() }() - - if resp.StatusCode != http.StatusOK { - return nil, handleAPIError(resp) - } - - // Schedule API returns: {"data": {"items": [{"id": 123, "name": "xxx"}, ...]}} - // Note: id and name may be pointers in the model, but JSON numbers/strings decode fine - var result struct { - Error *DutyError `json:"error,omitempty"` - Data *struct { - Items []struct { - ID *int64 `json:"id"` - Name *string `json:"name"` - } `json:"items"` - } `json:"data,omitempty"` - } - if err := parseResponse(resp, &result); err != nil { - return nil, err - } - if result.Error != nil { - return nil, fmt.Errorf("API error: %s - %s", result.Error.Code, result.Error.Message) - } - - scheduleMap := make(map[int64]ScheduleInfo) - if result.Data != nil { - for _, item := range result.Data.Items { - if item.ID != nil { - info := ScheduleInfo{ScheduleID: *item.ID} - if item.Name != nil { - info.ScheduleName = *item.Name - } - scheduleMap[*item.ID] = info - } - } - } - return scheduleMap, nil -} - -// fetchChannelInfos fetches channel information by IDs -func (c *Client) fetchChannelInfos(ctx context.Context, channelIDs []int64) (map[int64]ChannelInfo, error) { - if len(channelIDs) == 0 { - return make(map[int64]ChannelInfo), nil - } - - // Deduplicate channel IDs - idSet := make(map[int64]struct{}) - for _, id := range channelIDs { - if id != 0 { - idSet[id] = struct{}{} - } - } - uniqueIDs := make([]int64, 0, len(idSet)) - for id := range idSet { - uniqueIDs = append(uniqueIDs, id) - } - - if len(uniqueIDs) == 0 { - return make(map[int64]ChannelInfo), nil - } - - requestBody := map[string]interface{}{ - "channel_ids": uniqueIDs, - } - - resp, err := c.makeRequest(ctx, "POST", "/channel/infos", requestBody) - if err != nil { - return nil, fmt.Errorf("unable to fetch channel information: %w", err) - } - defer func() { _ = resp.Body.Close() }() - - if resp.StatusCode != http.StatusOK { - return nil, handleAPIError(resp) - } - - var result struct { - Error *DutyError `json:"error,omitempty"` - Data *struct { - Items []struct { - ChannelID int64 `json:"channel_id"` - ChannelName string `json:"channel_name"` - TeamID int64 `json:"team_id,omitempty"` - CreatorID int64 `json:"creator_id,omitempty"` - } `json:"items"` - } `json:"data,omitempty"` - } - if err := parseResponse(resp, &result); err != nil { - return nil, err - } - if result.Error != nil { - return nil, fmt.Errorf("API error: %s - %s", result.Error.Code, result.Error.Message) - } - - channelMap := make(map[int64]ChannelInfo) - if result.Data != nil { - for _, item := range result.Data.Items { - channelMap[item.ChannelID] = ChannelInfo{ - ChannelID: item.ChannelID, - ChannelName: item.ChannelName, - TeamID: item.TeamID, - CreatorID: item.CreatorID, - } - } - } - return channelMap, nil -} - -// enrichChannels enriches channel information with team and creator names -func (c *Client) enrichChannels(ctx context.Context, channels []ChannelInfo) ([]ChannelInfo, error) { - if len(channels) == 0 { - return channels, nil - } - - // Collect all team IDs and creator IDs - teamIDs := make([]int64, 0) - personIDs := make([]int64, 0) - for _, ch := range channels { - if ch.TeamID != 0 { - teamIDs = append(teamIDs, ch.TeamID) - } - if ch.CreatorID != 0 { - personIDs = append(personIDs, ch.CreatorID) - } - } - - // Fetch team and person info concurrently - var teamMap map[int64]TeamInfo - var personMap map[int64]PersonInfo - g, gctx := errgroup.WithContext(ctx) - - g.Go(func() error { - var err error - teamMap, err = c.fetchTeamInfos(gctx, teamIDs) - if err != nil { - // Graceful degradation: continue without team names - teamMap = make(map[int64]TeamInfo) - } - return nil - }) - - g.Go(func() error { - var err error - personMap, err = c.fetchPersonInfos(gctx, personIDs) - if err != nil { - // Graceful degradation: continue without creator names - personMap = make(map[int64]PersonInfo) - } - return nil - }) - - _ = g.Wait() - - // Enrich channels - enriched := make([]ChannelInfo, len(channels)) - for i, ch := range channels { - enriched[i] = ch - if t, ok := teamMap[ch.TeamID]; ok { - enriched[i].TeamName = t.TeamName - } - if p, ok := personMap[ch.CreatorID]; ok { - enriched[i].CreatorName = p.PersonName - } - } - - return enriched, nil -} - -// RawIncident represents raw incident data from API -type RawIncident struct { - IncidentID string `json:"incident_id"` - Title string `json:"title"` - Description string `json:"description,omitempty"` - Severity string `json:"incident_severity"` - Progress string `json:"progress"` - StartTime int64 `json:"start_time"` - AckTime int64 `json:"ack_time,omitempty"` - CloseTime int64 `json:"close_time,omitempty"` - ChannelID int64 `json:"channel_id,omitempty"` - CreatorID int64 `json:"creator_id,omitempty"` - CloserID int64 `json:"closer_id,omitempty"` - Responders []RawResponder `json:"responders,omitempty"` - Labels map[string]string `json:"labels,omitempty"` - Fields map[string]any `json:"fields,omitempty"` -} - -// RawResponder represents raw responder data from API -type RawResponder struct { - PersonID int64 `json:"person_id"` - AssignedAt int64 `json:"assigned_at,omitempty"` - AcknowledgedAt int64 `json:"acknowledged_at,omitempty"` -} - -// enrichIncidents enriches incidents with person and channel names (without timeline/alerts) -func (c *Client) enrichIncidents(ctx context.Context, rawIncidents []RawIncident) ([]EnrichedIncident, error) { - // Collect all person IDs and channel IDs - personIDs := make([]int64, 0) - channelIDs := make([]int64, 0) - - for _, inc := range rawIncidents { - if inc.CreatorID != 0 { - personIDs = append(personIDs, inc.CreatorID) - } - if inc.CloserID != 0 { - personIDs = append(personIDs, inc.CloserID) - } - for _, r := range inc.Responders { - if r.PersonID != 0 { - personIDs = append(personIDs, r.PersonID) - } - } - if inc.ChannelID != 0 { - channelIDs = append(channelIDs, inc.ChannelID) - } - } - - // Fetch person and channel info concurrently - var personMap map[int64]PersonInfo - var channelMap map[int64]ChannelInfo - g, ctx := errgroup.WithContext(ctx) - - g.Go(func() error { - var err error - personMap, err = c.fetchPersonInfos(ctx, personIDs) - return err - }) - - g.Go(func() error { - var err error - channelMap, err = c.fetchChannelInfos(ctx, channelIDs) - return err - }) - - if err := g.Wait(); err != nil { - return nil, err - } - - // Build enriched incidents - enriched := make([]EnrichedIncident, 0, len(rawIncidents)) - for _, raw := range rawIncidents { - inc := EnrichedIncident{ - IncidentID: raw.IncidentID, - Title: raw.Title, - Description: raw.Description, - Severity: raw.Severity, - Progress: raw.Progress, - StartTime: raw.StartTime, - AckTime: raw.AckTime, - CloseTime: raw.CloseTime, - ChannelID: raw.ChannelID, - CreatorID: raw.CreatorID, - CloserID: raw.CloserID, - Labels: raw.Labels, - CustomFields: raw.Fields, - } - - // Enrich channel name - if ch, ok := channelMap[raw.ChannelID]; ok { - inc.ChannelName = ch.ChannelName - } - - // Enrich creator - if p, ok := personMap[raw.CreatorID]; ok { - inc.CreatorName = p.PersonName - inc.CreatorEmail = p.Email - } - - // Enrich closer - if p, ok := personMap[raw.CloserID]; ok { - inc.CloserName = p.PersonName - } - - // Enrich responders - if len(raw.Responders) > 0 { - inc.Responders = make([]EnrichedResponder, 0, len(raw.Responders)) - for _, r := range raw.Responders { - er := EnrichedResponder{ - PersonID: r.PersonID, - AssignedAt: r.AssignedAt, - AcknowledgedAt: r.AcknowledgedAt, - } - if p, ok := personMap[r.PersonID]; ok { - er.PersonName = p.PersonName - er.Email = p.Email - } - inc.Responders = append(inc.Responders, er) - } - } - - enriched = append(enriched, inc) - } - - return enriched, nil -} - -// collectTimelinePersonIDs extracts all person IDs from timeline items (including nested IDs in detail) -func collectTimelinePersonIDs(items []RawTimelineItem) []int64 { - personIDs := make([]int64, 0) - - for _, item := range items { - if item.PersonID != 0 { - personIDs = append(personIDs, item.PersonID) - } - - if item.Detail == nil { - continue - } - - switch item.Type { - case "i_assign", "i_a_rspd": - personIDs = extractPersonIDsFromDetail(item.Detail, "to", personIDs) - personIDs = extractPersonIDsFromDetail(item.Detail, "person_ids", personIDs) - case "i_notify": - personIDs = extractPersonIDsFromDetail(item.Detail, "to", personIDs) - } - } - - return personIDs -} - -// extractPersonIDsFromDetail extracts person IDs from a detail map field -func extractPersonIDsFromDetail(detail map[string]any, field string, personIDs []int64) []int64 { - if values, ok := detail[field].([]interface{}); ok { - for _, v := range values { - if id, ok := toInt64(v); ok && id != 0 { - personIDs = append(personIDs, id) - } - } - } - return personIDs -} - -// toInt64 converts interface{} to int64 -func toInt64(v interface{}) (int64, bool) { - switch n := v.(type) { - case float64: - return int64(n), true - case int64: - return n, true - case int: - return int64(n), true - } - return 0, false -} - -// enrichTimelineItems enriches raw timeline items with person names -func enrichTimelineItems(items []RawTimelineItem, personMap map[int64]PersonInfo) []TimelineEvent { - events := make([]TimelineEvent, 0, len(items)) - - for _, item := range items { - event := TimelineEvent{ - Type: item.Type, - Timestamp: item.CreatedAt, - OperatorID: item.PersonID, - } - - // Enrich operator name - if p, ok := personMap[item.PersonID]; ok { - event.OperatorName = p.PersonName - } - - // Build enriched detail based on event type - event.Detail = enrichTimelineDetail(item.Type, item.Detail, personMap) - - events = append(events, event) - } - - return events -} - -// enrichTimelineDetail enriches the detail field based on event type -func enrichTimelineDetail(eventType string, detail map[string]any, personMap map[int64]PersonInfo) any { - if detail == nil { - return nil - } - - enriched := copyMap(detail) - - switch eventType { - case "i_notify": - enrichPersonIDsField(enriched, "to", personMap) - case "i_assign", "i_a_rspd": - enrichPersonIDsField(enriched, "to", personMap) - enrichPersonIDsField(enriched, "person_ids", personMap) - } - - return enriched -} - -// copyMap creates a shallow copy of a map -func copyMap(m map[string]any) map[string]any { - result := make(map[string]any, len(m)) - for k, v := range m { - result[k] = v - } - return result -} - -// enrichPersonIDsField enriches a field containing person IDs with person names -func enrichPersonIDsField(enriched map[string]any, field string, personMap map[int64]PersonInfo) { - values, ok := enriched[field].([]interface{}) - if !ok { - return - } - - enrichedValues := make([]map[string]any, 0, len(values)) - for _, v := range values { - id, ok := toInt64(v) - if !ok { - continue - } - - entry := map[string]any{"person_id": id} - if p, ok := personMap[id]; ok { - entry["person_name"] = p.PersonName - } - enrichedValues = append(enrichedValues, entry) - } - enriched[field] = enrichedValues -} diff --git a/pkg/flashduty/fields.go b/pkg/flashduty/fields.go index f9a0b63..857d75a 100644 --- a/pkg/flashduty/fields.go +++ b/pkg/flashduty/fields.go @@ -3,8 +3,8 @@ package flashduty import ( "context" "fmt" - "net/http" + sdk "github.com/flashcatcloud/flashduty-sdk" "github.com/mark3labs/mcp-go/mcp" "github.com/mark3labs/mcp-go/server" @@ -32,81 +32,22 @@ func QueryFields(getClient GetFlashdutyClientFn, t translations.TranslationHelpe fieldIdsStr, _ := OptionalParam[string](request, "field_ids") fieldName, _ := OptionalParam[string](request, "field_name") - // List all fields - resp, err := client.makeRequest(ctx, "POST", "/field/list", map[string]any{}) - if err != nil { - return nil, fmt.Errorf("failed to list fields: %w", err) - } - defer func() { _ = resp.Body.Close() }() - - if resp.StatusCode != http.StatusOK { - return mcp.NewToolResultError(handleAPIError(resp).Error()), nil - } - - var result struct { - Error *DutyError `json:"error,omitempty"` - Data *struct { - Items []struct { - FieldID string `json:"field_id"` - FieldName string `json:"field_name"` - DisplayName string `json:"display_name"` - FieldType string `json:"field_type"` - ValueType string `json:"value_type"` - Options []string `json:"options,omitempty"` - DefaultValue any `json:"default_value,omitempty"` - } `json:"items"` - } `json:"data,omitempty"` - } - if err := parseResponse(resp, &result); err != nil { - return nil, err - } - if result.Error != nil { - return mcp.NewToolResultError(fmt.Sprintf("API error: %s - %s", result.Error.Code, result.Error.Message)), nil + input := &sdk.ListFieldsInput{ + FieldName: fieldName, } - // Parse filter IDs - var filterIDs []string if fieldIdsStr != "" { - filterIDs = parseCommaSeparatedStrings(fieldIdsStr) + input.FieldIDs = parseCommaSeparatedStrings(fieldIdsStr) } - fields := []FieldInfo{} - if result.Data != nil { - for _, f := range result.Data.Items { - // Filter by ID if provided - if len(filterIDs) > 0 { - found := false - for _, id := range filterIDs { - if id == f.FieldID { - found = true - break - } - } - if !found { - continue - } - } - - // Filter by name if provided - if fieldName != "" && f.FieldName != fieldName { - continue - } - - fields = append(fields, FieldInfo{ - FieldID: f.FieldID, - FieldName: f.FieldName, - DisplayName: f.DisplayName, - FieldType: f.FieldType, - ValueType: f.ValueType, - Options: f.Options, - DefaultValue: f.DefaultValue, - }) - } + output, err := client.ListFields(ctx, input) + if err != nil { + return mcp.NewToolResultError(fmt.Sprintf("Unable to retrieve fields: %v", err)), nil } return MarshalResult(map[string]any{ - "fields": fields, - "total": len(fields), + "fields": output.Fields, + "total": output.Total, }), nil } } diff --git a/pkg/flashduty/format.go b/pkg/flashduty/format.go index 5131dd3..403d36c 100644 --- a/pkg/flashduty/format.go +++ b/pkg/flashduty/format.go @@ -1,41 +1,28 @@ package flashduty import ( - "encoding/json" "fmt" - "strings" "github.com/mark3labs/mcp-go/mcp" - toon "github.com/toon-format/toon-go" + + sdk "github.com/flashcatcloud/flashduty-sdk" ) -// OutputFormat defines the serialization format for tool results -type OutputFormat string +// OutputFormat is a type alias for the SDK's OutputFormat. +type OutputFormat = sdk.OutputFormat const ( // OutputFormatJSON uses standard JSON serialization (default) - OutputFormatJSON OutputFormat = "json" + OutputFormatJSON = sdk.OutputFormatJSON // OutputFormatTOON uses Token-Oriented Object Notation for reduced token usage - OutputFormatTOON OutputFormat = "toon" + OutputFormatTOON = sdk.OutputFormatTOON ) -// ParseOutputFormat converts a string to OutputFormat, defaulting to JSON -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) -} +// ParseOutputFormat converts a string to OutputFormat, defaulting to JSON. +var ParseOutputFormat = sdk.ParseOutputFormat // outputFormat is the current output format setting (package-level for simplicity) -var outputFormat = OutputFormatJSON +var outputFormat OutputFormat = OutputFormatJSON // SetOutputFormat sets the global output format func SetOutputFormat(format OutputFormat) { @@ -55,26 +42,16 @@ func MarshalResult(v any) *mcp.CallToolResult { // MarshalResultWithFormat serializes the given value using the specified format func MarshalResultWithFormat(v any, format OutputFormat) *mcp.CallToolResult { - var data []byte - var err error - - switch format { - case OutputFormatTOON: - data, err = toon.Marshal(v) - default: - data, err = json.Marshal(v) - } - + data, err := sdk.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 { - r, _ := json.Marshal(v) - return mcp.NewToolResultText(string(r)) + data, _ := sdk.Marshal(v, OutputFormatJSON) + return mcp.NewToolResultText(string(data)) } diff --git a/pkg/flashduty/incidents.go b/pkg/flashduty/incidents.go index 692abac..0165578 100644 --- a/pkg/flashduty/incidents.go +++ b/pkg/flashduty/incidents.go @@ -4,13 +4,10 @@ import ( "context" "encoding/json" "fmt" - "net/http" - "strconv" - "strings" + sdk "github.com/flashcatcloud/flashduty-sdk" "github.com/mark3labs/mcp-go/mcp" "github.com/mark3labs/mcp-go/server" - "golang.org/x/sync/errgroup" "github.com/flashcatcloud/flashduty-mcp-server/pkg/translations" ) @@ -62,63 +59,36 @@ func QueryIncidents(getClient GetFlashdutyClientFn, t translations.TranslationHe limit = defaultQueryLimit } - var rawIncidents []RawIncident + // Build SDK input + input := &sdk.ListIncidentsInput{ + Progress: progress, + Severity: severity, + ChannelID: int64(channelID), + StartTime: int64(startTime), + EndTime: int64(endTime), + Title: title, + Limit: limit, + IncludeAlerts: includeAlerts, + } - // Query by IDs or by filters if incidentIdsStr != "" { incidentIDs := parseCommaSeparatedStrings(incidentIdsStr) if len(incidentIDs) == 0 { return mcp.NewToolResultError("incident_ids must contain at least one valid ID when specified"), nil } - rawIncidents, err = client.fetchIncidentsByIDs(ctx, incidentIDs) - } else { - if startTime == 0 || endTime == 0 { - return mcp.NewToolResultError("Both start_time and end_time are required for time-based queries"), nil - } - rawIncidents, err = client.fetchIncidentsByFilters(ctx, progress, severity, channelID, startTime, endTime, title, limit) + input.IncidentIDs = incidentIDs + } else if startTime == 0 || endTime == 0 { + return mcp.NewToolResultError("Both start_time and end_time are required for time-based queries"), nil } + output, err := client.ListIncidents(ctx, input) if err != nil { return mcp.NewToolResultError(fmt.Sprintf("Unable to retrieve incidents: %v", err)), nil } - if len(rawIncidents) == 0 { - return MarshalResult(map[string]any{ - "incidents": []EnrichedIncident{}, - "total": 0, - }), nil - } - - // Enrich incidents with person/channel names - enrichedIncidents, err := client.enrichIncidents(ctx, rawIncidents) - if err != nil { - return mcp.NewToolResultError(fmt.Sprintf("Unable to load additional incident details: %v", err)), nil - } - - // Fetch alerts concurrently if requested - if includeAlerts && len(enrichedIncidents) > 0 { - g, gctx := errgroup.WithContext(ctx) - for i := range enrichedIncidents { - i := i - incidentID := enrichedIncidents[i].IncidentID - g.Go(func() error { - alerts, total, err := client.fetchIncidentAlerts(gctx, incidentID, defaultQueryLimit) - if err != nil { - return err - } - enrichedIncidents[i].AlertsPreview = alerts - enrichedIncidents[i].AlertsTotal = total - return nil - }) - } - if err := g.Wait(); err != nil { - return mcp.NewToolResultError(fmt.Sprintf("Unable to retrieve alerts: %v", err)), nil - } - } - return MarshalResult(map[string]any{ - "incidents": enrichedIncidents, - "total": len(enrichedIncidents), + "incidents": output.Incidents, + "total": output.Total, }), nil } } @@ -150,50 +120,18 @@ func QueryIncidentTimeline(getClient GetFlashdutyClientFn, t translations.Transl return mcp.NewToolResultError("incident_ids must contain at least one valid ID"), nil } - // Fetch all timelines concurrently - type timelineResult struct { - IncidentID string - Items []RawTimelineItem - } - results := make([]timelineResult, len(incidentIDs)) - allPersonIDs := make([]int64, 0) - - g, gctx := errgroup.WithContext(ctx) - for i, id := range incidentIDs { - i, id := i, id - g.Go(func() error { - items, err := client.fetchIncidentTimeline(gctx, id) - if err != nil { - return err - } - results[i] = timelineResult{IncidentID: id, Items: items} - return nil - }) - } - - if err := g.Wait(); err != nil { - return mcp.NewToolResultError(fmt.Sprintf("Unable to retrieve timeline: %v", err)), nil - } - - // Collect all person IDs from all timelines - for _, r := range results { - allPersonIDs = append(allPersonIDs, collectTimelinePersonIDs(r.Items)...) - } - - // Batch fetch person info (use original ctx, not errgroup's ctx) - personMap, err := client.fetchPersonInfos(ctx, allPersonIDs) + results, err := client.GetIncidentTimelines(ctx, incidentIDs) if err != nil { - return mcp.NewToolResultError(fmt.Sprintf("Unable to load person details: %v", err)), nil + return mcp.NewToolResultError(fmt.Sprintf("Unable to retrieve timeline: %v", err)), nil } - // Build enriched response + // Build response matching expected JSON shape response := make([]map[string]any, 0, len(results)) for _, r := range results { - enrichedEvents := enrichTimelineItems(r.Items, personMap) response = append(response, map[string]any{ "incident_id": r.IncidentID, - "timeline": enrichedEvents, - "total": len(enrichedEvents), + "timeline": r.Timeline, + "total": r.Total, }) } @@ -236,32 +174,12 @@ func QueryIncidentAlerts(getClient GetFlashdutyClientFn, t translations.Translat limit = defaultQueryLimit } - // Fetch all alerts concurrently - type alertsResult struct { - IncidentID string - Alerts []AlertPreview - Total int - } - results := make([]alertsResult, len(incidentIDs)) - - g, gctx := errgroup.WithContext(ctx) - for i, id := range incidentIDs { - i, id := i, id - g.Go(func() error { - alerts, total, err := client.fetchIncidentAlerts(gctx, id, limit) - if err != nil { - return err - } - results[i] = alertsResult{IncidentID: id, Alerts: alerts, Total: total} - return nil - }) - } - - if err := g.Wait(); err != nil { + results, err := client.ListIncidentAlerts(ctx, incidentIDs, limit) + if err != nil { return mcp.NewToolResultError(fmt.Sprintf("Unable to retrieve alerts: %v", err)), nil } - // Build response + // Build response matching expected JSON shape response := make([]map[string]any, 0, len(results)) for _, r := range results { response = append(response, map[string]any{ @@ -277,90 +195,6 @@ func QueryIncidentAlerts(getClient GetFlashdutyClientFn, t translations.Translat } } -// fetchIncidentsByIDs fetches incidents by their IDs -func (c *Client) fetchIncidentsByIDs(ctx context.Context, incidentIDs []string) ([]RawIncident, error) { - requestBody := map[string]interface{}{ - "incident_ids": incidentIDs, - } - - resp, err := c.makeRequest(ctx, "POST", "/incident/list-by-ids", requestBody) - if err != nil { - return nil, err - } - defer func() { _ = resp.Body.Close() }() - - if resp.StatusCode != http.StatusOK { - return nil, handleAPIError(resp) - } - - var result struct { - Error *DutyError `json:"error,omitempty"` - Data *struct { - Items []RawIncident `json:"items"` - } `json:"data,omitempty"` - } - if err := parseResponse(resp, &result); err != nil { - return nil, err - } - if result.Error != nil { - return nil, fmt.Errorf("API error: %s - %s", result.Error.Code, result.Error.Message) - } - if result.Data == nil { - return nil, nil - } - return result.Data.Items, nil -} - -// fetchIncidentsByFilters fetches incidents by filters -func (c *Client) fetchIncidentsByFilters(ctx context.Context, progress, severity string, channelID int, startTime, endTime int, title string, limit int) ([]RawIncident, error) { - requestBody := map[string]interface{}{ - "p": 1, - "limit": limit, - "start_time": startTime, - "end_time": endTime, - } - - if progress != "" { - requestBody["progress"] = progress - } - if severity != "" { - requestBody["incident_severity"] = severity - } - if channelID > 0 { - requestBody["channel_id"] = channelID - } - if title != "" { - requestBody["title"] = title - } - - resp, err := c.makeRequest(ctx, "POST", "/incident/list", requestBody) - if err != nil { - return nil, err - } - defer func() { _ = resp.Body.Close() }() - - if resp.StatusCode != http.StatusOK { - return nil, handleAPIError(resp) - } - - var result struct { - Error *DutyError `json:"error,omitempty"` - Data *struct { - Items []RawIncident `json:"items"` - } `json:"data,omitempty"` - } - if err := parseResponse(resp, &result); err != nil { - return nil, err - } - if result.Error != nil { - return nil, fmt.Errorf("API error: %s - %s", result.Error.Code, result.Error.Message) - } - if result.Data == nil { - return nil, nil - } - return result.Data.Items, nil -} - const createIncidentDescription = `Create a new incident with title and severity. Optionally assign to channel or responders.` // CreateIncident creates a tool to create a new incident @@ -394,43 +228,25 @@ func CreateIncident(getClient GetFlashdutyClientFn, t translations.TranslationHe channelID, _ := OptionalInt(request, "channel_id") description, _ := OptionalParam[string](request, "description") - assignedTo, _ := OptionalParam[string](request, "assigned_to") + assignedToStr, _ := OptionalParam[string](request, "assigned_to") - requestBody := map[string]interface{}{ - "title": title, - "incident_severity": severity, - } - if channelID > 0 { - requestBody["channel_id"] = channelID - } - if description != "" { - requestBody["description"] = description - } - if assignedTo != "" { - personIDs := parseCommaSeparatedInts(assignedTo) - if len(personIDs) > 0 { - requestBody["assigned_to"] = map[string]interface{}{ - "type": "assign", - "person_ids": personIDs, - } - } + input := &sdk.CreateIncidentInput{ + Title: title, + Severity: severity, + ChannelID: int64(channelID), + Description: description, } - resp, err := client.makeRequest(ctx, "POST", "/incident/create", requestBody) - if err != nil { - return nil, fmt.Errorf("failed to create incident: %w", err) + if assignedToStr != "" { + input.AssignedTo = parseCommaSeparatedInts(assignedToStr) } - defer func() { _ = resp.Body.Close() }() - var result FlashdutyResponse - if err := parseResponse(resp, &result); err != nil { - return nil, err - } - if result.Error != nil { - return mcp.NewToolResultError(fmt.Sprintf("API error: %s - %s", result.Error.Code, result.Error.Message)), nil + result, err := client.CreateIncident(ctx, input) + if err != nil { + return mcp.NewToolResultError(fmt.Sprintf("Unable to create incident: %v", err)), nil } - return MarshalResult(result.Data), nil + return MarshalResult(result), nil } } @@ -465,71 +281,28 @@ func UpdateIncident(getClient GetFlashdutyClientFn, t translations.TranslationHe severity, _ := OptionalParam[string](request, "severity") customFieldsStr, _ := OptionalParam[string](request, "custom_fields") - updatedFields := make([]string, 0) - - // Update title - if title != "" { - if err := client.updateIncidentField(ctx, incidentID, "/incident/title/reset", "title", title); err != nil { - return mcp.NewToolResultError(fmt.Sprintf("Unable to update title: %v", err)), nil - } - updatedFields = append(updatedFields, "title") - } - - // Update description - if description != "" { - if err := client.updateIncidentField(ctx, incidentID, "/incident/description/reset", "description", description); err != nil { - return mcp.NewToolResultError(fmt.Sprintf("Unable to update description: %v", err)), nil - } - updatedFields = append(updatedFields, "description") + input := &sdk.UpdateIncidentInput{ + IncidentID: incidentID, + Title: title, + Description: description, + Severity: severity, } - // Update severity - if severity != "" { - if err := client.updateIncidentField(ctx, incidentID, "/incident/severity/reset", "incident_severity", severity); err != nil { - return mcp.NewToolResultError(fmt.Sprintf("Unable to update severity: %v", err)), nil - } - updatedFields = append(updatedFields, "severity") - } - - // Update custom fields + // Parse custom fields JSON if provided 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 } - if len(customFields) == 0 { return mcp.NewToolResultError("custom_fields must contain at least one field"), nil } - - // Validate field names (alphanumeric and underscore only) - for fieldName := range customFields { - if fieldName == "" { - return mcp.NewToolResultError("custom_fields contains an empty field name"), nil - } - for _, c := range fieldName { - isValid := (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '_' - if !isValid { - return mcp.NewToolResultError(fmt.Sprintf("custom field name '%s' contains invalid characters (only alphanumeric and underscore allowed)", fieldName)), nil - } - } - } - - for fieldName, fieldValue := range customFields { - if err := client.updateCustomField(ctx, incidentID, fieldName, fieldValue); err != nil { - return mcp.NewToolResultError(fmt.Sprintf("Unable to update custom field '%s': %v", fieldName, err)), nil - } - updatedFields = append(updatedFields, fieldName) - } + input.CustomFields = customFields } - if len(updatedFields) == 0 { - return mcp.NewToolResultError("No fields specified to update"), nil + updatedFields, err := client.UpdateIncident(ctx, input) + if err != nil { + return mcp.NewToolResultError(fmt.Sprintf("Unable to update incident: %v", err)), nil } return MarshalResult(map[string]any{ @@ -540,61 +313,6 @@ func UpdateIncident(getClient GetFlashdutyClientFn, t translations.TranslationHe } } -// updateIncidentField is a helper to update a single incident field -func (c *Client) updateIncidentField(ctx context.Context, incidentID, endpoint, fieldName, fieldValue string) error { - requestBody := map[string]interface{}{ - "incident_id": incidentID, - fieldName: fieldValue, - } - - resp, err := c.makeRequest(ctx, "POST", endpoint, requestBody) - if err != nil { - return err - } - defer func() { _ = resp.Body.Close() }() - - if resp.StatusCode != http.StatusOK { - return handleAPIError(resp) - } - - var result FlashdutyResponse - if err := parseResponse(resp, &result); err != nil { - return err - } - if result.Error != nil { - return fmt.Errorf("API error: %s - %s", result.Error.Code, result.Error.Message) - } - return nil -} - -// updateCustomField is a helper to update a custom field -func (c *Client) updateCustomField(ctx context.Context, incidentID, fieldName string, fieldValue any) error { - requestBody := map[string]interface{}{ - "incident_id": incidentID, - "field_name": fieldName, - "field_value": fieldValue, - } - - resp, err := c.makeRequest(ctx, "POST", "/incident/field/reset", requestBody) - if err != nil { - return err - } - defer func() { _ = resp.Body.Close() }() - - if resp.StatusCode != http.StatusOK { - return handleAPIError(resp) - } - - var result FlashdutyResponse - if err := parseResponse(resp, &result); err != nil { - return err - } - if result.Error != nil { - return fmt.Errorf("API error: %s - %s", result.Error.Code, result.Error.Message) - } - return nil -} - const ackIncidentDescription = `Acknowledge incidents. Moves status from Triggered to Processing.` // AckIncident creates a tool to acknowledge incidents @@ -622,26 +340,8 @@ func AckIncident(getClient GetFlashdutyClientFn, t translations.TranslationHelpe return mcp.NewToolResultError("incident_ids must contain at least one valid ID"), nil } - requestBody := map[string]interface{}{ - "incident_ids": incidentIDs, - } - - resp, err := client.makeRequest(ctx, "POST", "/incident/ack", requestBody) - if err != nil { - return nil, fmt.Errorf("unable to acknowledge incidents: %w", err) - } - defer func() { _ = resp.Body.Close() }() - - if resp.StatusCode != http.StatusOK { - return mcp.NewToolResultError(handleAPIError(resp).Error()), nil - } - - var result FlashdutyResponse - if err := parseResponse(resp, &result); err != nil { - return nil, err - } - if result.Error != nil { - return mcp.NewToolResultError(fmt.Sprintf("API error: %s - %s", result.Error.Code, result.Error.Message)), nil + if err := client.AckIncidents(ctx, incidentIDs); err != nil { + return mcp.NewToolResultError(fmt.Sprintf("Unable to acknowledge incidents: %v", err)), nil } return MarshalResult(map[string]string{ @@ -678,26 +378,8 @@ func CloseIncident(getClient GetFlashdutyClientFn, t translations.TranslationHel return mcp.NewToolResultError("incident_ids must contain at least one valid ID"), nil } - requestBody := map[string]interface{}{ - "incident_ids": incidentIDs, - } - - resp, err := client.makeRequest(ctx, "POST", "/incident/resolve", requestBody) - if err != nil { - return nil, fmt.Errorf("unable to close incidents: %w", err) - } - defer func() { _ = resp.Body.Close() }() - - if resp.StatusCode != http.StatusOK { - return mcp.NewToolResultError(handleAPIError(resp).Error()), nil - } - - var result FlashdutyResponse - if err := parseResponse(resp, &result); err != nil { - return nil, err - } - if result.Error != nil { - return mcp.NewToolResultError(fmt.Sprintf("API error: %s - %s", result.Error.Code, result.Error.Message)), nil + if err := client.CloseIncidents(ctx, incidentIDs); err != nil { + return mcp.NewToolResultError(fmt.Sprintf("Unable to close incidents: %v", err)), nil } return MarshalResult(map[string]string{ @@ -735,88 +417,14 @@ func ListSimilarIncidents(getClient GetFlashdutyClientFn, t translations.Transla limit = defaultQueryLimit } - requestBody := map[string]interface{}{ - "incident_id": incidentID, - "p": 1, - "limit": limit, - } - - resp, err := client.makeRequest(ctx, "POST", "/incident/past/list", requestBody) - if err != nil { - return nil, fmt.Errorf("unable to find similar incidents: %w", err) - } - defer func() { _ = resp.Body.Close() }() - - if resp.StatusCode != http.StatusOK { - return mcp.NewToolResultError(handleAPIError(resp).Error()), nil - } - - var result struct { - Error *DutyError `json:"error,omitempty"` - Data *struct { - Items []RawIncident `json:"items"` - Total int `json:"total"` - } `json:"data,omitempty"` - } - if err := parseResponse(resp, &result); err != nil { - return nil, err - } - if result.Error != nil { - return mcp.NewToolResultError(fmt.Sprintf("API error: %s - %s", result.Error.Code, result.Error.Message)), nil - } - - if result.Data == nil || len(result.Data.Items) == 0 { - return MarshalResult(map[string]any{ - "incidents": []EnrichedIncident{}, - "total": 0, - }), nil - } - - // Enrich similar incidents - enrichedIncidents, err := client.enrichIncidents(ctx, result.Data.Items) + output, err := client.ListSimilarIncidents(ctx, incidentID, limit) if err != nil { - return mcp.NewToolResultError(fmt.Sprintf("Unable to load additional incident details: %v", err)), nil + return mcp.NewToolResultError(fmt.Sprintf("Unable to find similar incidents: %v", err)), nil } return MarshalResult(map[string]any{ - "incidents": enrichedIncidents, - "total": result.Data.Total, + "incidents": output.Incidents, + "total": output.Total, }), nil } } - -// Helper functions - -func parseCommaSeparatedStrings(s string) []string { - if s == "" { - return nil - } - parts := strings.Split(s, ",") - result := make([]string, 0, len(parts)) - for _, part := range parts { - part = strings.TrimSpace(part) - if part != "" { - result = append(result, part) - } - } - return result -} - -func parseCommaSeparatedInts(s string) []int { - if s == "" { - return nil - } - parts := strings.Split(s, ",") - result := make([]int, 0, len(parts)) - for _, part := range parts { - part = strings.TrimSpace(part) - if part == "" { - continue - } - id, err := strconv.Atoi(part) - if err == nil { - result = append(result, id) - } - } - return result -} diff --git a/pkg/flashduty/server.go b/pkg/flashduty/server.go index cead868..a1fd537 100644 --- a/pkg/flashduty/server.go +++ b/pkg/flashduty/server.go @@ -2,6 +2,8 @@ package flashduty import ( "fmt" + "strconv" + "strings" "github.com/mark3labs/mcp-go/mcp" ) @@ -81,3 +83,35 @@ func OptionalInt(r mcp.CallToolRequest, p string) (int, error) { func ToBoolPtr(b bool) *bool { return &b } + +// parseCommaSeparatedStrings splits a comma-separated string into trimmed non-empty parts. +func parseCommaSeparatedStrings(s string) []string { + if s == "" { + return nil + } + parts := strings.Split(s, ",") + result := make([]string, 0, len(parts)) + for _, p := range parts { + p = strings.TrimSpace(p) + if p != "" { + result = append(result, p) + } + } + return result +} + +// parseCommaSeparatedInts splits a comma-separated string into ints, skipping invalid values. +func parseCommaSeparatedInts(s string) []int { + if s == "" { + return nil + } + parts := strings.Split(s, ",") + result := make([]int, 0, len(parts)) + for _, p := range parts { + p = strings.TrimSpace(p) + if v, err := strconv.Atoi(p); err == nil { + result = append(result, v) + } + } + return result +} diff --git a/pkg/flashduty/statuspage.go b/pkg/flashduty/statuspage.go index 4dff39a..23a7ffa 100644 --- a/pkg/flashduty/statuspage.go +++ b/pkg/flashduty/statuspage.go @@ -2,15 +2,12 @@ package flashduty import ( "context" - "encoding/json" "fmt" - "net/http" - "strings" - "time" "github.com/mark3labs/mcp-go/mcp" "github.com/mark3labs/mcp-go/server" + sdk "github.com/flashcatcloud/flashduty-sdk" "github.com/flashcatcloud/flashduty-mcp-server/pkg/translations" ) @@ -33,47 +30,6 @@ func QueryStatusPages(getClient GetFlashdutyClientFn, t translations.Translation pageIdsStr, _ := OptionalParam[string](request, "page_ids") - // List all pages first - resp, err := client.makeRequest(ctx, "GET", "/status-page/list", nil) - if err != nil { - return nil, fmt.Errorf("failed to list status pages: %w", err) - } - defer func() { _ = resp.Body.Close() }() - - if resp.StatusCode != http.StatusOK { - return mcp.NewToolResultError(handleAPIError(resp).Error()), nil - } - - var result struct { - Error *DutyError `json:"error,omitempty"` - Data *struct { - Items []struct { - PageID int64 `json:"page_id"` - PageName string `json:"name"` - URLName string `json:"url_name,omitempty"` - Description string `json:"description,omitempty"` - Components []struct { - ComponentID string `json:"component_id"` - Name string `json:"name"` - } `json:"components,omitempty"` - } `json:"items"` - } `json:"data,omitempty"` - } - if err := parseResponse(resp, &result); err != nil { - return nil, err - } - if result.Error != nil { - return mcp.NewToolResultError(fmt.Sprintf("API error: %s - %s", result.Error.Code, result.Error.Message)), nil - } - - if result.Data == nil || len(result.Data.Items) == 0 { - return MarshalResult(map[string]any{ - "pages": []StatusPage{}, - "total": 0, - }), nil - } - - // Filter by page_ids if provided var pageIDs []int64 if pageIdsStr != "" { for _, id := range parseCommaSeparatedInts(pageIdsStr) { @@ -81,44 +37,9 @@ func QueryStatusPages(getClient GetFlashdutyClientFn, t translations.Translation } } - pages := make([]StatusPage, 0) - for _, item := range result.Data.Items { - // Skip if filtering and not in list - if len(pageIDs) > 0 { - found := false - for _, id := range pageIDs { - if id == item.PageID { - found = true - break - } - } - if !found { - continue - } - } - - page := StatusPage{ - PageID: item.PageID, - PageName: item.PageName, - Slug: item.URLName, - Description: item.Description, - } - - // Convert components and calculate overall status - worstStatus := "operational" - if len(item.Components) > 0 { - page.Components = make([]StatusComponent, 0, len(item.Components)) - for _, comp := range item.Components { - page.Components = append(page.Components, StatusComponent{ - ComponentID: comp.ComponentID, - ComponentName: comp.Name, - Status: "operational", // Default status - }) - } - } - page.OverallStatus = worstStatus - - pages = append(pages, page) + pages, err := client.ListStatusPages(ctx, pageIDs) + if err != nil { + return mcp.NewToolResultError(fmt.Sprintf("failed to list status pages: %v", err)), nil } return MarshalResult(map[string]any{ @@ -160,41 +81,17 @@ func ListStatusChanges(getClient GetFlashdutyClientFn, t translations.Translatio return mcp.NewToolResultError("type must be 'incident' or 'maintenance'"), nil } - // Use GET for active list endpoint - resp, err := client.makeRequest(ctx, "GET", fmt.Sprintf("/status-page/change/active/list?page_id=%d&type=%s", pageID, changeType), nil) + output, err := client.ListStatusChanges(ctx, &sdk.ListStatusChangesInput{ + PageID: int64(pageID), + ChangeType: changeType, + }) if err != nil { - return nil, fmt.Errorf("failed to list status changes: %w", err) - } - defer func() { _ = resp.Body.Close() }() - - if resp.StatusCode != http.StatusOK { - return mcp.NewToolResultError(handleAPIError(resp).Error()), nil - } - - var result struct { - Error *DutyError `json:"error,omitempty"` - Data *struct { - Items []StatusChange `json:"items"` - Total int `json:"total"` - } `json:"data,omitempty"` - } - if err := parseResponse(resp, &result); err != nil { - return nil, err - } - if result.Error != nil { - return mcp.NewToolResultError(fmt.Sprintf("API error: %s - %s", result.Error.Code, result.Error.Message)), nil - } - - changes := []StatusChange{} - total := 0 - if result.Data != nil { - changes = result.Data.Items - total = result.Data.Total + return mcp.NewToolResultError(fmt.Sprintf("failed to list status changes: %v", err)), nil } return MarshalResult(map[string]any{ - "changes": changes, - "total": total, + "changes": output.Changes, + "total": output.Total, }), nil } } @@ -236,76 +133,19 @@ func CreateStatusIncident(getClient GetFlashdutyClientFn, t translations.Transla affectedComponents, _ := OptionalParam[string](request, "affected_components") notifySubscribers, _ := OptionalParam[bool](request, "notify_subscribers") - if status == "" { - status = "investigating" - } - - // Build the initial update - update := map[string]interface{}{ - "at_seconds": time.Now().Unix(), - "status": status, - } - if message != "" { - update["description"] = message - } - - // Parse component changes if provided (format: "id1:status1,id2:status2") - if affectedComponents != "" { - var componentChanges []map[string]string - parts := parseCommaSeparatedStrings(affectedComponents) - for _, part := range parts { - kv := strings.SplitN(part, ":", 2) - if len(kv) == 2 { - componentChanges = append(componentChanges, map[string]string{ - "component_id": strings.TrimSpace(kv[0]), - "status": strings.TrimSpace(kv[1]), - }) - } else if len(kv) == 1 && kv[0] != "" { - // Default to partial_outage if no status specified - componentChanges = append(componentChanges, map[string]string{ - "component_id": strings.TrimSpace(kv[0]), - "status": "partial_outage", - }) - } - } - if len(componentChanges) > 0 { - update["component_changes"] = componentChanges - } - } - - // Use message as both change description and first update description - description := message - if description == "" { - description = title // Fallback to title if no message provided - } - - requestBody := map[string]interface{}{ - "page_id": pageID, - "title": title, - "type": "incident", - "status": status, - "description": description, - "updates": []map[string]interface{}{update}, - } - - // Default notify_subscribers to true - requestBody["notify_subscribers"] = notifySubscribers - - resp, err := client.makeRequest(ctx, "POST", "/status-page/change/create", requestBody) + data, err := client.CreateStatusIncident(ctx, &sdk.CreateStatusIncidentInput{ + PageID: int64(pageID), + Title: title, + Message: message, + Status: status, + AffectedComponents: affectedComponents, + NotifySubscribers: notifySubscribers, + }) if err != nil { - return nil, fmt.Errorf("failed to create status incident: %w", err) - } - defer func() { _ = resp.Body.Close() }() - - var result FlashdutyResponse - if err := parseResponse(resp, &result); err != nil { - return nil, err - } - if result.Error != nil { - return mcp.NewToolResultError(fmt.Sprintf("API error: %s - %s", result.Error.Code, result.Error.Message)), nil + return mcp.NewToolResultError(fmt.Sprintf("failed to create status incident: %v", err)), nil } - return MarshalResult(result.Data), nil + return MarshalResult(data), nil } } @@ -350,37 +190,16 @@ func CreateChangeTimeline(getClient GetFlashdutyClientFn, t translations.Transla status, _ := OptionalParam[string](request, "status") componentChanges, _ := OptionalParam[string](request, "component_changes") - requestBody := map[string]interface{}{ - "page_id": pageID, - "change_id": changeID, - "description": message, - } - if at > 0 { - requestBody["at_seconds"] = at - } - if status != "" { - requestBody["status"] = status - } - if componentChanges != "" { - // Parse JSON array if provided - var changes []map[string]string - if err := json.Unmarshal([]byte(componentChanges), &changes); err == nil { - requestBody["component_changes"] = changes - } - } - - resp, err := client.makeRequest(ctx, "POST", "/status-page/change/timeline/create", requestBody) + err = client.CreateChangeTimeline(ctx, &sdk.CreateChangeTimelineInput{ + PageID: int64(pageID), + ChangeID: int64(changeID), + Message: message, + AtSeconds: int64(at), + Status: status, + ComponentChanges: componentChanges, + }) if err != nil { - return nil, fmt.Errorf("failed to create timeline: %w", err) - } - defer func() { _ = resp.Body.Close() }() - - var result FlashdutyResponse - if err := parseResponse(resp, &result); err != nil { - return nil, err - } - if result.Error != nil { - return mcp.NewToolResultError(fmt.Sprintf("API error: %s - %s", result.Error.Code, result.Error.Message)), nil + return mcp.NewToolResultError(fmt.Sprintf("failed to create timeline: %v", err)), nil } return MarshalResult(map[string]string{ diff --git a/pkg/flashduty/templates.go b/pkg/flashduty/templates.go new file mode 100644 index 0000000..51ad36e --- /dev/null +++ b/pkg/flashduty/templates.go @@ -0,0 +1,161 @@ +package flashduty + +import ( + "context" + "fmt" + + sdk "github.com/flashcatcloud/flashduty-sdk" + "github.com/mark3labs/mcp-go/mcp" + "github.com/mark3labs/mcp-go/server" + + "github.com/flashcatcloud/flashduty-mcp-server/pkg/translations" +) + +// --- 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.` + +// GetPresetTemplate creates a tool to fetch the preset template for a channel. +func GetPresetTemplate(getClient GetFlashdutyClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { + return mcp.NewTool("get_preset_template", + mcp.WithDescription(t("TOOL_GET_PRESET_TEMPLATE_DESCRIPTION", getPresetTemplateDescription)), + mcp.WithToolAnnotation(mcp.ToolAnnotation{ + Title: t("TOOL_GET_PRESET_TEMPLATE_USER_TITLE", "Get preset template"), + ReadOnlyHint: ToBoolPtr(true), + }), + mcp.WithString("channel", + mcp.Required(), + mcp.Description("The notification channel to get the preset template for."), + mcp.Enum(sdk.ChannelEnumValues()...), + ), + mcp.WithString("locale", + mcp.Description("Locale for the preset template. Defaults to zh-CN."), + mcp.Enum("zh-CN", "en-US"), + ), + ), func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + ctx, client, err := getClient(ctx) + if err != nil { + return nil, fmt.Errorf("failed to get Flashduty client: %w", err) + } + + channel, err := RequiredParam[string](request, "channel") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + + locale, _ := OptionalParam[string](request, "locale") + if locale == "" { + locale = "zh-CN" + } + + input := &sdk.GetPresetTemplateInput{ + Channel: channel, + Locale: locale, + } + + output, err := client.GetPresetTemplate(ctx, input) + if err != nil { + return mcp.NewToolResultError(fmt.Sprintf("Unable to fetch preset template: %v", err)), nil + } + + return MarshalResult(output), nil + } +} + +// --- 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.` + +// ValidateTemplate creates a tool to validate and preview a template. +func ValidateTemplate(getClient GetFlashdutyClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { + return mcp.NewTool("validate_template", + mcp.WithDescription(t("TOOL_VALIDATE_TEMPLATE_DESCRIPTION", validateTemplateDescription)), + mcp.WithToolAnnotation(mcp.ToolAnnotation{ + Title: t("TOOL_VALIDATE_TEMPLATE_USER_TITLE", "Validate template"), + ReadOnlyHint: ToBoolPtr(true), + }), + mcp.WithString("channel", + mcp.Required(), + mcp.Description("The notification channel this template is for."), + mcp.Enum(sdk.ChannelEnumValues()...), + ), + mcp.WithString("template_code", + mcp.Required(), + mcp.Description("The Go template code to validate and preview."), + ), + mcp.WithString("incident_id", + mcp.Description("Optional incident ID for real data preview. If omitted, uses mock data."), + ), + ), func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + ctx, client, err := getClient(ctx) + if err != nil { + return nil, fmt.Errorf("failed to get Flashduty client: %w", err) + } + + channel, err := RequiredParam[string](request, "channel") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + + templateCode, err := RequiredParam[string](request, "template_code") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + + incidentID, _ := OptionalParam[string](request, "incident_id") + + input := &sdk.ValidateTemplateInput{ + Channel: channel, + TemplateCode: templateCode, + IncidentID: incidentID, + } + + output, err := client.ValidateTemplate(ctx, input) + if err != nil { + return mcp.NewToolResultError(fmt.Sprintf("Unable to validate template: %v", err)), nil + } + + return MarshalResult(output), nil + } +} + +// --- Tool 3: list_template_variables --- + +const listTemplateVariablesDescription = `List all available template variables that can be used in notification templates. Returns typed variable schema with descriptions and example values.` + +// ListTemplateVariables creates a tool that returns the available template variable schema. +func ListTemplateVariables(_ GetFlashdutyClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { + return mcp.NewTool("list_template_variables", + mcp.WithDescription(t("TOOL_LIST_TEMPLATE_VARIABLES_DESCRIPTION", listTemplateVariablesDescription)), + mcp.WithToolAnnotation(mcp.ToolAnnotation{ + Title: t("TOOL_LIST_TEMPLATE_VARIABLES_USER_TITLE", "List template variables"), + ReadOnlyHint: ToBoolPtr(true), + }), + ), func(_ context.Context, _ mcp.CallToolRequest) (*mcp.CallToolResult, error) { + variables := sdk.TemplateVariables() + return MarshalResult(map[string]any{ + "variables": variables, + "total": len(variables), + }), nil + } +} + +// --- Tool 4: list_template_functions --- + +const listTemplateFunctionsDescription = `List all available template functions that can be used in notification templates. Includes custom FlashDuty functions and commonly used Sprig functions.` + +// ListTemplateFunctions creates a tool that returns the available template functions. +func ListTemplateFunctions(_ GetFlashdutyClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { + return mcp.NewTool("list_template_functions", + mcp.WithDescription(t("TOOL_LIST_TEMPLATE_FUNCTIONS_DESCRIPTION", listTemplateFunctionsDescription)), + mcp.WithToolAnnotation(mcp.ToolAnnotation{ + Title: t("TOOL_LIST_TEMPLATE_FUNCTIONS_USER_TITLE", "List template functions"), + ReadOnlyHint: ToBoolPtr(true), + }), + ), func(_ context.Context, _ mcp.CallToolRequest) (*mcp.CallToolResult, error) { + return MarshalResult(map[string]any{ + "custom_functions": sdk.TemplateCustomFunctions(), + "sprig_functions": sdk.TemplateSprigFunctions(), + }), nil + } +} diff --git a/pkg/flashduty/tools.go b/pkg/flashduty/tools.go index f459b84..93d6813 100644 --- a/pkg/flashduty/tools.go +++ b/pkg/flashduty/tools.go @@ -6,7 +6,7 @@ import ( ) // DefaultTools is the default list of enabled Flashduty toolsets -var DefaultTools = []string{"incidents", "changes", "status_page", "users", "channels", "fields"} +var DefaultTools = []string{"incidents", "changes", "status_page", "users", "channels", "fields", "templates"} // DefaultToolsetGroup returns the default toolset group for Flashduty func DefaultToolsetGroup(getClient GetFlashdutyClientFn, readOnly bool, t translations.TranslationHelperFunc) *toolsets.ToolsetGroup { @@ -70,5 +70,15 @@ func DefaultToolsetGroup(getClient GetFlashdutyClientFn, readOnly bool, t transl ) group.AddToolset(fields) + // Templates toolset (4 tools) + templates := toolsets.NewToolset("templates", "Notification template management and validation tools"). + AddReadTools( + toolsets.NewServerTool(GetPresetTemplate(getClient, t)), + toolsets.NewServerTool(ValidateTemplate(getClient, t)), + toolsets.NewServerTool(ListTemplateVariables(getClient, t)), + toolsets.NewServerTool(ListTemplateFunctions(getClient, t)), + ) + group.AddToolset(templates) + return group } diff --git a/pkg/flashduty/types.go b/pkg/flashduty/types.go deleted file mode 100644 index 0dd9ffa..0000000 --- a/pkg/flashduty/types.go +++ /dev/null @@ -1,279 +0,0 @@ -package flashduty - -// EnrichedIncident contains full incident data with human-readable names -type EnrichedIncident struct { - // Basic fields - IncidentID string `json:"incident_id" toon:"incident_id"` - Title string `json:"title" toon:"title"` - Description string `json:"description,omitempty" toon:"description,omitempty"` - Severity string `json:"severity" toon:"severity"` - Progress string `json:"progress" toon:"progress"` - - // Time fields - StartTime int64 `json:"start_time" toon:"start_time"` - AckTime int64 `json:"ack_time,omitempty" toon:"ack_time,omitempty"` - CloseTime int64 `json:"close_time,omitempty" toon:"close_time,omitempty"` - - // Channel (enriched) - ChannelID int64 `json:"channel_id,omitempty" toon:"channel_id,omitempty"` - ChannelName string `json:"channel_name,omitempty" toon:"channel_name,omitempty"` - - // Creator (enriched) - CreatorID int64 `json:"creator_id,omitempty" toon:"creator_id,omitempty"` - CreatorName string `json:"creator_name,omitempty" toon:"creator_name,omitempty"` - CreatorEmail string `json:"creator_email,omitempty" toon:"creator_email,omitempty"` - - // Closer (enriched) - CloserID int64 `json:"closer_id,omitempty" toon:"closer_id,omitempty"` - CloserName string `json:"closer_name,omitempty" toon:"closer_name,omitempty"` - - // Responders (enriched) - Responders []EnrichedResponder `json:"responders,omitempty" toon:"responders,omitempty"` - - // Timeline (full) - Timeline []TimelineEvent `json:"timeline,omitempty" toon:"timeline,omitempty"` - - // Alerts (preview) - AlertsPreview []AlertPreview `json:"alerts_preview,omitempty" toon:"alerts_preview,omitempty"` - AlertsTotal int `json:"alerts_total" toon:"alerts_total"` - - // Other - Labels map[string]string `json:"labels,omitempty" toon:"labels,omitempty"` - CustomFields map[string]any `json:"custom_fields,omitempty" toon:"custom_fields,omitempty"` -} - -// EnrichedResponder contains responder info with human-readable names -type EnrichedResponder struct { - PersonID int64 `json:"person_id" toon:"person_id"` - PersonName string `json:"person_name" toon:"person_name"` - Email string `json:"email,omitempty" toon:"email,omitempty"` - AssignedAt int64 `json:"assigned_at,omitempty" toon:"assigned_at,omitempty"` - AcknowledgedAt int64 `json:"acknowledged_at,omitempty" toon:"acknowledged_at,omitempty"` -} - -// TimelineEvent represents an entry in incident timeline -type TimelineEvent struct { - Type string `json:"type" toon:"type"` - Timestamp int64 `json:"timestamp" toon:"timestamp"` - OperatorID int64 `json:"operator_id,omitempty" toon:"operator_id,omitempty"` - OperatorName string `json:"operator_name,omitempty" toon:"operator_name,omitempty"` - Detail any `json:"detail,omitempty" toon:"detail,omitempty"` -} - -// AlertPreview represents a preview of an alert -type AlertPreview struct { - AlertID string `json:"alert_id" toon:"alert_id"` - Title string `json:"title" toon:"title"` - Severity string `json:"severity" toon:"severity"` - Status string `json:"status" toon:"status"` - StartTime int64 `json:"start_time" toon:"start_time"` - Labels map[string]string `json:"labels,omitempty" toon:"labels,omitempty"` -} - -// PersonInfo represents person information from /person/infos API -type PersonInfo struct { - PersonID int64 `json:"person_id" toon:"person_id"` - PersonName string `json:"person_name" toon:"person_name"` - Email string `json:"email,omitempty" toon:"email,omitempty"` - Avatar string `json:"avatar,omitempty" toon:"avatar,omitempty"` - As string `json:"as,omitempty" toon:"as,omitempty"` -} - -// ChannelInfo represents channel information with enriched fields -type ChannelInfo struct { - ChannelID int64 `json:"channel_id" toon:"channel_id"` - ChannelName string `json:"channel_name" toon:"channel_name"` - TeamID int64 `json:"team_id,omitempty" toon:"team_id,omitempty"` - TeamName string `json:"team_name,omitempty" toon:"team_name,omitempty"` - CreatorID int64 `json:"creator_id,omitempty" toon:"creator_id,omitempty"` - CreatorName string `json:"creator_name,omitempty" toon:"creator_name,omitempty"` -} - -// TeamInfo represents team information -type TeamInfo struct { - TeamID int64 `json:"team_id" toon:"team_id"` - TeamName string `json:"team_name" toon:"team_name"` - Members []TeamMember `json:"members,omitempty" toon:"members,omitempty"` -} - -// TeamMember represents a team member -type TeamMember struct { - PersonID int64 `json:"person_id" toon:"person_id"` - PersonName string `json:"person_name" toon:"person_name"` - Email string `json:"email,omitempty" toon:"email,omitempty"` -} - -// FieldInfo represents custom field definition -type FieldInfo struct { - FieldID string `json:"field_id" toon:"field_id"` - FieldName string `json:"field_name" toon:"field_name"` - DisplayName string `json:"display_name" toon:"display_name"` - FieldType string `json:"field_type" toon:"field_type"` - ValueType string `json:"value_type" toon:"value_type"` - Options []string `json:"options,omitempty" toon:"options,omitempty"` - DefaultValue any `json:"default_value,omitempty" toon:"default_value,omitempty"` -} - -// EscalationRule represents an escalation rule with full details -type EscalationRule struct { - RuleID string `json:"rule_id" toon:"rule_id"` - RuleName string `json:"rule_name" toon:"rule_name"` - Description string `json:"description,omitempty" toon:"description,omitempty"` - ChannelID int64 `json:"channel_id" toon:"channel_id"` - ChannelName string `json:"channel_name,omitempty" toon:"channel_name,omitempty"` - Status string `json:"status,omitempty" toon:"status,omitempty"` - Priority int `json:"priority" toon:"priority"` - AggrWindow int `json:"aggr_window" toon:"aggr_window"` - Layers []EscalationLayer `json:"layers,omitempty" toon:"layers,omitempty"` - TimeFilters []TimeFilter `json:"time_filters,omitempty" toon:"time_filters,omitempty"` - Filters AlertFilters `json:"filters,omitempty" toon:"filters,omitempty"` -} - -// EscalationLayer represents a layer in an escalation rule -type EscalationLayer struct { - LayerIdx int `json:"layer_idx" toon:"layer_idx"` - Timeout int `json:"timeout" toon:"timeout"` - NotifyInterval float64 `json:"notify_interval,omitempty" toon:"notify_interval,omitempty"` - MaxTimes int `json:"max_times,omitempty" toon:"max_times,omitempty"` - ForceEscalate bool `json:"force_escalate,omitempty" toon:"force_escalate,omitempty"` - Target *EscalationTarget `json:"target,omitempty" toon:"target,omitempty"` -} - -// EscalationTarget represents the complete target configuration for a layer -type EscalationTarget struct { - Persons []PersonTarget `json:"persons,omitempty" toon:"persons,omitempty"` - Teams []TeamTarget `json:"teams,omitempty" toon:"teams,omitempty"` - Schedules []ScheduleTarget `json:"schedules,omitempty" toon:"schedules,omitempty"` - NotifyBy *NotifyBy `json:"notify_by,omitempty" toon:"notify_by,omitempty"` - Webhooks []WebhookConfig `json:"webhooks,omitempty" toon:"webhooks,omitempty"` -} - -// PersonTarget represents a person in escalation target -type PersonTarget struct { - PersonID int64 `json:"person_id" toon:"person_id"` - PersonName string `json:"person_name,omitempty" toon:"person_name,omitempty"` - Email string `json:"email,omitempty" toon:"email,omitempty"` -} - -// TeamTarget represents a team in escalation target with members -type TeamTarget struct { - TeamID int64 `json:"team_id" toon:"team_id"` - TeamName string `json:"team_name,omitempty" toon:"team_name,omitempty"` - Members []PersonTarget `json:"members,omitempty" toon:"members,omitempty"` -} - -// ScheduleTarget represents a schedule in escalation target -type ScheduleTarget struct { - ScheduleID int64 `json:"schedule_id" toon:"schedule_id"` - ScheduleName string `json:"schedule_name,omitempty" toon:"schedule_name,omitempty"` - RoleIDs []int64 `json:"role_ids,omitempty" toon:"role_ids,omitempty"` -} - -// NotifyBy represents direct message notification configuration -// When follow_preference is true, notifications follow each person's preference settings -// When follow_preference is false, use severity-specific methods (critical/warning/info) -type NotifyBy struct { - FollowPreference bool `json:"follow_preference" toon:"follow_preference"` - Critical []string `json:"critical,omitempty" toon:"critical,omitempty"` - Warning []string `json:"warning,omitempty" toon:"warning,omitempty"` - Info []string `json:"info,omitempty" toon:"info,omitempty"` -} - -// WebhookConfig represents a webhook configuration in escalation target -type WebhookConfig struct { - Type string `json:"type" toon:"type"` - Alias string `json:"alias,omitempty" toon:"alias,omitempty"` - Settings map[string]any `json:"settings,omitempty" toon:"settings,omitempty"` -} - -// TimeFilter represents time-based filter for rule activation -type TimeFilter struct { - Start string `json:"start" toon:"start"` - End string `json:"end" toon:"end"` - Repeat []int `json:"repeat,omitempty" toon:"repeat,omitempty"` - CalID string `json:"cal_id,omitempty" toon:"cal_id,omitempty"` - IsOff bool `json:"is_off,omitempty" toon:"is_off,omitempty"` -} - -// AlertFilters represents alert filter conditions as OR groups of AND conditions -// Structure: [[{key,oper,vals}, ...], ...] where outer array is OR, inner array is AND -type AlertFilters []AlertFilterGroup - -// AlertFilterGroup represents AND conditions within an OR group -type AlertFilterGroup []AlertCondition - -// AlertCondition represents a single filter condition -// Oper can be "IN" (match) or "NOTIN" (not match) -type AlertCondition struct { - Key string `json:"key" toon:"key"` - Oper string `json:"oper" toon:"oper"` - Vals []string `json:"vals" toon:"vals"` -} - -// ScheduleInfo represents schedule information from /schedule/infos API -type ScheduleInfo struct { - ScheduleID int64 `json:"schedule_id" toon:"schedule_id"` - ScheduleName string `json:"schedule_name" toon:"schedule_name"` -} - -// StatusPage represents a status page -type StatusPage struct { - PageID int64 `json:"page_id" toon:"page_id"` - PageName string `json:"page_name" toon:"page_name"` - Slug string `json:"slug,omitempty" toon:"slug,omitempty"` - Description string `json:"description,omitempty" toon:"description,omitempty"` - Sections []StatusSection `json:"sections,omitempty" toon:"sections,omitempty"` - Components []StatusComponent `json:"components,omitempty" toon:"components,omitempty"` - OverallStatus string `json:"overall_status,omitempty" toon:"overall_status,omitempty"` -} - -// StatusSection represents a section in status page -type StatusSection struct { - SectionID string `json:"section_id" toon:"section_id"` - SectionName string `json:"section_name" toon:"section_name"` -} - -// StatusComponent represents a component in status page -type StatusComponent struct { - ComponentID string `json:"component_id" toon:"component_id"` - ComponentName string `json:"component_name" toon:"component_name"` - Status string `json:"status" toon:"status"` - SectionID string `json:"section_id,omitempty" toon:"section_id,omitempty"` -} - -// StatusChange represents a change event on status page -type StatusChange struct { - ChangeID int64 `json:"change_id" toon:"change_id"` - PageID int64 `json:"page_id" toon:"page_id"` - Title string `json:"title" toon:"title"` - Description string `json:"description,omitempty" toon:"description,omitempty"` - Type string `json:"type" toon:"type"` // incident or maintenance - Status string `json:"status" toon:"status"` - CreatedAt int64 `json:"created_at" toon:"created_at"` - UpdatedAt int64 `json:"updated_at,omitempty" toon:"updated_at,omitempty"` - Timelines []ChangeTimeline `json:"timelines,omitempty" toon:"timelines,omitempty"` -} - -// ChangeTimeline represents a timeline entry in status change -type ChangeTimeline struct { - TimelineID int64 `json:"timeline_id" toon:"timeline_id"` - At int64 `json:"at" toon:"at"` - Status string `json:"status,omitempty" toon:"status,omitempty"` - Description string `json:"description,omitempty" toon:"description,omitempty"` -} - -// Change represents a change record -type Change struct { - ChangeID string `json:"change_id" toon:"change_id"` - Title string `json:"title" toon:"title"` - Description string `json:"description,omitempty" toon:"description,omitempty"` - Type string `json:"type,omitempty" toon:"type,omitempty"` - Status string `json:"status,omitempty" toon:"status,omitempty"` - ChannelID int64 `json:"channel_id,omitempty" toon:"channel_id,omitempty"` - ChannelName string `json:"channel_name,omitempty" toon:"channel_name,omitempty"` - CreatorID int64 `json:"creator_id,omitempty" toon:"creator_id,omitempty"` - CreatorName string `json:"creator_name,omitempty" toon:"creator_name,omitempty"` - StartTime int64 `json:"start_time,omitempty" toon:"start_time,omitempty"` - EndTime int64 `json:"end_time,omitempty" toon:"end_time,omitempty"` - Labels map[string]string `json:"labels,omitempty" toon:"labels,omitempty"` -} diff --git a/pkg/flashduty/users.go b/pkg/flashduty/users.go index 90eedd1..2d91e04 100644 --- a/pkg/flashduty/users.go +++ b/pkg/flashduty/users.go @@ -3,16 +3,14 @@ package flashduty import ( "context" "fmt" - "net/http" + sdk "github.com/flashcatcloud/flashduty-sdk" "github.com/mark3labs/mcp-go/mcp" "github.com/mark3labs/mcp-go/server" "github.com/flashcatcloud/flashduty-mcp-server/pkg/translations" ) -const defaultUsersQueryLimit = 20 - const queryMembersDescription = `Query members (users) by IDs, name, or email. Returns member info with team memberships.` // QueryMembers creates a tool to query members @@ -36,74 +34,36 @@ func QueryMembers(getClient GetFlashdutyClientFn, t translations.TranslationHelp name, _ := OptionalParam[string](request, "name") email, _ := OptionalParam[string](request, "email") - // Query by person IDs + input := &sdk.ListMembersInput{ + Name: name, + Email: email, + } + if personIdsStr != "" { personIDs := parseCommaSeparatedInts(personIdsStr) 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) } - - personMap, err := client.fetchPersonInfos(ctx, int64IDs) - if err != nil { - return mcp.NewToolResultError(fmt.Sprintf("Unable to retrieve members: %v", err)), nil - } - - members := make([]PersonInfo, 0, len(personMap)) - for _, p := range personMap { - members = append(members, p) - } - - return MarshalResult(map[string]any{ - "members": members, - "total": len(members), - }), nil + input.PersonIDs = int64IDs } - // List all members with optional filters - requestBody := map[string]interface{}{ - "p": 1, - "limit": defaultUsersQueryLimit, - } - if name != "" { - requestBody["member_name"] = name - } - if email != "" { - requestBody["email"] = email - } - - resp, err := client.makeRequest(ctx, "POST", "/member/list", requestBody) + output, err := client.ListMembers(ctx, input) if err != nil { - return nil, fmt.Errorf("unable to list members: %w", err) - } - defer func() { _ = resp.Body.Close() }() - - if resp.StatusCode != http.StatusOK { - return mcp.NewToolResultError(handleAPIError(resp).Error()), nil - } - - var result MemberListResponse - if err := parseResponse(resp, &result); err != nil { - return nil, err - } - if result.Error != nil { - return mcp.NewToolResultError(fmt.Sprintf("API error: %s - %s", result.Error.Code, result.Error.Message)), nil + return mcp.NewToolResultError(fmt.Sprintf("Unable to retrieve members: %v", err)), nil } - members := []MemberItem{} - total := 0 - if result.Data != nil { - members = result.Data.Items - total = result.Data.Total + members := any(output.Members) + if len(output.PersonInfos) > 0 { + members = output.PersonInfos } return MarshalResult(map[string]any{ "members": members, - "total": total, + "total": output.Total, }), nil } } @@ -129,105 +89,30 @@ func QueryTeams(getClient GetFlashdutyClientFn, t translations.TranslationHelper teamIdsStr, _ := OptionalParam[string](request, "team_ids") name, _ := OptionalParam[string](request, "name") - // Query by team IDs + input := &sdk.ListTeamsInput{ + Name: name, + } + if teamIdsStr != "" { teamIDs := parseCommaSeparatedInts(teamIdsStr) if len(teamIDs) == 0 { return mcp.NewToolResultError("team_ids must contain at least one valid ID when specified"), nil } - - requestBody := map[string]interface{}{ - "team_ids": teamIDs, - } - - resp, err := client.makeRequest(ctx, "POST", "/team/infos", requestBody) - if err != nil { - return nil, fmt.Errorf("unable to retrieve teams: %w", err) - } - defer func() { _ = resp.Body.Close() }() - - if resp.StatusCode != http.StatusOK { - return mcp.NewToolResultError(handleAPIError(resp).Error()), nil - } - - var result FlashdutyResponse - if err := parseResponse(resp, &result); err != nil { - return nil, err - } - if result.Error != nil { - return mcp.NewToolResultError(fmt.Sprintf("API error: %s - %s", result.Error.Code, result.Error.Message)), nil + int64IDs := make([]int64, len(teamIDs)) + for i, id := range teamIDs { + int64IDs[i] = int64(id) } - - return MarshalResult(result.Data), nil + input.TeamIDs = int64IDs } - // List all teams - requestBody := map[string]interface{}{ - "p": 1, - "limit": defaultUsersQueryLimit, - } - if name != "" { - requestBody["team_name"] = name - } - - resp, err := client.makeRequest(ctx, "POST", "/team/list", requestBody) + output, err := client.ListTeams(ctx, input) if err != nil { - return nil, fmt.Errorf("unable to list teams: %w", err) - } - defer func() { _ = resp.Body.Close() }() - - if resp.StatusCode != http.StatusOK { - return mcp.NewToolResultError(handleAPIError(resp).Error()), nil - } - - var result struct { - Error *DutyError `json:"error,omitempty"` - Data *struct { - Items []struct { - TeamID int64 `json:"team_id"` - TeamName string `json:"team_name"` - Members []struct { - PersonID int64 `json:"person_id"` - PersonName string `json:"person_name"` - Email string `json:"email,omitempty"` - } `json:"members,omitempty"` - } `json:"items"` - Total int `json:"total"` - } `json:"data,omitempty"` - } - if err := parseResponse(resp, &result); err != nil { - return nil, err - } - if result.Error != nil { - return mcp.NewToolResultError(fmt.Sprintf("API error: %s - %s", result.Error.Code, result.Error.Message)), nil - } - - teams := []TeamInfo{} - total := 0 - if result.Data != nil { - for _, t := range result.Data.Items { - team := TeamInfo{ - TeamID: t.TeamID, - TeamName: t.TeamName, - } - if len(t.Members) > 0 { - team.Members = make([]TeamMember, 0, len(t.Members)) - for _, m := range t.Members { - team.Members = append(team.Members, TeamMember{ - PersonID: m.PersonID, - PersonName: m.PersonName, - Email: m.Email, - }) - } - } - teams = append(teams, team) - } - total = result.Data.Total + return mcp.NewToolResultError(fmt.Sprintf("Unable to retrieve teams: %v", err)), nil } return MarshalResult(map[string]any{ - "teams": teams, - "total": total, + "teams": output.Teams, + "total": output.Total, }), nil } } From b4fa911a2d82006e5968d8f0d92aa215bdacc38d Mon Sep 17 00:00:00 2001 From: debidong <1953531014@qq.com> Date: Wed, 8 Apr 2026 16:18:10 +0800 Subject: [PATCH 2/4] style: fix gofmt import formatting --- internal/flashduty/context.go | 2 +- pkg/flashduty/statuspage.go | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/internal/flashduty/context.go b/internal/flashduty/context.go index 7a513cb..61d6546 100644 --- a/internal/flashduty/context.go +++ b/internal/flashduty/context.go @@ -8,8 +8,8 @@ import ( "github.com/bluele/gcache" - sdk "github.com/flashcatcloud/flashduty-sdk" "github.com/flashcatcloud/flashduty-mcp-server/pkg/trace" + sdk "github.com/flashcatcloud/flashduty-sdk" ) type contextKey string diff --git a/pkg/flashduty/statuspage.go b/pkg/flashduty/statuspage.go index 23a7ffa..24f6c83 100644 --- a/pkg/flashduty/statuspage.go +++ b/pkg/flashduty/statuspage.go @@ -7,8 +7,8 @@ import ( "github.com/mark3labs/mcp-go/mcp" "github.com/mark3labs/mcp-go/server" - sdk "github.com/flashcatcloud/flashduty-sdk" "github.com/flashcatcloud/flashduty-mcp-server/pkg/translations" + sdk "github.com/flashcatcloud/flashduty-sdk" ) const queryStatusPagesDescription = `Query status pages with components. Lists all pages or filter by IDs.` From 85e6d37b69dc816c9f73a841716b07f2f3dbf3a6 Mon Sep 17 00:00:00 2001 From: debidong <1953531014@qq.com> Date: Wed, 8 Apr 2026 18:13:57 +0800 Subject: [PATCH 3/4] fix: restore compatibility after SDK migration --- go.mod | 2 +- go.sum | 4 +- internal/flashduty/server.go | 46 +++++++++--------- internal/flashduty/server_test.go | 41 ++++++++++++++++ pkg/flashduty/templates.go | 21 ++++----- pkg/flashduty/templates_test.go | 58 +++++++++++++++++++++++ pkg/flashduty/users.go | 7 +++ pkg/flashduty/users_test.go | 77 +++++++++++++++++++++++++++++++ 8 files changed, 218 insertions(+), 38 deletions(-) create mode 100644 internal/flashduty/server_test.go create mode 100644 pkg/flashduty/templates_test.go create mode 100644 pkg/flashduty/users_test.go diff --git a/go.mod b/go.mod index 6c67ee3..926cb7c 100644 --- a/go.mod +++ b/go.mod @@ -4,7 +4,7 @@ go 1.24.4 require ( github.com/bluele/gcache v0.0.2 - github.com/flashcatcloud/flashduty-sdk v0.3.0 + github.com/flashcatcloud/flashduty-sdk v0.3.1-0.20260408101253-bbe0d25ae134 github.com/google/go-github/v72 v72.0.0 github.com/josephburnett/jd v1.9.2 github.com/mark3labs/mcp-go v0.45.0 diff --git a/go.sum b/go.sum index c5c134b..2adbce2 100644 --- a/go.sum +++ b/go.sum @@ -10,8 +10,8 @@ github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSs github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/flashcatcloud/flashduty-sdk v0.3.0 h1:jx7j6o+wFDIjTQaP5NtxWoAYIq6qtmIOQCZtG9OueV8= -github.com/flashcatcloud/flashduty-sdk v0.3.0/go.mod h1:dG4eJfdZaj4jNBMwEexbfK/3PmcIMhNeJ88L/DcZzUY= +github.com/flashcatcloud/flashduty-sdk v0.3.1-0.20260408101253-bbe0d25ae134 h1:QksBXCEjCub9p6na9qqWABTne3oNyV3vVlKZ/lw5qic= +github.com/flashcatcloud/flashduty-sdk v0.3.1-0.20260408101253-bbe0d25ae134/go.mod h1:dG4eJfdZaj4jNBMwEexbfK/3PmcIMhNeJ88L/DcZzUY= github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/fsnotify/fsnotify v1.8.0 h1:dAwr6QBTBZIkG8roQaJjGof0pp0EeF+tNV7YBP3F/8M= diff --git a/internal/flashduty/server.go b/internal/flashduty/server.go index 3efa405..f9a2303 100644 --- a/internal/flashduty/server.go +++ b/internal/flashduty/server.go @@ -147,6 +147,14 @@ func NewMCPServer(cfg FlashdutyConfig) (*server.MCPServer, error) { return flashdutyServer, nil } +func newStreamableHTTPServer(mcpServer *server.MCPServer, logger *slog.Logger, contextFunc server.HTTPContextFunc) *server.StreamableHTTPServer { + return server.NewStreamableHTTPServer( + mcpServer, + server.WithLogger(&slogAdapter{logger: logger}), + server.WithHTTPContextFunc(contextFunc), + ) +} + type StdioServerConfig struct { // Version of the server Version string @@ -339,29 +347,21 @@ func RunHTTPServer(cfg HTTPServerConfig) error { return fmt.Errorf("failed to create MCP server: %w", err) } - httpServer := server.NewStreamableHTTPServer( - mcpServer, - server.WithLogger(&slogAdapter{logger: logger}), - // Return 405 for GET requests — this server doesn't use server-initiated - // features (sampling, elicitation). Without this, the SDK's standalone SSE - // GET hangs indefinitely because mcp-go creates an orphan session and blocks. - server.WithDisableStreaming(true), - server.WithHTTPContextFunc(func(ctx context.Context, r *http.Request) context.Context { - // Extract W3C Trace Context from HTTP headers, or generate a new one - traceCtx, err := trace.FromHTTPHeadersOrNew(r.Header) - if err != nil { - logger.Warn("Failed to generate trace context, continuing without trace", "error", err) - // Continue without trace context if generation fails - } else { - ctx = trace.ContextWithTraceContext(ctx, traceCtx) - } - - // Note: HTTP request logging is handled by MCP hooks (OnBeforeAny, OnSuccess, OnError) - // which provide more detailed information including method, params, and results. - - return httpContextFunc(ctx, r, cfg.BaseURL) - }), - ) + httpServer := newStreamableHTTPServer(mcpServer, logger, func(ctx context.Context, r *http.Request) context.Context { + // Extract W3C Trace Context from HTTP headers, or generate a new one + traceCtx, err := trace.FromHTTPHeadersOrNew(r.Header) + if err != nil { + logger.Warn("Failed to generate trace context, continuing without trace", "error", err) + // Continue without trace context if generation fails + } else { + ctx = trace.ContextWithTraceContext(ctx, traceCtx) + } + + // Note: HTTP request logging is handled by MCP hooks (OnBeforeAny, OnSuccess, OnError) + // which provide more detailed information including method, params, and results. + + return httpContextFunc(ctx, r, cfg.BaseURL) + }) mux := http.NewServeMux() mux.Handle("/mcp", httpServer) diff --git a/internal/flashduty/server_test.go b/internal/flashduty/server_test.go new file mode 100644 index 0000000..3de5f59 --- /dev/null +++ b/internal/flashduty/server_test.go @@ -0,0 +1,41 @@ +package flashduty + +import ( + "context" + "io" + "log/slog" + "net/http" + "reflect" + "testing" + + "github.com/flashcatcloud/flashduty-mcp-server/pkg/translations" +) + +func TestNewStreamableHTTPServer_DoesNotDisableStreaming(t *testing.T) { + t.Parallel() + + mcpServer, err := NewMCPServer(FlashdutyConfig{ + Version: "test", + Translator: translations.NullTranslationHelper, + EnabledToolsets: []string{"incidents"}, + }) + if err != nil { + t.Fatalf("failed to create MCP server: %v", err) + } + + httpServer := newStreamableHTTPServer( + mcpServer, + slog.New(slog.NewTextHandler(io.Discard, nil)), + func(ctx context.Context, _ *http.Request) context.Context { + return ctx + }, + ) + + value := reflect.ValueOf(httpServer).Elem().FieldByName("disableStreaming") + if !value.IsValid() { + t.Fatal("expected streamable HTTP server to expose disableStreaming field") + } + if value.Bool() { + t.Fatal("expected streaming to remain enabled for GET/SSE clients") + } +} diff --git a/pkg/flashduty/templates.go b/pkg/flashduty/templates.go index 51ad36e..72c9bc4 100644 --- a/pkg/flashduty/templates.go +++ b/pkg/flashduty/templates.go @@ -3,6 +3,7 @@ package flashduty import ( "context" "fmt" + "slices" sdk "github.com/flashcatcloud/flashduty-sdk" "github.com/mark3labs/mcp-go/mcp" @@ -15,6 +16,12 @@ import ( 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 +} + // GetPresetTemplate creates a tool to fetch the preset template for a channel. func GetPresetTemplate(getClient GetFlashdutyClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { return mcp.NewTool("get_preset_template", @@ -26,11 +33,7 @@ func GetPresetTemplate(getClient GetFlashdutyClientFn, t translations.Translatio mcp.WithString("channel", mcp.Required(), mcp.Description("The notification channel to get the preset template for."), - mcp.Enum(sdk.ChannelEnumValues()...), - ), - mcp.WithString("locale", - mcp.Description("Locale for the preset template. Defaults to zh-CN."), - mcp.Enum("zh-CN", "en-US"), + mcp.Enum(sortedChannelEnumValues()...), ), ), func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { ctx, client, err := getClient(ctx) @@ -43,14 +46,8 @@ func GetPresetTemplate(getClient GetFlashdutyClientFn, t translations.Translatio return mcp.NewToolResultError(err.Error()), nil } - locale, _ := OptionalParam[string](request, "locale") - if locale == "" { - locale = "zh-CN" - } - input := &sdk.GetPresetTemplateInput{ Channel: channel, - Locale: locale, } output, err := client.GetPresetTemplate(ctx, input) @@ -77,7 +74,7 @@ func ValidateTemplate(getClient GetFlashdutyClientFn, t translations.Translation mcp.WithString("channel", mcp.Required(), mcp.Description("The notification channel this template is for."), - mcp.Enum(sdk.ChannelEnumValues()...), + mcp.Enum(sortedChannelEnumValues()...), ), mcp.WithString("template_code", mcp.Required(), diff --git a/pkg/flashduty/templates_test.go b/pkg/flashduty/templates_test.go new file mode 100644 index 0000000..6a85336 --- /dev/null +++ b/pkg/flashduty/templates_test.go @@ -0,0 +1,58 @@ +package flashduty + +import ( + "context" + "encoding/json" + "testing" + + "github.com/flashcatcloud/flashduty-mcp-server/pkg/translations" + sdk "github.com/flashcatcloud/flashduty-sdk" +) + +func TestGetPresetTemplateSchemaDoesNotExposeLocale(t *testing.T) { + t.Parallel() + + tool, _ := GetPresetTemplate(func(ctx context.Context) (context.Context, *sdk.Client, error) { + return ctx, nil, nil + }, translations.NullTranslationHelper) + + raw, err := json.Marshal(tool) + if err != nil { + t.Fatalf("marshal tool: %v", err) + } + + var payload map[string]any + if err := json.Unmarshal(raw, &payload); err != nil { + t.Fatalf("unmarshal tool: %v", err) + } + + schema, ok := payload["inputSchema"].(map[string]any) + if !ok { + t.Fatalf("expected inputSchema object, got %#v", payload["inputSchema"]) + } + props, ok := schema["properties"].(map[string]any) + if !ok { + t.Fatalf("expected properties object, got %#v", schema["properties"]) + } + if _, ok := props["locale"]; ok { + t.Fatal("expected locale to be absent from get_preset_template schema") + } + + channelSchema, ok := props["channel"].(map[string]any) + if !ok { + t.Fatalf("expected channel schema, got %#v", props["channel"]) + } + enumVals, ok := channelSchema["enum"].([]any) + if !ok { + t.Fatalf("expected channel enum values, got %#v", channelSchema["enum"]) + } + want := sortedChannelEnumValues() + if len(enumVals) != len(want) { + t.Fatalf("expected %d channel enum values, got %d", len(want), len(enumVals)) + } + for i, got := range enumVals { + if got.(string) != want[i] { + t.Fatalf("channel enum[%d] = %q, want %q", i, got.(string), want[i]) + } + } +} diff --git a/pkg/flashduty/users.go b/pkg/flashduty/users.go index 2d91e04..7ec616e 100644 --- a/pkg/flashduty/users.go +++ b/pkg/flashduty/users.go @@ -110,6 +110,13 @@ func QueryTeams(getClient GetFlashdutyClientFn, t translations.TranslationHelper 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 + } + return MarshalResult(map[string]any{ "teams": output.Teams, "total": output.Total, diff --git a/pkg/flashduty/users_test.go b/pkg/flashduty/users_test.go new file mode 100644 index 0000000..41f202a --- /dev/null +++ b/pkg/flashduty/users_test.go @@ -0,0 +1,77 @@ +package flashduty + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + sdk "github.com/flashcatcloud/flashduty-sdk" + "github.com/mark3labs/mcp-go/mcp" + + "github.com/flashcatcloud/flashduty-mcp-server/pkg/translations" +) + +func TestQueryTeamsByIDsPreservesLegacyItemsShape(t *testing.T) { + t.Parallel() + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/team/infos" { + t.Fatalf("unexpected path: %s", r.URL.Path) + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "data": map[string]any{ + "items": []any{ + map[string]any{ + "team_id": 101, + "team_name": "alpha", + }, + }, + }, + }) + })) + defer ts.Close() + + client, err := sdk.NewClient("test-key", sdk.WithBaseURL(ts.URL)) + if err != nil { + t.Fatalf("new sdk client: %v", err) + } + + _, handler := QueryTeams(func(ctx context.Context) (context.Context, *sdk.Client, error) { + return ctx, client, nil + }, translations.NullTranslationHelper) + + result, err := handler(context.Background(), mcp.CallToolRequest{ + Params: mcp.CallToolParams{ + Name: "query_teams", + Arguments: map[string]any{ + "team_ids": "101", + }, + }, + }) + if err != nil { + t.Fatalf("handler returned error: %v", err) + } + if result.IsError { + t.Fatalf("expected success result, got error result: %#v", result) + } + + textContent, ok := mcp.AsTextContent(result.Content[0]) + if !ok { + t.Fatalf("expected text content, got %#v", result.Content[0]) + } + + var payload map[string]any + if err := json.Unmarshal([]byte(textContent.Text), &payload); err != nil { + t.Fatalf("unmarshal payload: %v", err) + } + + if _, ok := payload["items"]; !ok { + t.Fatalf("expected legacy items key, got %#v", payload) + } + if _, ok := payload["teams"]; ok { + t.Fatalf("did not expect teams key for team_ids lookup, got %#v", payload) + } +} From 0a48b87dfc1f2f27f2b4e150f390a784a57ea88f Mon Sep 17 00:00:00 2001 From: debidong <1953531014@qq.com> Date: Wed, 8 Apr 2026 18:19:52 +0800 Subject: [PATCH 4/4] build: bump flashduty-sdk to v0.3.1 --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index 926cb7c..01e0a49 100644 --- a/go.mod +++ b/go.mod @@ -4,7 +4,7 @@ go 1.24.4 require ( github.com/bluele/gcache v0.0.2 - github.com/flashcatcloud/flashduty-sdk v0.3.1-0.20260408101253-bbe0d25ae134 + github.com/flashcatcloud/flashduty-sdk v0.3.1 github.com/google/go-github/v72 v72.0.0 github.com/josephburnett/jd v1.9.2 github.com/mark3labs/mcp-go v0.45.0 diff --git a/go.sum b/go.sum index 2adbce2..8633808 100644 --- a/go.sum +++ b/go.sum @@ -10,8 +10,8 @@ github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSs github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/flashcatcloud/flashduty-sdk v0.3.1-0.20260408101253-bbe0d25ae134 h1:QksBXCEjCub9p6na9qqWABTne3oNyV3vVlKZ/lw5qic= -github.com/flashcatcloud/flashduty-sdk v0.3.1-0.20260408101253-bbe0d25ae134/go.mod h1:dG4eJfdZaj4jNBMwEexbfK/3PmcIMhNeJ88L/DcZzUY= +github.com/flashcatcloud/flashduty-sdk v0.3.1 h1:60JHV22kauE4fOfUw7Yv2/XcbAMSmRFiJJc+CBl/dsU= +github.com/flashcatcloud/flashduty-sdk v0.3.1/go.mod h1:dG4eJfdZaj4jNBMwEexbfK/3PmcIMhNeJ88L/DcZzUY= github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/fsnotify/fsnotify v1.8.0 h1:dAwr6QBTBZIkG8roQaJjGof0pp0EeF+tNV7YBP3F/8M=