Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -174,7 +174,7 @@ For automation, `unic resources <query> --json` runs a read-only AWS query using

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`, `elasticache:DescribeCacheClusters`, `elasticache:DescribeReplicationGroups`, `cloudwatch:DescribeAlarms`, `ecs:DescribeServices`, `ecs:DescribeTaskDefinition`, `cloudtrail:LookupEvents`, `cloudformation:DescribeStacks`, `elasticloadbalancing:DescribeTargetGroups`, `elasticloadbalancing:DescribeTargetHealth`, `sns:ListTopics`, `sns:GetTopicAttributes`, `sns:ListSubscriptionsByTopic`, `sns:GetSubscriptionAttributes`, `sqs:ListQueues`, and `sqs:GetQueueAttributes`; 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`, `elasticache:DescribeCacheClusters`, `elasticache:DescribeReplicationGroups`, `cloudwatch:DescribeAlarms`, `ecs:DescribeServices`, `ecs:DescribeTaskDefinition`, `cloudtrail:LookupEvents`, `cloudformation:DescribeStacks`, `states:ListExecutions`, `elasticloadbalancing:DescribeTargetGroups`, `elasticloadbalancing:DescribeTargetHealth`, `sns:ListTopics`, `sns:GetTopicAttributes`, `sns:ListSubscriptionsByTopic`, `sns:GetSubscriptionAttributes`, `sqs:ListQueues`, and `sqs:GetQueueAttributes`; AWS Backup retains the permissions documented below. CLI and MCP output never includes resolved credentials.

### MCP server

Expand Down Expand Up @@ -266,6 +266,11 @@ The server exposes the read-only resource operations listed above—including `l
- `Show the AWS capabilities available through unic.`
- `List my AWS Backup vaults in ap-northeast-2.`
- `Show failed or rollback CloudFormation stacks and their status reasons.`
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.
Expand Down
4 changes: 4 additions & 0 deletions docs/development.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@ unic schema resources elasticache-resources --json
unic schema resources sns-topics --json

unic schema resources cloudformation-stacks --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.
Expand All @@ -42,6 +44,8 @@ Read-only automation commands live under `internal/cli/`; keep their `--json` ou

`unic resources cloudformation-stacks --json` reuses the browser's failure-first stack ordering and returns status reasons, drift state, parameters, and outputs. Recent events remain a separate per-stack detail lookup and are not implied by this listing contract.

`unic resources step-function-executions --state-machine <arn> --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.
Expand Down
14 changes: 12 additions & 2 deletions internal/cli/resources.go
Original file line number Diff line number Diff line change
Expand Up @@ -100,13 +100,23 @@ func cloudFormationValuesJSON(values []awsservice.CloudFormationValue) []cloudFo
return result
}

func cloudFormationTimeJSON(value time.Time) string {
func resourceTimeJSON(value time.Time) string {
if value.IsZero() {
return ""
}
return value.UTC().Format(time.RFC3339)
}

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 {
Expand All @@ -130,7 +140,7 @@ func newResourcesCmd() *cobra.Command {
cmd := &cobra.Command{Use: "resources", Short: "Read-only resource queries for automation"}
cmd.AddCommand(newBackupVaultsCmd())
cmd.AddCommand(newEC2InstancesCmd(), newRDSInstancesCmd(), newCloudFormationStacksCmd(), newAlarmsCmd())
cmd.AddCommand(newECSRolloutCmd(), newCloudTrailEventsCmd(), newELBTargetHealthCmd(), newSQSQueuesCmd(), newElastiCacheResourcesCmd(), newSNSTopicsCmd())
cmd.AddCommand(newECSRolloutCmd(), newCloudTrailEventsCmd(), newELBTargetHealthCmd(), newSQSQueuesCmd(), newElastiCacheResourcesCmd(), newSNSTopicsCmd(), newStepFunctionExecutionsCmd())
return cmd
}

Expand Down
51 changes: 50 additions & 1 deletion internal/cli/resources_operations.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"context"
"encoding/json"
"errors"
"strings"
"time"

"github.com/spf13/cobra"
Expand Down Expand Up @@ -91,6 +92,13 @@ var (
}
return repo.ListCloudFormationStacks(ctx)
}
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)
}
loadSQSQueues = func(ctx context.Context) ([]awsservice.SQSQueue, error) {
repo, err := resourceRepository(ctx)
if err != nil {
Expand Down Expand Up @@ -287,11 +295,52 @@ func newCloudFormationStacksCmd() *cobra.Command {
data = append(data, cloudFormationStackJSON{
ID: stack.ID, Name: stack.Name, Description: stack.Description,
Status: stack.Status, StatusReason: stack.StatusReason, DriftStatus: stack.DriftStatus, Region: stack.Region,
LastDriftCheck: cloudFormationTimeJSON(stack.LastDriftCheck), CreatedAt: cloudFormationTimeJSON(stack.CreatedAt), UpdatedAt: cloudFormationTimeJSON(stack.UpdatedAt),
LastDriftCheck: resourceTimeJSON(stack.LastDriftCheck), CreatedAt: resourceTimeJSON(stack.CreatedAt), UpdatedAt: resourceTimeJSON(stack.UpdatedAt),
TerminationProtection: stack.TerminationProtection,
Parameters: cloudFormationValuesJSON(stack.Parameters), Outputs: cloudFormationValuesJSON(stack.Outputs),
})
}
return data, err
})
}

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
Comment thread
YoungJinJung marked this conversation as resolved.
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
}
88 changes: 88 additions & 0 deletions internal/cli/resources_operations_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -347,3 +347,91 @@ func TestCloudFormationStacksLoaderErrorEmitsNoEnvelope(t *testing.T) {
t.Fatalf("expected no success envelope, got %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())
}
}
2 changes: 1 addition & 1 deletion internal/mcp/agent_surface_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ var agentSurfaceByFeature = map[domain.FeatureKind]agentSurface{
domain.FeatureRDSBrowser: {command: "rds-instances", tool: "list_rds_instances"},
domain.FeatureSQSBrowser: {command: "sqs-queues", tool: "list_sqs_queues"},
domain.FeatureSNSBrowser: {command: "sns-topics", tool: "list_sns_topics"},
domain.FeatureStepFunctionsBrowser: {command: "step-function-executions", tool: "list_step_function_executions", arguments: json.RawMessage(`{"state_machine":"arn:machine"}`)},
}

var agentSurfaceExempt = map[domain.FeatureKind]string{
Expand Down Expand Up @@ -60,7 +61,6 @@ var agentSurfaceExempt = map[domain.FeatureKind]string{
domain.FeatureSecretsBrowser: "secret values require operator-controlled reveal and copy handling",
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",
}
Expand Down
21 changes: 21 additions & 0 deletions internal/mcp/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,14 @@ var tools = []tool{
OutputContract: "unic.resources.sqs-queues.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{
Expand Down Expand Up @@ -497,6 +505,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"`
Expand Down
11 changes: 9 additions & 2 deletions internal/mcp/server_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,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"}},
}
Expand All @@ -85,6 +86,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) {
Expand All @@ -106,8 +110,11 @@ 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_cloudformation_stacks" {
want := []string{"cloudformation:DescribeStacks", "cloudformation:ListStacks"}
wantPermissions := map[string][]string{
"list_cloudformation_stacks": {"cloudformation:DescribeStacks", "cloudformation:ListStacks"},
"list_step_function_executions": {"states:ListExecutions"},
}
if want, ok := wantPermissions[registered.Name]; ok {
if !reflect.DeepEqual(listed[i]["required_permissions"], want) {
t.Fatalf("tool %s permissions = %#v, want %#v", registered.Name, listed[i]["required_permissions"], want)
}
Expand Down
Loading
Loading