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
5 changes: 3 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -160,14 +160,15 @@ For automation, `unic resources <query> --json` runs a read-only AWS query using
- `backup-vaults`
- `ec2-instances`
- `rds-instances`
- `elasticache-resources`
Comment thread
YoungJinJung marked this conversation as resolved.
- `alarms`
- `ecs-rollout --cluster <name-or-arn> --service <name-or-arn>`
- `cloudtrail-events [--since 24h] [--resource <name>] [--mutations-only]`
- `elb-target-health --load-balancer <arn>`

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`, `elasticache:DescribeCacheClusters`, `elasticache:DescribeReplicationGroups`, `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

Expand Down Expand Up @@ -249,7 +250,7 @@ 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_elasticache_resources`—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.`
Expand Down
1 change: 1 addition & 0 deletions docs/development.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ 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 elasticache-resources --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 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(), newElastiCacheResourcesCmd())
return cmd
}

Expand Down
22 changes: 22 additions & 0 deletions internal/cli/resources_operations.go
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,13 @@ var (
}
return repo.ListDBInstances(ctx)
}
loadElastiCacheResources = func(ctx context.Context) ([]awsservice.ElastiCacheResource, error) {
repo, err := resourceRepository(ctx)
if err != nil {
return nil, err
}
return repo.ListElastiCacheResources(ctx)
}
loadAlarms = func(ctx context.Context) ([]awsservice.CloudWatchAlarm, error) {
repo, err := resourceRepository(ctx)
if err != nil {
Expand Down Expand Up @@ -116,6 +123,21 @@ func newRDSInstancesCmd() *cobra.Command {
})
}

func newElastiCacheResourcesCmd() *cobra.Command {
return jsonResourceCommand("elasticache-resources", "List ElastiCache replication groups and standalone clusters as JSON", func(ctx context.Context) (any, error) {
items, err := loadElastiCacheResources(ctx)
if items == nil {
items = []awsservice.ElastiCacheResource{}
}
for i := range items {
if items[i].Nodes == nil {
items[i].Nodes = []awsservice.ElastiCacheNode{}
}
}
return items, err
})
}

func newAlarmsCmd() *cobra.Command {
return jsonResourceCommand("alarms", "List CloudWatch alarms as JSON", func(ctx context.Context) (any, error) {
items, err := loadAlarms(ctx)
Expand Down
57 changes: 57 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 @@ -38,6 +39,62 @@ func TestEC2InstancesJSONContract(t *testing.T) {
}
}

func TestElastiCacheResourcesJSONContract(t *testing.T) {
original := loadElastiCacheResources
defer func() { loadElastiCacheResources = original }()
loadElastiCacheResources = func(context.Context) ([]awsservice.ElastiCacheResource, error) {
return []awsservice.ElastiCacheResource{{
ID: "prod", Kind: "replication group", Engine: "valkey", EngineVersion: "8.0",
Status: "available", NodeType: "cache.r7g.large", Endpoint: "prod.cache.amazonaws.com:6379", Region: "eu-west-1",
Nodes: []awsservice.ElastiCacheNode{{ID: "0001", ClusterID: "prod-001", ShardID: "0001", Role: "primary", Status: "available", AZ: "eu-west-1a", Endpoint: "prod-001.cache.amazonaws.com:6379"}},
}, {ID: "empty", Kind: "cluster"}}, nil
}
cmd := NewRootCmd()
var output bytes.Buffer
cmd.SetOut(&output)
cmd.SetArgs([]string{"resources", "elasticache-resources", "--json"})
if err := cmd.Execute(); err != nil {
t.Fatal(err)
}
var result struct {
SchemaVersion string `json:"schema_version"`
Data []awsservice.ElastiCacheResource `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.Warnings == nil || !result.Pagination.Complete {
t.Fatalf("unexpected result: %+v", result)
}
resource := result.Data[0]
if resource.ID != "prod" || resource.EngineVersion != "8.0" || resource.NodeType != "cache.r7g.large" || resource.Region != "eu-west-1" || len(resource.Nodes) != 1 {
t.Fatalf("unexpected resource: %+v", resource)
}
if node := resource.Nodes[0]; node.ClusterID != "prod-001" || node.ShardID != "0001" || node.AZ != "eu-west-1a" {
t.Fatalf("unexpected node: %+v", node)
}
if result.Data[1].Nodes == nil {
t.Fatalf("empty nodes must be an array: %+v", result.Data[1])
}
}

func TestElastiCacheResourcesLoaderErrorEmitsNoEnvelope(t *testing.T) {
original := loadElastiCacheResources
defer func() { loadElastiCacheResources = original }()
loadElastiCacheResources = func(context.Context) ([]awsservice.ElastiCacheResource, error) {
return nil, errors.New("denied")
}
cmd := NewRootCmd()
var output bytes.Buffer
cmd.SetOut(&output)
cmd.SetArgs([]string{"resources", "elasticache-resources", "--json"})
if err := cmd.Execute(); err == nil || output.Len() != 0 {
t.Fatalf("expected loader error without success envelope, err=%v output=%q", err, output.String())
}
}

func TestCloudTrailEventsRejectsInvalidLookback(t *testing.T) {
cmd := NewRootCmd()
cmd.SetArgs([]string{"resources", "cloudtrail-events", "--since", "0s", "--json"})
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 @@ -27,6 +27,7 @@ var agentSurfaceByFeature = map[domain.FeatureKind]agentSurface{
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"},
}
Expand All @@ -43,7 +44,6 @@ var agentSurfaceExempt = map[domain.FeatureKind]string{
domain.FeatureECRLoginHelper: "the credential-bearing shell handoff is not an agent resource query",
domain.FeatureECRRepositoryBrowser: "no curated repository and image query is defined yet",
domain.FeatureEKSBrowser: "no curated cluster and node-group query is defined yet",
domain.FeatureElastiCacheBrowser: "the joined replication-group and node view has no agent contract yet",
domain.FeatureEventBridgeRules: "rule mutations are confirmation-gated and no separate read-only contract exists yet",
domain.FeatureFISTemplateBrowser: "no curated experiment-template and history query is defined yet",
domain.FeatureIAMUsersBrowser: "no curated IAM user posture query is defined 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 @@ -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_elasticache_resources", Description: "List ElastiCache replication groups and standalone clusters with node status and endpoints.",
InputSchema: awsContextSchema(nil, nil),
Annotations: annotations{ReadOnlyHint: true, IdempotentHint: true, OpenWorldHint: true},
Metadata: toolMetadata{
RequiredPermissions: []string{"elasticache:DescribeCacheClusters", "elasticache:DescribeReplicationGroups"},
OutputContract: "unic.resources.elasticache-resources.v1", Paginated: true,
},
},
{
Name: "list_cloudwatch_alarms", Description: "List CloudWatch alarms with firing alarms first.",
InputSchema: awsContextSchema(nil, nil),
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_elasticache_resources", "list_cloudwatch_alarms":
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_elasticache_resources": "elasticache-resources", "list_cloudwatch_alarms": "alarms"}[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_elasticache_resources", `{"profile":"prod","region":"eu-west-1"}`, []string{"resources", "elasticache-resources", "--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
5 changes: 5 additions & 0 deletions internal/services/aws/elasticache.go
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,9 @@ func (r *AwsRepository) ListElastiCacheResources(ctx context.Context) ([]ElastiC
}
resources = append(resources, mapElastiCacheCluster(cluster))
}
for i := range resources {
resources[i].Region = r.Region
}

sort.Slice(resources, func(i, j int) bool {
left := normalizedSortKey(resources[i].ID)
Expand Down Expand Up @@ -85,6 +88,7 @@ func mapElastiCacheReplicationGroup(group elasticachetypes.ReplicationGroup, clu
Status: awssdk.ToString(group.Status),
NodeType: awssdk.ToString(group.CacheNodeType),
Endpoint: formatElastiCacheEndpoint(group.ConfigurationEndpoint),
Nodes: []ElastiCacheNode{},
}
for _, nodeGroup := range group.NodeGroups {
if resource.Endpoint == "" {
Expand Down Expand Up @@ -122,6 +126,7 @@ func mapElastiCacheCluster(cluster elasticachetypes.CacheCluster) ElastiCacheRes
Status: awssdk.ToString(cluster.CacheClusterStatus),
NodeType: awssdk.ToString(cluster.CacheNodeType),
Endpoint: formatElastiCacheEndpoint(cluster.ConfigurationEndpoint),
Nodes: []ElastiCacheNode{},
}
for _, cacheNode := range cluster.CacheNodes {
role := "primary"
Expand Down
31 changes: 16 additions & 15 deletions internal/services/aws/elasticache_model.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,15 @@ import (

// ElastiCacheResource is either a replication group or a standalone cache cluster.
type ElastiCacheResource struct {
ID string
Kind string
Engine string
EngineVersion string
Status string
NodeType string
Endpoint string
Nodes []ElastiCacheNode
ID string `json:"id"`
Kind string `json:"kind"`
Engine string `json:"engine"`
EngineVersion string `json:"engine_version"`
Status string `json:"status"`
NodeType string `json:"node_type"`
Endpoint string `json:"endpoint"`
Region string `json:"region"`
Nodes []ElastiCacheNode `json:"nodes"`
}

// FilterText returns a lowercase string for shared list filtering.
Expand All @@ -25,11 +26,11 @@ func (r ElastiCacheResource) FilterText() string {

// ElastiCacheNode holds node-level connection and placement metadata.
type ElastiCacheNode struct {
ID string
ClusterID string
ShardID string
Role string
Status string
AZ string
Endpoint string
ID string `json:"id"`
ClusterID string `json:"cluster_id"`
ShardID string `json:"shard_id"`
Role string `json:"role"`
Status string `json:"status"`
AZ string `json:"availability_zone"`
Endpoint string `json:"endpoint"`
}
7 changes: 6 additions & 1 deletion internal/services/aws/elasticache_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,7 @@ func TestListElastiCacheResourcesMapsReplicationGroupsAndStandaloneClusters(t *t
}}}, nil
},
}
repo := &AwsRepository{ElastiCacheClient: client}
repo := &AwsRepository{ElastiCacheClient: client, Region: "us-east-1"}

resources, err := repo.ListElastiCacheResources(context.Background())
if err != nil {
Expand All @@ -115,6 +115,11 @@ func TestListElastiCacheResourcesMapsReplicationGroupsAndStandaloneClusters(t *t
if len(resources) != 3 {
t.Fatalf("expected replication group and standalone clusters, got %+v", resources)
}
for _, resource := range resources {
if resource.Region != "us-east-1" || resource.Nodes == nil {
t.Fatalf("expected region and stable node arrays, got %+v", resource)
}
}
standalone, group := resources[0], resources[1]
if standalone.ID != "memcached-dev" || standalone.Kind != "cluster" || standalone.Endpoint != "memcached.cfg.cache.amazonaws.com:11211" {
t.Fatalf("unexpected standalone cluster: %+v", standalone)
Expand Down
Loading