From 16ca7244123afa1078f6de2e4d9a6767930871ff Mon Sep 17 00:00:00 2001 From: YoungJin Date: Tue, 15 Sep 2026 10:58:32 +0900 Subject: [PATCH 1/2] feat: expose ElastiCache resources to automation - add a read-only JSON resource command and MCP tool - preserve joined replication-group and node details in a stable contract - document permissions and cover CLI, MCP, and repository behavior --- README.md | 3 +- docs/development.md | 1 + internal/cli/resources.go | 2 +- internal/cli/resources_operations.go | 22 +++++++++ internal/cli/resources_operations_test.go | 57 ++++++++++++++++++++++ internal/mcp/agent_surface_test.go | 2 +- internal/mcp/server.go | 13 ++++- internal/mcp/server_test.go | 1 + internal/services/aws/elasticache.go | 5 ++ internal/services/aws/elasticache_model.go | 31 ++++++------ internal/services/aws/elasticache_test.go | 7 ++- 11 files changed, 123 insertions(+), 21 deletions(-) diff --git a/README.md b/README.md index 67b9efe0..1f54b792 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` +- `elasticache-resources` - `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`, `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 diff --git a/docs/development.md b/docs/development.md index 97651782..09327637 100644 --- a/docs/development.md +++ b/docs/development.md @@ -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. diff --git a/internal/cli/resources.go b/internal/cli/resources.go index cf2f4f9b..0c93a02e 100644 --- a/internal/cli/resources.go +++ b/internal/cli/resources.go @@ -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 } diff --git a/internal/cli/resources_operations.go b/internal/cli/resources_operations.go index 4444dfbd..152e6cc6 100644 --- a/internal/cli/resources_operations.go +++ b/internal/cli/resources_operations.go @@ -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 { @@ -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) diff --git a/internal/cli/resources_operations_test.go b/internal/cli/resources_operations_test.go index ac319b8b..f9c5a025 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" @@ -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"}) diff --git a/internal/mcp/agent_surface_test.go b/internal/mcp/agent_surface_test.go index 7d9d24c1..a13b9587 100644 --- a/internal/mcp/agent_surface_test.go +++ b/internal/mcp/agent_surface_test.go @@ -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"}, } @@ -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", diff --git a/internal/mcp/server.go b/internal/mcp/server.go index b43d9ad6..ce82d119 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_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), @@ -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_elasticache_resources", "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_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 { diff --git a/internal/mcp/server_test.go b/internal/mcp/server_test.go index e139f006..2586d67f 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_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"}}, diff --git a/internal/services/aws/elasticache.go b/internal/services/aws/elasticache.go index 30a0c4cc..86f65c5b 100644 --- a/internal/services/aws/elasticache.go +++ b/internal/services/aws/elasticache.go @@ -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) @@ -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 == "" { @@ -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" diff --git a/internal/services/aws/elasticache_model.go b/internal/services/aws/elasticache_model.go index 0e7f5d49..3bd38576 100644 --- a/internal/services/aws/elasticache_model.go +++ b/internal/services/aws/elasticache_model.go @@ -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. @@ -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"` } diff --git a/internal/services/aws/elasticache_test.go b/internal/services/aws/elasticache_test.go index 4485f66f..1de9d4ac 100644 --- a/internal/services/aws/elasticache_test.go +++ b/internal/services/aws/elasticache_test.go @@ -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 { @@ -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) From a0b241b09f8ed3f2a15e0e912ae3407c88ecdcca Mon Sep 17 00:00:00 2001 From: YoungJin Date: Tue, 15 Sep 2026 13:10:34 +0900 Subject: [PATCH 2/2] docs: clarify MCP tool inventory - Replace the stale exhaustive tool list with durable wording.\n- Call out the ElastiCache MCP resource operation explicitly. --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 1f54b792..43996da7 100644 --- a/README.md +++ b/README.md @@ -250,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.`