From f79d9cb6c07d2f343474f8b2f830c8dec74b0ea5 Mon Sep 17 00:00:00 2001 From: YoungJin Date: Tue, 15 Sep 2026 19:49:14 +0900 Subject: [PATCH] feat: expose Step Functions executions to automation - Add a read-only failure-first execution JSON command and MCP tool. - Report the 200-execution cap and document the stable contract. --- README.md | 6 +- docs/development.md | 3 + internal/cli/resources.go | 12 ++- internal/cli/resources_operations.go | 56 ++++++++++++++ internal/cli/resources_operations_test.go | 90 +++++++++++++++++++++++ internal/mcp/agent_surface_test.go | 16 ++-- internal/mcp/server.go | 21 ++++++ internal/mcp/server_test.go | 10 +++ skills/unic-aws/SKILL.md | 4 +- 9 files changed, 205 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index 67b9efe0..03b4c37e 100644 --- a/README.md +++ b/README.md @@ -164,10 +164,11 @@ For automation, `unic resources --json` runs a read-only AWS query using - `ecs-rollout --cluster --service ` - `cloudtrail-events [--since 24h] [--resource ] [--mutations-only]` - `elb-target-health --load-balancer ` +- `step-function-executions --state-machine ` Inspector runs outside `resources` because it is a scan, not a resource listing. `unic inspect --json` runs every built-in security and cost/waste rule pack against the active context and returns the same v1 envelope, with `data` carrying `scanned_at`, `scanner_count`, `finding_count`, `severity_counts`, and the `findings` array. Rule packs that fail — most often a denied API call — appear in `warnings` rather than being dropped, so a partially blocked scan is never reported as a clean one. The equivalent MCP tool is `run_security_inspector`. The root `--checklist` flag is inherited but rejected here: Checklist Inspector produces a different report shape and has no agent contract yet, so it fails loudly rather than returning security findings in its place. -The same operations are exposed by `unic-mcp` as read-only tools. Call `get_mcp_capabilities` to discover their versioned input contracts, strict input schemas, output contracts, pagination behavior, and required IAM permissions. The operation permissions are `ec2:DescribeInstances`, `rds:DescribeDBInstances`, `cloudwatch:DescribeAlarms`, `ecs:DescribeServices`, `ecs:DescribeTaskDefinition`, `cloudtrail:LookupEvents`, `elasticloadbalancing:DescribeTargetGroups`, and `elasticloadbalancing:DescribeTargetHealth`; AWS Backup retains the permissions documented below. CLI and MCP output never includes resolved credentials. +The same operations are exposed by `unic-mcp` as read-only tools. Call `get_mcp_capabilities` to discover their versioned input contracts, strict input schemas, output contracts, pagination behavior, and required IAM permissions. The operation permissions are `ec2:DescribeInstances`, `rds:DescribeDBInstances`, `cloudwatch:DescribeAlarms`, `ecs:DescribeServices`, `ecs:DescribeTaskDefinition`, `cloudtrail:LookupEvents`, `elasticloadbalancing:DescribeTargetGroups`, `elasticloadbalancing:DescribeTargetHealth`, and `states:ListExecutions`; AWS Backup retains the permissions documented below. CLI and MCP output never includes resolved credentials. ### MCP server @@ -249,10 +250,11 @@ For Claude Desktop and other JSON-configured MCP clients, use: In Kiro, open **Powers**, choose **Add Custom Power**, and import this repository from GitHub. The root `plugin.json`, `mcp.json`, and `skills/` directory follow the Agent Plugins format used by Kiro Powers. -The server provides `get_mcp_capabilities`, `get_capabilities`, `get_command_schema`, `list_backup_vaults`, `run_security_inspector`, and `plan_context_sync`. Agents should call `get_mcp_capabilities` first because it describes only operations callable through MCP, including permissions and output contracts. Example prompts: +The server exposes the read-only resource operations listed above—including `list_step_function_executions`—plus capability discovery, Security Inspector, and context-sync preview tools. Agents should call `get_mcp_capabilities` first because it describes only operations callable through MCP, including permissions and output contracts. Example prompts: - `Show the AWS capabilities available through unic.` - `List my AWS Backup vaults in ap-northeast-2.` +- `Show the recent failed executions for this STANDARD Step Functions state machine ARN.` - `Preview a unic context sync without changing config.` The context-sync tool is preview-only: it never passes `--apply` or writes configuration. If a client cannot start the server, verify `unic-mcp` is on the client's `PATH` and that the required AWS profile or SSO session is available in the client process environment. diff --git a/docs/development.md b/docs/development.md index 97651782..eb5e426a 100644 --- a/docs/development.md +++ b/docs/development.md @@ -25,12 +25,15 @@ Use the registered Cobra command tree and domain catalog as the source of truth ```bash unic capabilities --json unic schema context sync --json +unic schema resources step-function-executions --json ``` Discovery output is deterministic, versioned JSON. New executable commands should set the `unic.dev/read-only`, `unic.dev/destructive`, and `unic.dev/output-version` annotations when their defaults do not describe the command accurately. Read-only automation commands live under `internal/cli/`; keep their `--json` output versioned and deterministic, write only JSON to stdout, and cover human and JSON output paths with CLI tests. +`unic resources step-function-executions --state-machine --json` reuses the browser's failure-first ordering for up to 200 recent STANDARD workflow executions. The JSON pagination metadata reports the cap; EXPRESS workflow execution history is not available through this API. + The stdio MCP entry point lives at `cmd/unic-mcp` and delegates tool calls to those same CLI commands through `internal/cli.ExecuteAutomation`. Keep the MCP layer limited to protocol handling and argument mapping; AWS and config behavior belongs in the existing CLI, auth, and service packages. MCP mutation tools remain preview-only until their trust boundary is reviewed. The repository root is also the portable agent-plugin package. Keep shared MCP guidance in `skills/unic-aws`, Kiro metadata in `plugin.json` and `mcp.json`, and client-specific manifests in `.codex-plugin`, `.claude-plugin`, and `.mcp.json`. All clients must launch the released `unic-mcp` binary from `PATH`; do not add client-specific MCP implementations. diff --git a/internal/cli/resources.go b/internal/cli/resources.go index cf2f4f9b..0b69533d 100644 --- a/internal/cli/resources.go +++ b/internal/cli/resources.go @@ -35,6 +35,16 @@ type backupVaultJSON struct { Locked bool `json:"locked"` } +type stepFunctionExecutionJSON struct { + ARN string `json:"arn"` + Name string `json:"name"` + StateMachineARN string `json:"state_machine_arn"` + Status string `json:"status"` + StartedAt string `json:"started_at"` + StoppedAt string `json:"stopped_at,omitempty"` + NeedsAttention bool `json:"needs_attention"` +} + var loadBackupVaults = func(ctx context.Context) ([]awsservice.BackupVault, []error, error) { configPath, err := config.DefaultPath() if err != nil { @@ -58,7 +68,7 @@ func newResourcesCmd() *cobra.Command { cmd := &cobra.Command{Use: "resources", Short: "Read-only resource queries for automation"} cmd.AddCommand(newBackupVaultsCmd()) cmd.AddCommand(newEC2InstancesCmd(), newRDSInstancesCmd(), newAlarmsCmd()) - cmd.AddCommand(newECSRolloutCmd(), newCloudTrailEventsCmd(), newELBTargetHealthCmd()) + cmd.AddCommand(newECSRolloutCmd(), newCloudTrailEventsCmd(), newELBTargetHealthCmd(), newStepFunctionExecutionsCmd()) return cmd } diff --git a/internal/cli/resources_operations.go b/internal/cli/resources_operations.go index 4444dfbd..dd98086a 100644 --- a/internal/cli/resources_operations.go +++ b/internal/cli/resources_operations.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "errors" + "strings" "time" "github.com/spf13/cobra" @@ -70,6 +71,13 @@ var ( } return repo.ListTargetGroupHealth(ctx, arn) } + loadStepFunctionExecutions = func(ctx context.Context, stateMachineARN string) ([]awsservice.StepFunctionExecution, error) { + repo, err := resourceRepository(ctx) + if err != nil { + return nil, err + } + return repo.ListStepFunctionExecutions(ctx, stateMachineARN) + } ) func writeResourceJSON(cmd *cobra.Command, data any, complete bool, warnings []string) error { @@ -180,3 +188,51 @@ func newELBTargetHealthCmd() *cobra.Command { _ = cmd.MarkFlagRequired("load-balancer") return cmd } + +func newStepFunctionExecutionsCmd() *cobra.Command { + var stateMachineARN string + var jsonOutput bool + cmd := &cobra.Command{ + Use: "step-function-executions", + Short: "List recent Step Functions executions in triage order as JSON", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + if !jsonOutput { + return errors.New("this automation command supports JSON output only") + } + if strings.TrimSpace(stateMachineARN) == "" { + return errors.New("state-machine is required") + } + executions, err := loadStepFunctionExecutions(cmd.Context(), stateMachineARN) + if err != nil { + return err + } + data := make([]stepFunctionExecutionJSON, 0, len(executions)) + for _, execution := range executions { + data = append(data, stepFunctionExecutionJSON{ + ARN: execution.ARN, Name: execution.Name, StateMachineARN: execution.StateMachineARN, + Status: execution.Status, StartedAt: resourceTimeJSON(execution.StartDate), + StoppedAt: resourceTimeJSON(execution.StopDate), NeedsAttention: execution.NeedsAttention(), + }) + } + complete := len(data) < 200 + warnings := []string{} + if !complete { + warnings = append(warnings, "results reached the 200-execution limit") + } + return writeResourceJSON(cmd, data, complete, warnings) + }, + } + cmd.Annotations = map[string]string{annotationReadOnly: "true", annotationOutputVersion: "v1"} + cmd.Flags().StringVar(&stateMachineARN, "state-machine", "", "STANDARD state machine ARN") + cmd.Flags().BoolVar(&jsonOutput, "json", true, "Emit stable machine-readable JSON") + _ = cmd.MarkFlagRequired("state-machine") + return cmd +} + +func resourceTimeJSON(value time.Time) string { + if value.IsZero() { + return "" + } + return value.UTC().Format(time.RFC3339) +} diff --git a/internal/cli/resources_operations_test.go b/internal/cli/resources_operations_test.go index ac319b8b..0cf7d07a 100644 --- a/internal/cli/resources_operations_test.go +++ b/internal/cli/resources_operations_test.go @@ -4,7 +4,9 @@ import ( "bytes" "context" "encoding/json" + "errors" "testing" + "time" awsservice "unic/internal/services/aws" ) @@ -83,3 +85,91 @@ func TestCloudTrailEventsReportsCapAsIncomplete(t *testing.T) { t.Fatalf("missing truncation signal: %s", output.String()) } } + +func TestStepFunctionExecutionsJSONContract(t *testing.T) { + original := loadStepFunctionExecutions + defer func() { loadStepFunctionExecutions = original }() + started := time.Date(2026, 9, 15, 18, 0, 0, 0, time.FixedZone("KST", 9*60*60)) + loadStepFunctionExecutions = func(context.Context, string) ([]awsservice.StepFunctionExecution, error) { + return []awsservice.StepFunctionExecution{ + {ARN: "arn:execution", Name: "failed-run", StateMachineARN: "arn:machine", Status: "FAILED", StartDate: started, StopDate: started.Add(time.Minute)}, + {ARN: "arn:running", Name: "running", StateMachineARN: "arn:machine", Status: "RUNNING", StartDate: started}, + }, nil + } + cmd := NewRootCmd() + var output bytes.Buffer + cmd.SetOut(&output) + cmd.SetArgs([]string{"resources", "step-function-executions", "--state-machine", "arn:machine", "--json"}) + if err := cmd.Execute(); err != nil { + t.Fatal(err) + } + var result struct { + SchemaVersion string `json:"schema_version"` + Data []struct { + ARN string `json:"arn"` + StateMachineARN string `json:"state_machine_arn"` + Status string `json:"status"` + StartedAt string `json:"started_at"` + StoppedAt string `json:"stopped_at"` + NeedsAttention bool `json:"needs_attention"` + } `json:"data"` + Warnings []string `json:"warnings"` + Pagination jsonPagination `json:"pagination"` + } + if err := json.Unmarshal(output.Bytes(), &result); err != nil { + t.Fatal(err) + } + if result.SchemaVersion != "v1" || len(result.Data) != 2 || result.Data[0].ARN != "arn:execution" || + result.Data[0].StateMachineARN != "arn:machine" || result.Data[0].Status != "FAILED" || + result.Data[0].StartedAt != "2026-09-15T09:00:00Z" || result.Data[0].StoppedAt != "2026-09-15T09:01:00Z" || + !result.Data[0].NeedsAttention || result.Data[1].StoppedAt != "" || result.Data[1].NeedsAttention || + result.Warnings == nil || !result.Pagination.Complete { + t.Fatalf("unexpected result: %+v", result) + } +} + +func TestStepFunctionExecutionsReportsCap(t *testing.T) { + original := loadStepFunctionExecutions + defer func() { loadStepFunctionExecutions = original }() + loadStepFunctionExecutions = func(context.Context, string) ([]awsservice.StepFunctionExecution, error) { + return make([]awsservice.StepFunctionExecution, 200), nil + } + cmd := NewRootCmd() + var output bytes.Buffer + cmd.SetOut(&output) + cmd.SetArgs([]string{"resources", "step-function-executions", "--state-machine", "arn:machine", "--json"}) + if err := cmd.Execute(); err != nil { + t.Fatal(err) + } + if !bytes.Contains(output.Bytes(), []byte(`"complete":false`)) || !bytes.Contains(output.Bytes(), []byte("200-execution limit")) { + t.Fatalf("missing execution cap signal: %s", output.String()) + } +} + +func TestStepFunctionExecutionsRejectsMissingARNAndLoaderErrors(t *testing.T) { + cmd := NewRootCmd() + cmd.SetArgs([]string{"resources", "step-function-executions", "--json"}) + if err := cmd.Execute(); err == nil { + t.Fatal("missing state-machine ARN must fail") + } + cmd = NewRootCmd() + cmd.SetArgs([]string{"resources", "step-function-executions", "--state-machine", " ", "--json"}) + if err := cmd.Execute(); err == nil { + t.Fatal("blank state-machine ARN must fail") + } + + original := loadStepFunctionExecutions + defer func() { loadStepFunctionExecutions = original }() + wantErr := errors.New("execution lookup failed") + loadStepFunctionExecutions = func(context.Context, string) ([]awsservice.StepFunctionExecution, error) { return nil, wantErr } + cmd = NewRootCmd() + var output bytes.Buffer + cmd.SetOut(&output) + cmd.SetArgs([]string{"resources", "step-function-executions", "--state-machine", "arn:machine", "--json"}) + if err := cmd.Execute(); !errors.Is(err, wantErr) { + t.Fatalf("expected loader error, got %v", err) + } + if output.Len() != 0 { + t.Fatalf("expected no success envelope, got %s", output.String()) + } +} diff --git a/internal/mcp/agent_surface_test.go b/internal/mcp/agent_surface_test.go index 7d9d24c1..a205be67 100644 --- a/internal/mcp/agent_surface_test.go +++ b/internal/mcp/agent_surface_test.go @@ -22,13 +22,14 @@ type agentCommandContract struct { } var agentSurfaceByFeature = map[domain.FeatureKind]agentSurface{ - domain.FeatureBackupBrowser: {command: "backup-vaults", tool: "list_backup_vaults"}, - domain.FeatureCloudTrailEvents: {command: "cloudtrail-events", tool: "list_cloudtrail_events"}, - domain.FeatureCloudWatchAlarms: {command: "alarms", tool: "list_cloudwatch_alarms"}, - domain.FeatureEC2InstanceBrowser: {command: "ec2-instances", tool: "list_ec2_instances"}, - domain.FeatureECSExec: {command: "ecs-rollout", tool: "get_ecs_service_rollout", arguments: json.RawMessage(`{"cluster":"cluster","service":"service"}`)}, - domain.FeatureELBBrowser: {command: "elb-target-health", tool: "get_elb_target_health", arguments: json.RawMessage(`{"load_balancer":"load-balancer"}`)}, - domain.FeatureRDSBrowser: {command: "rds-instances", tool: "list_rds_instances"}, + domain.FeatureBackupBrowser: {command: "backup-vaults", tool: "list_backup_vaults"}, + domain.FeatureCloudTrailEvents: {command: "cloudtrail-events", tool: "list_cloudtrail_events"}, + domain.FeatureCloudWatchAlarms: {command: "alarms", tool: "list_cloudwatch_alarms"}, + domain.FeatureEC2InstanceBrowser: {command: "ec2-instances", tool: "list_ec2_instances"}, + domain.FeatureECSExec: {command: "ecs-rollout", tool: "get_ecs_service_rollout", arguments: json.RawMessage(`{"cluster":"cluster","service":"service"}`)}, + domain.FeatureELBBrowser: {command: "elb-target-health", tool: "get_elb_target_health", arguments: json.RawMessage(`{"load_balancer":"load-balancer"}`)}, + domain.FeatureRDSBrowser: {command: "rds-instances", tool: "list_rds_instances"}, + domain.FeatureStepFunctionsBrowser: {command: "step-function-executions", tool: "list_step_function_executions", arguments: json.RawMessage(`{"state_machine":"arn:machine"}`)}, } var agentSurfaceExempt = map[domain.FeatureKind]string{ @@ -60,7 +61,6 @@ var agentSurfaceExempt = map[domain.FeatureKind]string{ domain.FeatureSQSBrowser: "queue mutations are confirmation-gated and no separate read-only contract exists yet", domain.FeatureSSMParameterBrowser: "parameter values require operator-controlled reveal and copy handling", domain.FeatureSSMSession: "starts an interactive shell session instead of returning resource data", - domain.FeatureStepFunctionsBrowser: "the failure-first execution view has no curated agent contract yet", domain.FeatureVPCBrowser: "no bounded VPC and subnet query is defined yet", domain.FeatureWAFWebACLBrowser: "the regional and global joined view has no agent contract yet", } diff --git a/internal/mcp/server.go b/internal/mcp/server.go index b43d9ad6..4e3b546d 100644 --- a/internal/mcp/server.go +++ b/internal/mcp/server.go @@ -153,6 +153,14 @@ var tools = []tool{ Annotations: annotations{ReadOnlyHint: true, IdempotentHint: true, OpenWorldHint: true}, Metadata: toolMetadata{RequiredPermissions: []string{"elasticloadbalancing:DescribeTargetGroups", "elasticloadbalancing:DescribeTargetHealth"}, OutputContract: "unic.resources.elb-target-health.v1", Paginated: true}, }, + { + Name: "list_step_function_executions", Description: "List up to 200 recent STANDARD Step Functions executions in failure-first triage order.", + InputSchema: awsContextSchema(map[string]any{ + "state_machine": map[string]any{"type": "string", "minLength": 1, "description": "STANDARD state machine ARN"}, + }, []string{"state_machine"}), + Annotations: annotations{ReadOnlyHint: true, IdempotentHint: true, OpenWorldHint: true}, + Metadata: toolMetadata{RequiredPermissions: []string{"states:ListExecutions"}, OutputContract: "unic.resources.step-function-executions.v1", Paginated: true}, + }, { Name: "plan_context_sync", Description: "Preview an SSO context sync plan. This tool never writes configuration.", InputSchema: objectSchema(map[string]any{ @@ -464,6 +472,19 @@ func toolArgs(name string, raw json.RawMessage) ([]string, error) { return nil, errors.New("load_balancer is required") } return withAWSContext([]string{"resources", "elb-target-health", "--load-balancer", args.LoadBalancer, "--json"}, args.Profile, args.Region), nil + case "list_step_function_executions": + var args struct { + StateMachine string `json:"state_machine"` + Profile string `json:"profile"` + Region string `json:"region"` + } + if err := decodeArguments(raw, &args); err != nil { + return nil, err + } + if strings.TrimSpace(args.StateMachine) == "" { + return nil, errors.New("state_machine is required") + } + return withAWSContext([]string{"resources", "step-function-executions", "--state-machine", args.StateMachine, "--json"}, args.Profile, args.Region), nil case "plan_context_sync": var args struct { BaseContext string `json:"base_context"` diff --git a/internal/mcp/server_test.go b/internal/mcp/server_test.go index e139f006..6bbcc34f 100644 --- a/internal/mcp/server_test.go +++ b/internal/mcp/server_test.go @@ -58,6 +58,7 @@ func TestReadOnlyOperationToolArgs(t *testing.T) { {"get_ecs_service_rollout", `{"cluster":"prod","service":"api"}`, []string{"resources", "ecs-rollout", "--cluster", "prod", "--service", "api", "--json"}}, {"list_cloudtrail_events", `{"since":"6h","mutations_only":true}`, []string{"resources", "cloudtrail-events", "--since", "6h", "--json", "--mutations-only"}}, {"get_elb_target_health", `{"load_balancer":"arn:lb"}`, []string{"resources", "elb-target-health", "--load-balancer", "arn:lb", "--json"}}, + {"list_step_function_executions", `{"state_machine":"arn:machine","profile":"prod","region":"eu-west-1"}`, []string{"resources", "step-function-executions", "--state-machine", "arn:machine", "--json", "--profile", "prod", "--region", "eu-west-1"}}, {"run_security_inspector", `{}`, []string{"inspect", "--json"}}, {"run_security_inspector", `{"profile":"prod","region":"eu-west-1"}`, []string{"inspect", "--json", "--profile", "prod", "--region", "eu-west-1"}}, } @@ -81,6 +82,9 @@ func TestReadOnlyOperationToolValidation(t *testing.T) { if _, err := toolArgs("get_elb_target_health", json.RawMessage(`{"load_balancer":""}`)); err == nil { t.Fatal("empty load balancer must fail") } + if _, err := toolArgs("list_step_function_executions", json.RawMessage(`{"state_machine":" "}`)); err == nil { + t.Fatal("empty state machine must fail") + } } func TestMCPCapabilitiesStayAlignedWithRegisteredTools(t *testing.T) { @@ -102,6 +106,12 @@ func TestMCPCapabilitiesStayAlignedWithRegisteredTools(t *testing.T) { if _, ok := listed[i]["required_permissions"].([]string); !ok { t.Fatalf("tool %s permissions are not a stable array", registered.Name) } + if registered.Name == "list_step_function_executions" { + want := []string{"states:ListExecutions"} + if !reflect.DeepEqual(listed[i]["required_permissions"], want) { + t.Fatalf("tool %s permissions = %#v, want %#v", registered.Name, listed[i]["required_permissions"], want) + } + } } } diff --git a/skills/unic-aws/SKILL.md b/skills/unic-aws/SKILL.md index d93f84f0..7f0ef39e 100644 --- a/skills/unic-aws/SKILL.md +++ b/skills/unic-aws/SKILL.md @@ -1,6 +1,6 @@ --- name: unic-aws -description: Use unic to discover supported AWS operations, inspect AWS Backup vaults, or preview SSO context synchronization through MCP. +description: Use unic to discover supported AWS operations, inspect AWS resources, or preview SSO context synchronization through MCP. --- # unic AWS @@ -10,7 +10,7 @@ Use the `unic` MCP server for supported AWS inspection and context planning. 1. Call `get_mcp_capabilities` first to discover operations this MCP server can actually execute. 2. Call `get_capabilities` only when broader unic TUI or CLI feature discovery is useful. 3. Call `get_command_schema` before composing an automation command contract. -4. Call `list_backup_vaults` with optional `profile` and `region` arguments to inspect AWS Backup. +4. Call a discovered read-only resource tool with optional `profile` and `region` arguments; for example, `list_backup_vaults` or `list_step_function_executions`. 5. Call `plan_context_sync` to preview SSO context changes. It never applies or writes configuration. The server inherits local unic and AWS configuration from its process environment. Never request, store, or place AWS credentials in plugin configuration. Report structured permission errors and partial-result warnings to the user.