Skip to content
Open
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
6 changes: 4 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -164,10 +164,11 @@ For automation, `unic resources <query> --json` runs a read-only AWS query using
- `ecs-rollout --cluster <name-or-arn> --service <name-or-arn>`
- `cloudtrail-events [--since 24h] [--resource <name>] [--mutations-only]`
- `elb-target-health --load-balancer <arn>`
- `sqs-queues`

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`, `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 @@ -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 provides the read-only resource operations listed above, including `list_sqs_queues`, 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 deepest SQS backlogs and their 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.
Expand Down
2 changes: 2 additions & 0 deletions docs/development.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@ Discovery output is deterministic, versioned JSON. New executable commands shoul

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 sqs-queues --json` reuses the backlog ordering and DLQ relationships from `AwsRepository.ListQueues`. Keep that CLI/MCP contract read-only; SQS purge and redrive remain confirmation-gated TUI mutations.

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
2 changes: 1 addition & 1 deletion internal/cli/resources.go
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,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(), newSQSQueuesCmd())
return cmd
}

Expand Down
17 changes: 17 additions & 0 deletions internal/cli/resources_operations.go
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,13 @@ var (
}
return repo.ListTargetGroupHealth(ctx, arn)
}
loadSQSQueues = func(ctx context.Context) ([]awsservice.SQSQueue, error) {
repo, err := resourceRepository(ctx)
if err != nil {
return nil, err
}
return repo.ListQueues(ctx)
}
)

func writeResourceJSON(cmd *cobra.Command, data any, complete bool, warnings []string) error {
Expand Down Expand Up @@ -180,3 +187,13 @@ func newELBTargetHealthCmd() *cobra.Command {
_ = cmd.MarkFlagRequired("load-balancer")
return cmd
}

func newSQSQueuesCmd() *cobra.Command {
return jsonResourceCommand("sqs-queues", "List SQS queues by backlog as JSON", func(ctx context.Context) (any, error) {
items, err := loadSQSQueues(ctx)
if items == nil {
items = []awsservice.SQSQueue{}
}
return items, err
})
}
60 changes: 60 additions & 0 deletions internal/cli/resources_operations_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"bytes"
"context"
"encoding/json"
"errors"
"testing"

awsservice "unic/internal/services/aws"
Expand Down Expand Up @@ -66,6 +67,65 @@ func TestEC2InstancesEmptyDataIsArrayAndDiscoveryIsReadOnlyV1(t *testing.T) {
}
}

func TestSQSQueuesJSONContract(t *testing.T) {
original := loadSQSQueues
defer func() { loadSQSQueues = original }()
loadSQSQueues = func(context.Context) ([]awsservice.SQSQueue, error) {
return []awsservice.SQSQueue{
{
Name: "orders-dlq", ARN: "arn:aws:sqs:us-east-1:123456789012:orders-dlq",
Region: "us-east-1", Depth: 42,
SourceQueueARNs: []string{"arn:aws:sqs:us-east-1:123456789012:orders", "arn:aws:sqs:us-east-1:123456789012:payments"}, SourceQueueCount: 2,
},
{Name: "idle", SourceQueueARNs: []string{}},
}, nil
}
cmd := NewRootCmd()
var output bytes.Buffer
cmd.SetOut(&output)
cmd.SetArgs([]string{"resources", "sqs-queues", "--json"})
if err := cmd.Execute(); err != nil {
t.Fatal(err)
}
var result struct {
SchemaVersion string `json:"schema_version"`
Data []struct {
Name string `json:"name"`
Depth int `json:"depth"`
SourceQueueARNs []string `json:"source_queue_arns"`
SourceQueueCount int `json:"source_queue_count"`
} `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-dlq" ||
result.Data[0].Depth != 42 || len(result.Data[0].SourceQueueARNs) != 2 || result.Data[0].SourceQueueCount != 2 ||
result.Data[1].Name != "idle" || result.Data[1].SourceQueueARNs == nil || len(result.Data[1].SourceQueueARNs) != 0 || result.Data[1].SourceQueueCount != 0 ||
result.Warnings == nil || !result.Pagination.Complete {
t.Fatalf("unexpected result: %+v", result)
}
}

func TestSQSQueuesReturnsLoaderErrorWithoutJSON(t *testing.T) {
original := loadSQSQueues
defer func() { loadSQSQueues = original }()
wantErr := errors.New("queue lookup failed")
loadSQSQueues = func(context.Context) ([]awsservice.SQSQueue, error) { return nil, wantErr }
cmd := NewRootCmd()
var output bytes.Buffer
cmd.SetOut(&output)
cmd.SetArgs([]string{"resources", "sqs-queues", "--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 }()
Expand Down
2 changes: 1 addition & 1 deletion internal/mcp/agent_surface_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.FeatureSQSBrowser: {command: "sqs-queues", tool: "list_sqs_queues"},
}

var agentSurfaceExempt = map[domain.FeatureKind]string{
Expand Down Expand Up @@ -57,7 +58,6 @@ var agentSurfaceExempt = map[domain.FeatureKind]string{
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",
domain.FeatureStepFunctionsBrowser: "the failure-first execution view has no curated agent contract yet",
Expand Down
13 changes: 11 additions & 2 deletions internal/mcp/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,15 @@ 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_sqs_queues", Description: "List SQS queues by backlog with dead-letter queue relationships.",
InputSchema: awsContextSchema(nil, nil),
Annotations: annotations{ReadOnlyHint: true, IdempotentHint: true, OpenWorldHint: true},
Metadata: toolMetadata{
RequiredPermissions: []string{"sqs:ListQueues", "sqs:GetQueueAttributes"},
OutputContract: "unic.resources.sqs-queues.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 @@ -405,15 +414,15 @@ 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_cloudwatch_alarms", "list_sqs_queues":
var args struct {
Profile string `json:"profile"`
Region string `json:"region"`
}
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_cloudwatch_alarms": "alarms", "list_sqs_queues": "sqs-queues"}[name]
return withAWSContext([]string{"resources", command, "--json"}, args.Profile, args.Region), nil
case "get_ecs_service_rollout":
var args struct {
Expand Down
1 change: 1 addition & 0 deletions internal/mcp/server_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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_sqs_queues", `{"profile":"prod","region":"eu-west-1"}`, []string{"resources", "sqs-queues", "--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"}},
Expand Down
34 changes: 18 additions & 16 deletions internal/services/aws/sqs.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (
"strings"
"sync"

awssdk "github.com/aws/aws-sdk-go-v2/aws"
"github.com/aws/aws-sdk-go-v2/service/sqs"
sqstypes "github.com/aws/aws-sdk-go-v2/service/sqs/types"

Expand All @@ -17,20 +18,20 @@ import (

// SQSQueue is one queue with the backlog fields operators triage by.
type SQSQueue struct {
Name string
URL string
ARN string
Region string
Depth int // ApproximateNumberOfMessages
InFlight int // ApproximateNumberOfMessagesNotVisible
Delayed int
VisibilitySec int
RetentionSec int
Fifo bool
DLQTargetARN string // where this queue's failures go
MaxReceiveCount int
SourceQueueARNs []string // queues that dead-letter into this one
SourceQueueCount int // len(SourceQueueARNs), kept for display
Name string `json:"name"`
URL string `json:"url"`
ARN string `json:"arn"`
Region string `json:"region"`
Depth int `json:"depth"` // ApproximateNumberOfMessages
InFlight int `json:"in_flight"` // ApproximateNumberOfMessagesNotVisible
Delayed int `json:"delayed"`
VisibilitySec int `json:"visibility_seconds"`
RetentionSec int `json:"retention_seconds"`
Fifo bool `json:"fifo"`
DLQTargetARN string `json:"dlq_target_arn,omitempty"` // where this queue's failures go
MaxReceiveCount int `json:"max_receive_count,omitempty"`
SourceQueueARNs []string `json:"source_queue_arns"` // queues that dead-letter into this one
Comment thread
coderabbitai[bot] marked this conversation as resolved.
SourceQueueCount int `json:"source_queue_count"` // len(SourceQueueARNs), kept for display
}

// IsDLQ reports whether other queues dead-letter into this queue.
Expand Down Expand Up @@ -69,7 +70,7 @@ func (r *AwsRepository) ListQueues(ctx context.Context) ([]SQSQueue, error) {
uniclog.Debug("aws", "ListQueues called")

var urls []string
paginator := sqs.NewListQueuesPaginator(r.SQSClient, &sqs.ListQueuesInput{})
paginator := sqs.NewListQueuesPaginator(r.SQSClient, &sqs.ListQueuesInput{MaxResults: awssdk.Int32(1000)})
for paginator.HasMorePages() {
page, err := paginator.NextPage(ctx)
if err != nil {
Expand Down Expand Up @@ -108,7 +109,8 @@ func (r *AwsRepository) ListQueues(ctx context.Context) ([]SQSQueue, error) {
}
}
for i := range queues {
queues[i].SourceQueueARNs = sources[queues[i].ARN]
queues[i].SourceQueueARNs = append([]string{}, sources[queues[i].ARN]...)
sort.Strings(queues[i].SourceQueueARNs)
queues[i].SourceQueueCount = len(queues[i].SourceQueueARNs)
}

Expand Down
26 changes: 22 additions & 4 deletions internal/services/aws/sqs_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package aws
import (
"context"
"errors"
"slices"
"strings"
"testing"

Expand Down Expand Up @@ -61,6 +62,9 @@ func TestListQueuesSortsByBacklogAndResolvesDLQ(t *testing.T) {
map[string]string{"RedrivePolicy": `{"deadLetterTargetArn":"arn:aws:sqs:us-east-1:1:orders-dlq","maxReceiveCount":3}`}),
"https://sqs.us-east-1.amazonaws.com/1/orders-dlq": sqsQueueAttrs(
"arn:aws:sqs:us-east-1:1:orders-dlq", "120", nil),
"https://sqs.us-east-1.amazonaws.com/1/payments": sqsQueueAttrs(
"arn:aws:sqs:us-east-1:1:payments", "3",
map[string]string{"RedrivePolicy": `{"deadLetterTargetArn":"arn:aws:sqs:us-east-1:1:orders-dlq","maxReceiveCount":5}`}),
"https://sqs.us-east-1.amazonaws.com/1/idle": sqsQueueAttrs(
"arn:aws:sqs:us-east-1:1:idle", "0", nil),
}
Expand All @@ -82,24 +86,35 @@ func TestListQueuesSortsByBacklogAndResolvesDLQ(t *testing.T) {
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(queues) != 3 {
t.Fatalf("expected 3 queues, got %d", len(queues))
if len(queues) != 4 {
t.Fatalf("expected 4 queues, got %d", len(queues))
}
if queues[0].Name != "orders-dlq" || queues[0].Depth != 120 {
t.Fatalf("expected deepest backlog first, got %+v", queues[0])
}
if !queues[0].IsDLQ() || queues[0].SourceQueueCount != 1 {
t.Fatalf("expected orders-dlq marked as DLQ with one source, got %+v", queues[0])
if !queues[0].IsDLQ() || queues[0].SourceQueueCount != 2 {
t.Fatalf("expected orders-dlq marked as DLQ with two sources, got %+v", queues[0])
}
wantSources := []string{"arn:aws:sqs:us-east-1:1:orders", "arn:aws:sqs:us-east-1:1:payments"}
if !slices.Equal(queues[0].SourceQueueARNs, wantSources) {
t.Fatalf("expected sorted source queue ARNs %v, got %v", wantSources, queues[0].SourceQueueARNs)
}
var orders SQSQueue
var idle SQSQueue
for _, queue := range queues {
if queue.Name == "orders" {
orders = queue
}
if queue.Name == "idle" {
idle = queue
}
}
if orders.DLQTargetARN != "arn:aws:sqs:us-east-1:1:orders-dlq" || orders.MaxReceiveCount != 3 {
t.Fatalf("expected redrive policy parsed, got %+v", orders)
}
if idle.SourceQueueARNs == nil || idle.SourceQueueCount != 0 {
t.Fatalf("expected empty source queue array, got %+v", idle)
}
if !strings.Contains(queues[0].DisplayTitle(), "!") {
t.Fatalf("expected DLQ marker in display title, got %q", queues[0].DisplayTitle())
}
Expand Down Expand Up @@ -133,6 +148,9 @@ func TestListQueuesAggregatesPages(t *testing.T) {
page := 0
mock := &mockSQSClient{
listQueuesFunc: func(_ context.Context, params *sqs.ListQueuesInput, _ ...func(*sqs.Options)) (*sqs.ListQueuesOutput, error) {
if got := awssdk.ToInt32(params.MaxResults); got != 1000 {
t.Fatalf("expected max SQS page size, got %d", got)
}
page++
if page == 1 {
token := "next"
Expand Down
Loading