diff --git a/README.md b/README.md index a6c3c734..0afd8d8b 100644 --- a/README.md +++ b/README.md @@ -165,6 +165,7 @@ For automation, `unic resources --json` runs a read-only AWS query using - `rds-instances` - `elasticache-resources` - `sns-topics` +- `cloudformation-stacks` - `alarms` - `ecs-rollout --cluster --service ` - `cloudtrail-events [--since 24h] [--resource ] [--mutations-only]` @@ -173,7 +174,7 @@ For automation, `unic resources --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`, `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`, `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 @@ -260,6 +261,11 @@ The server provides the read-only resource operations listed above plus capabili - `Show the AWS capabilities available through unic.` - `List my AWS Backup vaults in ap-northeast-2.` - `Show my deepest SQS backlogs and their dead-letter queue relationships.` +The server exposes the read-only resource operations listed above—including `list_cloudformation_stacks`—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 failed or rollback CloudFormation stacks and their status reasons.` - `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 55ce7a79..3e931e04 100644 --- a/docs/development.md +++ b/docs/development.md @@ -28,6 +28,8 @@ unic schema context sync --json unic schema resources elasticache-resources --json unic schema resources sns-topics --json + +unic schema resources cloudformation-stacks --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. @@ -38,6 +40,8 @@ Read-only automation commands live under `internal/cli/`; keep their `--json` ou `unic resources sns-topics --json` composes the existing topic and per-topic subscription reads. Keep partial lookup failures in the envelope's `warnings` array and keep the contract read-only; publishing and subscription changes remain outside the agent surface. +`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. + 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 c86410e4..e5da56ec 100644 --- a/internal/cli/resources.go +++ b/internal/cli/resources.go @@ -5,6 +5,7 @@ import ( "encoding/json" "fmt" "text/tabwriter" + "time" "github.com/spf13/cobra" @@ -66,6 +67,46 @@ type snsSubscriptionJSON struct { AttributesKnown bool `json:"attributes_known"` } +type cloudFormationStackJSON struct { + ID string `json:"id"` + Name string `json:"name"` + Description string `json:"description,omitempty"` + Status string `json:"status"` + StatusReason string `json:"status_reason,omitempty"` + DriftStatus string `json:"drift_status"` + Region string `json:"region"` + LastDriftCheck string `json:"last_drift_check,omitempty"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at,omitempty"` + TerminationProtection bool `json:"termination_protection"` + Parameters []cloudFormationValueJSON `json:"parameters"` + Outputs []cloudFormationValueJSON `json:"outputs"` +} + +type cloudFormationValueJSON struct { + Key string `json:"key"` + Value string `json:"value"` + Description string `json:"description,omitempty"` + ExportName string `json:"export_name,omitempty"` +} + +func cloudFormationValuesJSON(values []awsservice.CloudFormationValue) []cloudFormationValueJSON { + result := make([]cloudFormationValueJSON, 0, len(values)) + for _, value := range values { + result = append(result, cloudFormationValueJSON{ + Key: value.Key, Value: value.Value, Description: value.Description, ExportName: value.ExportName, + }) + } + return result +} + +func cloudFormationTimeJSON(value time.Time) string { + if value.IsZero() { + return "" + } + return value.UTC().Format(time.RFC3339) +} + var loadBackupVaults = func(ctx context.Context) ([]awsservice.BackupVault, []error, error) { configPath, err := config.DefaultPath() if err != nil { @@ -88,7 +129,7 @@ var loadBackupVaults = func(ctx context.Context) ([]awsservice.BackupVault, []er 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(newEC2InstancesCmd(), newRDSInstancesCmd(), newCloudFormationStacksCmd(), newAlarmsCmd()) cmd.AddCommand(newECSRolloutCmd(), newCloudTrailEventsCmd(), newELBTargetHealthCmd(), newSQSQueuesCmd(), newElastiCacheResourcesCmd(), newSNSTopicsCmd()) return cmd } diff --git a/internal/cli/resources_operations.go b/internal/cli/resources_operations.go index 9a210c02..b636e584 100644 --- a/internal/cli/resources_operations.go +++ b/internal/cli/resources_operations.go @@ -84,6 +84,13 @@ var ( } return repo.ListSNSTopicResources(ctx) } + loadCloudFormationStacks = func(ctx context.Context) ([]awsservice.CloudFormationStack, error) { + repo, err := resourceRepository(ctx) + if err != nil { + return nil, err + } + return repo.ListCloudFormationStacks(ctx) + } loadSQSQueues = func(ctx context.Context) ([]awsservice.SQSQueue, error) { repo, err := resourceRepository(ctx) if err != nil { @@ -271,3 +278,20 @@ func newSNSTopicsCmd() *cobra.Command { cmd.Flags().BoolVar(&jsonOutput, "json", true, "Emit stable machine-readable JSON") return cmd } + +func newCloudFormationStacksCmd() *cobra.Command { + return jsonResourceCommand("cloudformation-stacks", "List CloudFormation stacks in triage order as JSON", func(ctx context.Context) (any, error) { + stacks, err := loadCloudFormationStacks(ctx) + data := make([]cloudFormationStackJSON, 0, len(stacks)) + for _, stack := range stacks { + 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), + TerminationProtection: stack.TerminationProtection, + Parameters: cloudFormationValuesJSON(stack.Parameters), Outputs: cloudFormationValuesJSON(stack.Outputs), + }) + } + return data, err + }) +} diff --git a/internal/cli/resources_operations_test.go b/internal/cli/resources_operations_test.go index fc631640..328b5b26 100644 --- a/internal/cli/resources_operations_test.go +++ b/internal/cli/resources_operations_test.go @@ -6,6 +6,7 @@ import ( "encoding/json" "errors" "testing" + "time" awsservice "unic/internal/services/aws" ) @@ -271,3 +272,78 @@ func TestSNSTopicsLoaderErrorEmitsNoEnvelope(t *testing.T) { t.Fatalf("expected no success envelope, got %s", output.String()) } } + +func TestCloudFormationStacksJSONContract(t *testing.T) { + original := loadCloudFormationStacks + defer func() { loadCloudFormationStacks = original }() + now := time.Date(2026, 9, 15, 5, 0, 0, 0, time.FixedZone("KST", 9*60*60)) + loadCloudFormationStacks = func(context.Context) ([]awsservice.CloudFormationStack, error) { + return []awsservice.CloudFormationStack{ + { + ID: "stack-id", Name: "failed", Description: "production stack", Status: "CREATE_FAILED", StatusReason: "bucket exists", + DriftStatus: "DRIFTED", Region: "ap-northeast-2", LastDriftCheck: now, CreatedAt: now.Add(-time.Hour), UpdatedAt: now, + TerminationProtection: true, + Parameters: []awsservice.CloudFormationValue{{Key: "Environment", Value: "prod"}}, + Outputs: []awsservice.CloudFormationValue{{Key: "Endpoint", Value: "example.com", Description: "service endpoint", ExportName: "prod-endpoint"}}, + }, + {Name: "empty", DriftStatus: "NOT_CHECKED", CreatedAt: now}, + }, nil + } + + cmd := NewRootCmd() + var output bytes.Buffer + cmd.SetOut(&output) + cmd.SetArgs([]string{"resources", "cloudformation-stacks", "--json"}) + if err := cmd.Execute(); err != nil { + t.Fatal(err) + } + var result struct { + SchemaVersion string `json:"schema_version"` + Data []struct { + Name string `json:"name"` + Status string `json:"status"` + StatusReason string `json:"status_reason"` + DriftStatus string `json:"drift_status"` + LastDriftCheck string `json:"last_drift_check"` + CreatedAt string `json:"created_at"` + Parameters []struct { + Key string `json:"key"` + } `json:"parameters"` + Outputs []struct { + ExportName string `json:"export_name"` + } `json:"outputs"` + } `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].Name != "failed" || result.Data[0].Status != "CREATE_FAILED" || + result.Data[0].StatusReason != "bucket exists" || result.Data[0].DriftStatus != "DRIFTED" || result.Data[0].LastDriftCheck != "2026-09-14T20:00:00Z" || + result.Data[0].CreatedAt != "2026-09-14T19:00:00Z" || len(result.Data[0].Parameters) != 1 || result.Data[0].Parameters[0].Key != "Environment" || + len(result.Data[0].Outputs) != 1 || result.Data[0].Outputs[0].ExportName != "prod-endpoint" || result.Data[1].Parameters == nil || result.Data[1].Outputs == nil || + result.Warnings == nil || !result.Pagination.Complete { + t.Fatalf("unexpected result: %+v", result) + } + if bytes.Contains(output.Bytes(), []byte(`"events"`)) { + t.Fatalf("stack listing must not imply that recent events were loaded: %s", output.String()) + } +} + +func TestCloudFormationStacksLoaderErrorEmitsNoEnvelope(t *testing.T) { + original := loadCloudFormationStacks + defer func() { loadCloudFormationStacks = original }() + wantErr := errors.New("stack lookup failed") + loadCloudFormationStacks = func(context.Context) ([]awsservice.CloudFormationStack, error) { return nil, wantErr } + cmd := NewRootCmd() + var output bytes.Buffer + cmd.SetOut(&output) + cmd.SetArgs([]string{"resources", "cloudformation-stacks", "--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 f99b904f..7997d0d4 100644 --- a/internal/mcp/agent_surface_test.go +++ b/internal/mcp/agent_surface_test.go @@ -22,16 +22,17 @@ 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.FeatureElastiCacheBrowser: {command: "elasticache-resources", tool: "list_elasticache_resources"}, - 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.FeatureSQSBrowser: {command: "sqs-queues", tool: "list_sqs_queues"}, - domain.FeatureSNSBrowser: {command: "sns-topics", tool: "list_sns_topics"}, + domain.FeatureBackupBrowser: {command: "backup-vaults", tool: "list_backup_vaults"}, + domain.FeatureCloudFormationBrowser: {command: "cloudformation-stacks", tool: "list_cloudformation_stacks"}, + 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.FeatureElastiCacheBrowser: {command: "elasticache-resources", tool: "list_elasticache_resources"}, + 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.FeatureSQSBrowser: {command: "sqs-queues", tool: "list_sqs_queues"}, + domain.FeatureSNSBrowser: {command: "sns-topics", tool: "list_sns_topics"}, } var agentSurfaceExempt = map[domain.FeatureKind]string{ @@ -39,7 +40,6 @@ var agentSurfaceExempt = map[domain.FeatureKind]string{ domain.FeatureAPIGatewayV2Browser: "no curated API and route query is defined yet", domain.FeatureAutoScalingBrowser: "capacity changes are mutation-gated and no separate read-only contract exists yet", domain.FeatureBedrockAPIKeys: "key management handles one-time secrets and mutations", - domain.FeatureCloudFormationBrowser: "the failure-first multi-call view has no curated agent contract yet", domain.FeatureCloudWatchLogsBrowser: "log content needs an explicitly bounded query contract", domain.FeatureCloudWatchMetrics: "interactive chart presets have no stable agent query contract", domain.FeatureDynamoDBBrowser: "item reads need an explicitly bounded key and output contract", diff --git a/internal/mcp/server.go b/internal/mcp/server.go index 5ddfa665..d8dd84ab 100644 --- a/internal/mcp/server.go +++ b/internal/mcp/server.go @@ -138,6 +138,12 @@ var tools = []tool{ OutputContract: "unic.resources.sns-topics.v1", Paginated: true, PartialResults: true, }, }, + { + Name: "list_cloudformation_stacks", Description: "List CloudFormation stacks in failure-first triage order with status, drift, parameters, and outputs.", + InputSchema: awsContextSchema(nil, nil), + Annotations: annotations{ReadOnlyHint: true, IdempotentHint: true, OpenWorldHint: true}, + Metadata: toolMetadata{RequiredPermissions: []string{"cloudformation:DescribeStacks", "cloudformation:ListStacks"}, OutputContract: "unic.resources.cloudformation-stacks.v1", Paginated: true}, + }, { Name: "list_cloudwatch_alarms", Description: "List CloudWatch alarms with firing alarms first.", InputSchema: awsContextSchema(nil, nil), @@ -432,7 +438,7 @@ func toolArgs(name string, raw json.RawMessage) ([]string, error) { result = append(result, "--region", args.Region) } return result, nil - case "list_ec2_instances", "list_rds_instances", "list_cloudwatch_alarms", "list_sqs_queues", "list_elasticache_resources", "list_sns_topics": + case "list_ec2_instances", "list_rds_instances", "list_cloudwatch_alarms", "list_sqs_queues", "list_elasticache_resources", "list_sns_topics", "list_cloudformation_stacks": var args struct { Profile string `json:"profile"` Region string `json:"region"` @@ -440,7 +446,7 @@ func toolArgs(name string, raw json.RawMessage) ([]string, error) { if err := decodeArguments(raw, &args); err != nil { return nil, err } - command := map[string]string{"list_ec2_instances": "ec2-instances", "list_rds_instances": "rds-instances", "list_cloudwatch_alarms": "alarms", "list_sqs_queues": "sqs-queues", "list_elasticache_resources": "elasticache-resources", "list_sns_topics": "sns-topics"}[name] + command := map[string]string{"list_ec2_instances": "ec2-instances", "list_rds_instances": "rds-instances", "list_cloudwatch_alarms": "alarms", "list_sqs_queues": "sqs-queues", "list_elasticache_resources": "elasticache-resources", "list_sns_topics": "sns-topics", "list_cloudformation_stacks": "cloudformation-stacks"}[name] return withAWSContext([]string{"resources", command, "--json"}, args.Profile, args.Region), nil case "get_ecs_service_rollout": var args struct { diff --git a/internal/mcp/server_test.go b/internal/mcp/server_test.go index a3c15e78..35f316f7 100644 --- a/internal/mcp/server_test.go +++ b/internal/mcp/server_test.go @@ -58,6 +58,7 @@ func TestReadOnlyOperationToolArgs(t *testing.T) { {"list_sqs_queues", `{"profile":"prod","region":"eu-west-1"}`, []string{"resources", "sqs-queues", "--json", "--profile", "prod", "--region", "eu-west-1"}}, {"list_elasticache_resources", `{"profile":"prod","region":"eu-west-1"}`, []string{"resources", "elasticache-resources", "--json", "--profile", "prod", "--region", "eu-west-1"}}, {"list_sns_topics", `{"profile":"prod","region":"eu-west-1"}`, []string{"resources", "sns-topics", "--json", "--profile", "prod", "--region", "eu-west-1"}}, + {"list_cloudformation_stacks", `{"profile":"prod","region":"eu-west-1"}`, []string{"resources", "cloudformation-stacks", "--json", "--profile", "prod", "--region", "eu-west-1"}}, {"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"}}, @@ -105,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_cloudformation_stacks" { + want := []string{"cloudformation:DescribeStacks", "cloudformation:ListStacks"} + 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 17204ec9..9c0ff865 100644 --- a/skills/unic-aws/SKILL.md +++ b/skills/unic-aws/SKILL.md @@ -11,6 +11,7 @@ Use the `unic` MCP server for supported AWS inspection and context planning. 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 a discovered read-only resource tool with optional `profile` and `region` arguments; for example, `list_backup_vaults` or `list_sns_topics`. +4. Call a discovered read-only resource tool with optional `profile` and `region` arguments; for example, `list_backup_vaults` or `list_cloudformation_stacks`. 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.