From c7e2fed6eb0b6adb116f2083e9921681588762a3 Mon Sep 17 00:00:00 2001 From: YoungJin Date: Tue, 15 Sep 2026 14:30:56 +0900 Subject: [PATCH 1/2] feat: expose SNS topics to automation - join topics with per-topic subscriptions and partial-result warnings - register the versioned CLI and MCP contracts - document discovery, permissions, and agent usage --- README.md | 6 +- docs/development.md | 3 + internal/cli/resources.go | 32 ++++++++++- internal/cli/resources_operations.go | 51 +++++++++++++++++ internal/cli/resources_operations_test.go | 70 +++++++++++++++++++++++ internal/mcp/agent_surface_test.go | 2 +- internal/mcp/server.go | 13 ++++- internal/mcp/server_test.go | 1 + internal/services/aws/sns.go | 27 +++++++++ internal/services/aws/sns_model.go | 8 +++ internal/services/aws/sns_test.go | 36 ++++++++++++ skills/unic-aws/SKILL.md | 4 +- 12 files changed, 245 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 67b9efe0..df92da59 100644 --- a/README.md +++ b/README.md @@ -160,6 +160,7 @@ For automation, `unic resources --json` runs a read-only AWS query using - `backup-vaults` - `ec2-instances` - `rds-instances` +- `sns-topics` - `alarms` - `ecs-rollout --cluster --service ` - `cloudtrail-events [--since 24h] [--resource ] [--mutations-only]` @@ -167,7 +168,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`, `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`, `sns:ListTopics`, `sns:GetTopicAttributes`, `sns:ListSubscriptionsByTopic`, `sns:GetSubscriptionAttributes`, `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. ### 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_sns_topics`—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 my SNS topics, subscriptions, and dead-letter queue relationships.` - `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..e2135c0a 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 sns-topics --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 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. + 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..70607ba1 100644 --- a/internal/cli/resources.go +++ b/internal/cli/resources.go @@ -35,6 +35,36 @@ type backupVaultJSON struct { Locked bool `json:"locked"` } +type snsTopicJSON struct { + ARN string `json:"arn"` + Name string `json:"name"` + DisplayName string `json:"display_name,omitempty"` + Region string `json:"region"` + Type string `json:"type"` + KMSMasterKeyID string `json:"kms_master_key_id,omitempty"` + DeliveryPolicy string `json:"delivery_policy,omitempty"` + EffectiveDeliveryPolicy string `json:"effective_delivery_policy,omitempty"` + SubscriptionsConfirmed int `json:"subscriptions_confirmed"` + SubscriptionsPending int `json:"subscriptions_pending"` + SubscriptionsDeleted int `json:"subscriptions_deleted"` + ContentBasedDeduplication bool `json:"content_based_deduplication"` + AttributesKnown bool `json:"attributes_known"` + Subscriptions []snsSubscriptionJSON `json:"subscriptions"` +} + +type snsSubscriptionJSON struct { + ARN string `json:"arn"` + Protocol string `json:"protocol"` + Endpoint string `json:"endpoint"` + Owner string `json:"owner"` + TopicARN string `json:"topic_arn"` + Status string `json:"status"` + RawMessageDelivery bool `json:"raw_message_delivery"` + DeadLetterTargetARN string `json:"dead_letter_target_arn,omitempty"` + FilterPolicy string `json:"filter_policy,omitempty"` + AttributesKnown bool `json:"attributes_known"` +} + var loadBackupVaults = func(ctx context.Context) ([]awsservice.BackupVault, []error, error) { configPath, err := config.DefaultPath() if err != nil { @@ -58,7 +88,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(), newSNSTopicsCmd()) return cmd } diff --git a/internal/cli/resources_operations.go b/internal/cli/resources_operations.go index 4444dfbd..58e354da 100644 --- a/internal/cli/resources_operations.go +++ b/internal/cli/resources_operations.go @@ -70,6 +70,13 @@ var ( } return repo.ListTargetGroupHealth(ctx, arn) } + loadSNSTopicResources = func(ctx context.Context) ([]awsservice.SNSTopicResource, []error, error) { + repo, err := resourceRepository(ctx) + if err != nil { + return nil, nil, err + } + return repo.ListSNSTopicResources(ctx) + } ) func writeResourceJSON(cmd *cobra.Command, data any, complete bool, warnings []string) error { @@ -180,3 +187,47 @@ func newELBTargetHealthCmd() *cobra.Command { _ = cmd.MarkFlagRequired("load-balancer") return cmd } + +func newSNSTopicsCmd() *cobra.Command { + var jsonOutput bool + cmd := &cobra.Command{ + Use: "sns-topics", Short: "List SNS topics and subscriptions as JSON", Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + if !jsonOutput { + return errors.New("this automation command supports JSON output only") + } + resources, warningErrors, err := loadSNSTopicResources(cmd.Context()) + if err != nil { + return err + } + data := make([]snsTopicJSON, 0, len(resources)) + for _, resource := range resources { + subscriptions := make([]snsSubscriptionJSON, 0, len(resource.Subscriptions)) + for _, subscription := range resource.Subscriptions { + subscriptions = append(subscriptions, snsSubscriptionJSON{ + ARN: subscription.ARN, Protocol: subscription.Protocol, Endpoint: subscription.Endpoint, + Owner: subscription.Owner, TopicARN: subscription.TopicARN, Status: subscription.Status(), + RawMessageDelivery: subscription.RawMessageDelivery, DeadLetterTargetARN: subscription.DeadLetterTargetARN(), + FilterPolicy: subscription.FilterPolicy, AttributesKnown: subscription.AttributesKnown, + }) + } + topic := resource.Topic + data = append(data, snsTopicJSON{ + ARN: topic.ARN, Name: topic.Name, DisplayName: topic.DisplayName, Region: topic.Region, Type: topic.KindLabel(), + KMSMasterKeyID: topic.KMSMasterKeyID, DeliveryPolicy: topic.DeliveryPolicy, EffectiveDeliveryPolicy: topic.EffectiveDeliveryPolicy, + SubscriptionsConfirmed: topic.SubscriptionsConfirmed, SubscriptionsPending: topic.SubscriptionsPending, + SubscriptionsDeleted: topic.SubscriptionsDeleted, ContentBasedDeduplication: topic.ContentBasedDeduplication, + AttributesKnown: topic.AttributesKnown, Subscriptions: subscriptions, + }) + } + warnings := make([]string, 0, len(warningErrors)) + for _, warning := range warningErrors { + warnings = append(warnings, warning.Error()) + } + return writeResourceJSON(cmd, data, len(warnings) == 0, warnings) + }, + } + cmd.Annotations = map[string]string{annotationReadOnly: "true", annotationOutputVersion: "v1"} + cmd.Flags().BoolVar(&jsonOutput, "json", true, "Emit stable machine-readable JSON") + return cmd +} diff --git a/internal/cli/resources_operations_test.go b/internal/cli/resources_operations_test.go index ac319b8b..c7aab3e0 100644 --- a/internal/cli/resources_operations_test.go +++ b/internal/cli/resources_operations_test.go @@ -4,6 +4,7 @@ import ( "bytes" "context" "encoding/json" + "errors" "testing" awsservice "unic/internal/services/aws" @@ -66,6 +67,75 @@ func TestEC2InstancesEmptyDataIsArrayAndDiscoveryIsReadOnlyV1(t *testing.T) { } } +func TestSNSTopicsJSONContractPreservesWarningsAndEmptyArrays(t *testing.T) { + original := loadSNSTopicResources + defer func() { loadSNSTopicResources = original }() + loadSNSTopicResources = func(context.Context) ([]awsservice.SNSTopicResource, []error, error) { + return []awsservice.SNSTopicResource{ + { + Topic: awsservice.SNSTopic{ + ARN: "arn:aws:sns:eu-west-1:1:orders.fifo", Name: "orders.fifo", Region: "eu-west-1", + KMSMasterKeyID: "alias/aws/sns", SubscriptionsConfirmed: 1, FIFO: true, + ContentBasedDeduplication: true, AttributesKnown: true, + }, + Subscriptions: []awsservice.SNSSubscription{{ + ARN: "arn:sub:orders", Protocol: "sqs", Endpoint: "arn:queue", TopicARN: "arn:aws:sns:eu-west-1:1:orders.fifo", + RedrivePolicy: `{"deadLetterTargetArn":"arn:dlq"}`, AttributesKnown: true, + }}, + }, + {Topic: awsservice.SNSTopic{ARN: "arn:aws:sns:eu-west-1:1:locked", Name: "locked", Region: "eu-west-1"}, Subscriptions: []awsservice.SNSSubscription{}}, + }, []error{errors.New("failed to list subscriptions for locked")}, nil + } + + cmd := NewRootCmd() + var output bytes.Buffer + cmd.SetOut(&output) + cmd.SetArgs([]string{"resources", "sns-topics", "--json"}) + if err := cmd.Execute(); err != nil { + t.Fatal(err) + } + var result struct { + SchemaVersion string `json:"schema_version"` + Data []struct { + Name string `json:"name"` + Type string `json:"type"` + Subscriptions []struct { + Status string `json:"status"` + DeadLetterTargetARN string `json:"dead_letter_target_arn"` + } `json:"subscriptions"` + } `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 != "orders.fifo" || result.Data[0].Type != "FIFO" || + len(result.Data[0].Subscriptions) != 1 || result.Data[0].Subscriptions[0].Status != "confirmed" || result.Data[0].Subscriptions[0].DeadLetterTargetARN != "arn:dlq" || + result.Data[1].Subscriptions == nil || len(result.Warnings) != 1 || result.Pagination.Complete { + t.Fatalf("unexpected result: %+v", result) + } +} + +func TestSNSTopicsLoaderErrorEmitsNoEnvelope(t *testing.T) { + original := loadSNSTopicResources + defer func() { loadSNSTopicResources = original }() + wantErr := errors.New("topic lookup failed") + loadSNSTopicResources = func(context.Context) ([]awsservice.SNSTopicResource, []error, error) { + return nil, nil, wantErr + } + cmd := NewRootCmd() + var output bytes.Buffer + cmd.SetOut(&output) + cmd.SetArgs([]string{"resources", "sns-topics", "--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()) + } +} + func TestCloudTrailEventsReportsCapAsIncomplete(t *testing.T) { original := loadCloudTrailEvents defer func() { loadCloudTrailEvents = original }() diff --git a/internal/mcp/agent_surface_test.go b/internal/mcp/agent_surface_test.go index 7d9d24c1..5668b519 100644 --- a/internal/mcp/agent_surface_test.go +++ b/internal/mcp/agent_surface_test.go @@ -29,6 +29,7 @@ var agentSurfaceByFeature = map[domain.FeatureKind]agentSurface{ 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.FeatureSNSBrowser: {command: "sns-topics", tool: "list_sns_topics"}, } var agentSurfaceExempt = map[domain.FeatureKind]string{ @@ -56,7 +57,6 @@ var agentSurfaceExempt = map[domain.FeatureKind]string{ domain.FeatureS3Browser: "object browsing needs an explicitly bounded pagination contract", domain.FeatureSecurityGroupBrowser: "no curated security-group rule query is defined yet", domain.FeatureSecretsBrowser: "secret values require operator-controlled reveal and copy handling", - domain.FeatureSNSBrowser: "the joined topic and subscription view has no agent contract yet", 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", diff --git a/internal/mcp/server.go b/internal/mcp/server.go index b43d9ad6..6bc905f7 100644 --- a/internal/mcp/server.go +++ b/internal/mcp/server.go @@ -120,6 +120,15 @@ var tools = []tool{ Annotations: annotations{ReadOnlyHint: true, IdempotentHint: true, OpenWorldHint: true}, Metadata: toolMetadata{RequiredPermissions: []string{"rds:DescribeDBInstances"}, OutputContract: "unic.resources.rds-instances.v1", Paginated: true}, }, + { + Name: "list_sns_topics", Description: "List SNS topics with subscriptions, delivery settings, and dead-letter queue relationships.", + InputSchema: awsContextSchema(nil, nil), + Annotations: annotations{ReadOnlyHint: true, IdempotentHint: true, OpenWorldHint: true}, + Metadata: toolMetadata{ + RequiredPermissions: []string{"sns:ListTopics", "sns:GetTopicAttributes", "sns:ListSubscriptionsByTopic", "sns:GetSubscriptionAttributes"}, + OutputContract: "unic.resources.sns-topics.v1", Paginated: true, PartialResults: true, + }, + }, { Name: "list_cloudwatch_alarms", Description: "List CloudWatch alarms with firing alarms first.", InputSchema: awsContextSchema(nil, nil), @@ -405,7 +414,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": + case "list_ec2_instances", "list_rds_instances", "list_sns_topics", "list_cloudwatch_alarms": var args struct { Profile string `json:"profile"` Region string `json:"region"` @@ -413,7 +422,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"}[name] + command := map[string]string{"list_ec2_instances": "ec2-instances", "list_rds_instances": "rds-instances", "list_sns_topics": "sns-topics", "list_cloudwatch_alarms": "alarms"}[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 e139f006..ce054472 100644 --- a/internal/mcp/server_test.go +++ b/internal/mcp/server_test.go @@ -55,6 +55,7 @@ func TestReadOnlyOperationToolArgs(t *testing.T) { want []string }{ {"list_ec2_instances", `{"profile":"prod","region":"eu-west-1"}`, []string{"resources", "ec2-instances", "--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"}}, {"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"}}, diff --git a/internal/services/aws/sns.go b/internal/services/aws/sns.go index 2e90d688..c8737e6d 100644 --- a/internal/services/aws/sns.go +++ b/internal/services/aws/sns.go @@ -149,6 +149,33 @@ func (r *AwsRepository) ListSNSSubscriptionsByTopic(ctx context.Context, topicAR return subscriptions, warnings, nil } +// ListSNSTopicResources joins topics with their subscriptions for read-only +// automation. A subscription-list failure remains a warning so one restrictive +// topic policy cannot hide resources that loaded successfully. +func (r *AwsRepository) ListSNSTopicResources(ctx context.Context) ([]SNSTopicResource, []error, error) { + topics, warnings, err := r.ListSNSTopics(ctx) + if err != nil { + return nil, nil, err + } + + resources := make([]SNSTopicResource, 0, len(topics)) + for _, topic := range topics { + subscriptions, subscriptionWarnings, err := r.ListSNSSubscriptionsByTopic(ctx, topic.ARN) + if err != nil { + if ctx.Err() != nil { + return nil, nil, ctx.Err() + } + warnings = append(warnings, err) + subscriptions = []SNSSubscription{} + } else if subscriptions == nil { + subscriptions = []SNSSubscription{} + } + warnings = append(warnings, subscriptionWarnings...) + resources = append(resources, SNSTopicResource{Topic: topic, Subscriptions: subscriptions}) + } + return resources, warnings, nil +} + func snsSubscriptionSortRank(subscription SNSSubscription) int { switch subscription.Status() { case "pending": diff --git a/internal/services/aws/sns_model.go b/internal/services/aws/sns_model.go index 2cff59ee..2ff57860 100644 --- a/internal/services/aws/sns_model.go +++ b/internal/services/aws/sns_model.go @@ -25,6 +25,14 @@ type SNSTopic struct { AttributesKnown bool } +// SNSTopicResource joins one topic with the subscriptions operators inspect +// beneath it. It is shared by read-only automation without changing the TUI's +// lazy per-topic loading flow. +type SNSTopicResource struct { + Topic SNSTopic + Subscriptions []SNSSubscription +} + // IsFIFO reports whether the topic is a FIFO topic. SNS marks these with a // .fifo ARN suffix as well as the FifoTopic attribute, so the name is used as // a fallback when attributes could not be read. diff --git a/internal/services/aws/sns_test.go b/internal/services/aws/sns_test.go index 15bc3768..b76b55ad 100644 --- a/internal/services/aws/sns_test.go +++ b/internal/services/aws/sns_test.go @@ -196,6 +196,42 @@ func TestListSNSSubscriptionsSkipsPendingAttributesAndSortsPendingFirst(t *testi } } +func TestListSNSTopicResourcesKeepsPartialSubscriptionResults(t *testing.T) { + client := &mockSNSClient{ + listTopicsFunc: func(context.Context, *sns.ListTopicsInput, ...func(*sns.Options)) (*sns.ListTopicsOutput, error) { + return &sns.ListTopicsOutput{Topics: []snstypes.Topic{ + {TopicArn: awssdk.String("arn:aws:sns:us-east-1:1:alpha")}, + {TopicArn: awssdk.String("arn:aws:sns:us-east-1:1:locked")}, + }}, nil + }, + getTopicAttributesFunc: func(context.Context, *sns.GetTopicAttributesInput, ...func(*sns.Options)) (*sns.GetTopicAttributesOutput, error) { + return &sns.GetTopicAttributesOutput{Attributes: map[string]string{}}, nil + }, + listSubscriptionsByTopicFunc: func(_ context.Context, in *sns.ListSubscriptionsByTopicInput, _ ...func(*sns.Options)) (*sns.ListSubscriptionsByTopicOutput, error) { + if awssdk.ToString(in.TopicArn) == "arn:aws:sns:us-east-1:1:locked" { + return nil, errors.New("AuthorizationError") + } + return &sns.ListSubscriptionsByTopicOutput{Subscriptions: []snstypes.Subscription{{ + SubscriptionArn: awssdk.String("arn:sub:alpha"), Protocol: awssdk.String("sqs"), Endpoint: awssdk.String("arn:queue"), + }}}, nil + }, + getSubscriptionAttributesFunc: func(context.Context, *sns.GetSubscriptionAttributesInput, ...func(*sns.Options)) (*sns.GetSubscriptionAttributesOutput, error) { + return &sns.GetSubscriptionAttributesOutput{Attributes: map[string]string{}}, nil + }, + } + + resources, warnings, err := (&AwsRepository{SNSClient: client, Region: "us-east-1"}).ListSNSTopicResources(context.Background()) + if err != nil { + t.Fatal(err) + } + if len(resources) != 2 || len(resources[0].Subscriptions) != 1 || resources[1].Subscriptions == nil { + t.Fatalf("expected successful and denied topics to remain in stable arrays, got %+v", resources) + } + if len(warnings) != 1 || !strings.Contains(warnings[0].Error(), "locked") { + t.Fatalf("expected the denied subscription list as one warning, got %v", warnings) + } +} + func TestSNSSubscriptionStatusHandlesSentinels(t *testing.T) { for arn, want := range map[string]string{ "PendingConfirmation": "pending", diff --git a/skills/unic-aws/SKILL.md b/skills/unic-aws/SKILL.md index d93f84f0..17204ec9 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_sns_topics`. 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. From 16f1f755d88f690909ce5065e80f49723967f850 Mon Sep 17 00:00:00 2001 From: YoungJin Date: Tue, 15 Sep 2026 14:39:12 +0900 Subject: [PATCH 2/2] fix: preserve SNS filter policy scope - expose MessageBody versus MessageAttributes semantics - default an omitted known scope to the AWS default - cover repository mapping and the JSON contract --- internal/cli/resources.go | 1 + internal/cli/resources_operations.go | 3 ++- internal/cli/resources_operations_test.go | 5 ++++- internal/services/aws/sns.go | 4 ++++ internal/services/aws/sns_model.go | 1 + internal/services/aws/sns_test.go | 11 ++++++++++- 6 files changed, 22 insertions(+), 3 deletions(-) diff --git a/internal/cli/resources.go b/internal/cli/resources.go index 70607ba1..fdad8f57 100644 --- a/internal/cli/resources.go +++ b/internal/cli/resources.go @@ -62,6 +62,7 @@ type snsSubscriptionJSON struct { RawMessageDelivery bool `json:"raw_message_delivery"` DeadLetterTargetARN string `json:"dead_letter_target_arn,omitempty"` FilterPolicy string `json:"filter_policy,omitempty"` + FilterPolicyScope string `json:"filter_policy_scope,omitempty"` AttributesKnown bool `json:"attributes_known"` } diff --git a/internal/cli/resources_operations.go b/internal/cli/resources_operations.go index 58e354da..22e009de 100644 --- a/internal/cli/resources_operations.go +++ b/internal/cli/resources_operations.go @@ -208,7 +208,8 @@ func newSNSTopicsCmd() *cobra.Command { ARN: subscription.ARN, Protocol: subscription.Protocol, Endpoint: subscription.Endpoint, Owner: subscription.Owner, TopicARN: subscription.TopicARN, Status: subscription.Status(), RawMessageDelivery: subscription.RawMessageDelivery, DeadLetterTargetARN: subscription.DeadLetterTargetARN(), - FilterPolicy: subscription.FilterPolicy, AttributesKnown: subscription.AttributesKnown, + FilterPolicy: subscription.FilterPolicy, FilterPolicyScope: subscription.FilterPolicyScope, + AttributesKnown: subscription.AttributesKnown, }) } topic := resource.Topic diff --git a/internal/cli/resources_operations_test.go b/internal/cli/resources_operations_test.go index c7aab3e0..d1c6e749 100644 --- a/internal/cli/resources_operations_test.go +++ b/internal/cli/resources_operations_test.go @@ -80,7 +80,8 @@ func TestSNSTopicsJSONContractPreservesWarningsAndEmptyArrays(t *testing.T) { }, Subscriptions: []awsservice.SNSSubscription{{ ARN: "arn:sub:orders", Protocol: "sqs", Endpoint: "arn:queue", TopicARN: "arn:aws:sns:eu-west-1:1:orders.fifo", - RedrivePolicy: `{"deadLetterTargetArn":"arn:dlq"}`, AttributesKnown: true, + RedrivePolicy: `{"deadLetterTargetArn":"arn:dlq"}`, FilterPolicy: `{"event":["created"]}`, + FilterPolicyScope: "MessageBody", AttributesKnown: true, }}, }, {Topic: awsservice.SNSTopic{ARN: "arn:aws:sns:eu-west-1:1:locked", Name: "locked", Region: "eu-west-1"}, Subscriptions: []awsservice.SNSSubscription{}}, @@ -102,6 +103,7 @@ func TestSNSTopicsJSONContractPreservesWarningsAndEmptyArrays(t *testing.T) { Subscriptions []struct { Status string `json:"status"` DeadLetterTargetARN string `json:"dead_letter_target_arn"` + FilterPolicyScope string `json:"filter_policy_scope"` } `json:"subscriptions"` } `json:"data"` Warnings []string `json:"warnings"` @@ -112,6 +114,7 @@ func TestSNSTopicsJSONContractPreservesWarningsAndEmptyArrays(t *testing.T) { } if result.SchemaVersion != "v1" || len(result.Data) != 2 || result.Data[0].Name != "orders.fifo" || result.Data[0].Type != "FIFO" || len(result.Data[0].Subscriptions) != 1 || result.Data[0].Subscriptions[0].Status != "confirmed" || result.Data[0].Subscriptions[0].DeadLetterTargetARN != "arn:dlq" || + result.Data[0].Subscriptions[0].FilterPolicyScope != "MessageBody" || result.Data[1].Subscriptions == nil || len(result.Warnings) != 1 || result.Pagination.Complete { t.Fatalf("unexpected result: %+v", result) } diff --git a/internal/services/aws/sns.go b/internal/services/aws/sns.go index c8737e6d..0e4bdf0c 100644 --- a/internal/services/aws/sns.go +++ b/internal/services/aws/sns.go @@ -215,6 +215,10 @@ func applySNSSubscriptionAttributes(subscription *SNSSubscription, attributes ma subscription.RawMessageDelivery = snsAttributeBool(attributes["RawMessageDelivery"]) subscription.RedrivePolicy = attributes["RedrivePolicy"] subscription.FilterPolicy = attributes["FilterPolicy"] + subscription.FilterPolicyScope = attributes["FilterPolicyScope"] + if subscription.FilterPolicy != "" && subscription.FilterPolicyScope == "" { + subscription.FilterPolicyScope = "MessageAttributes" + } if subscription.Owner == "" { subscription.Owner = attributes["Owner"] } diff --git a/internal/services/aws/sns_model.go b/internal/services/aws/sns_model.go index 2ff57860..15422e83 100644 --- a/internal/services/aws/sns_model.go +++ b/internal/services/aws/sns_model.go @@ -80,6 +80,7 @@ type SNSSubscription struct { RawMessageDelivery bool RedrivePolicy string FilterPolicy string + FilterPolicyScope string AttributesKnown bool } diff --git a/internal/services/aws/sns_test.go b/internal/services/aws/sns_test.go index b76b55ad..fc3825d9 100644 --- a/internal/services/aws/sns_test.go +++ b/internal/services/aws/sns_test.go @@ -160,6 +160,7 @@ func TestListSNSSubscriptionsSkipsPendingAttributesAndSortsPendingFirst(t *testi } return &sns.GetSubscriptionAttributesOutput{Attributes: map[string]string{ "RawMessageDelivery": "true", "RedrivePolicy": `{"deadLetterTargetArn":"arn:dlq"}`, + "FilterPolicy": `{"event":["created"]}`, "FilterPolicyScope": "MessageBody", }}, nil }, } @@ -191,11 +192,19 @@ func TestListSNSSubscriptionsSkipsPendingAttributesAndSortsPendingFirst(t *testi confirmed = sub } } - if !confirmed.Confirmed() || confirmed.Status() != "confirmed" || !confirmed.HasRedrive() || !confirmed.RawMessageDelivery { + if !confirmed.Confirmed() || confirmed.Status() != "confirmed" || !confirmed.HasRedrive() || !confirmed.RawMessageDelivery || confirmed.FilterPolicyScope != "MessageBody" { t.Fatalf("expected confirmed subscription attributes mapped, got %+v", confirmed) } } +func TestApplySNSSubscriptionAttributesDefaultsFilterPolicyScope(t *testing.T) { + subscription := SNSSubscription{} + applySNSSubscriptionAttributes(&subscription, map[string]string{"FilterPolicy": `{"event":["created"]}`}) + if subscription.FilterPolicyScope != "MessageAttributes" { + t.Fatalf("expected the AWS default filter policy scope, got %q", subscription.FilterPolicyScope) + } +} + func TestListSNSTopicResourcesKeepsPartialSubscriptionResults(t *testing.T) { client := &mockSNSClient{ listTopicsFunc: func(context.Context, *sns.ListTopicsInput, ...func(*sns.Options)) (*sns.ListTopicsOutput, error) {