Skip to content

Commit 1d40a71

Browse files
Genry1claude
andauthored
feat: add accountId parameter to CloudWatch tools for cross-account monitoring (#616)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 3c2d065 commit 1d40a71

2 files changed

Lines changed: 150 additions & 23 deletions

File tree

tools/cloudwatch.go

Lines changed: 42 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ type CloudWatchQueryParams struct {
3636
Start string `json:"start,omitempty" jsonschema:"description=Start time. Formats: 'now-1h'\\, '2026-02-02T19:00:00Z'\\, '1738519200000' (Unix ms). Default: now-1h"`
3737
End string `json:"end,omitempty" jsonschema:"description=End time. Formats: 'now'\\, '2026-02-02T20:00:00Z'\\, '1738522800000' (Unix ms). Default: now"`
3838
Region string `json:"region" jsonschema:"required,description=AWS region (e.g. us-east-1)"`
39+
AccountId string `json:"accountId,omitempty" jsonschema:"description=AWS account ID for cross-account monitoring. Specify an account ID to query metrics from a specific source account\\, or 'all' to query all accounts the monitoring account is permitted to query. Only relevant when using a CloudWatch monitoring account datasource."`
3940
}
4041

4142
// CloudWatchQueryResult represents the result of a CloudWatch query
@@ -143,26 +144,30 @@ func (c *cloudWatchClient) query(ctx context.Context, args CloudWatchQueryParams
143144
}
144145

145146
// Build the query payload
146-
payload := map[string]interface{}{
147-
"queries": []map[string]interface{}{
148-
{
149-
"datasource": map[string]string{
150-
"uid": args.DatasourceUID,
151-
"type": CloudWatchDatasourceType,
152-
},
153-
"refId": "A",
154-
"type": "timeSeriesQuery",
155-
"namespace": args.Namespace,
156-
"metricName": args.MetricName,
157-
"dimensions": dimensions,
158-
"statistic": statistic,
159-
"period": strconv.Itoa(period),
160-
"region": region,
161-
"matchExact": true,
162-
},
147+
query := map[string]interface{}{
148+
"datasource": map[string]string{
149+
"uid": args.DatasourceUID,
150+
"type": CloudWatchDatasourceType,
163151
},
164-
"from": strconv.FormatInt(from.UnixMilli(), 10),
165-
"to": strconv.FormatInt(to.UnixMilli(), 10),
152+
"refId": "A",
153+
"type": "timeSeriesQuery",
154+
"namespace": args.Namespace,
155+
"metricName": args.MetricName,
156+
"dimensions": dimensions,
157+
"statistic": statistic,
158+
"period": strconv.Itoa(period),
159+
"region": region,
160+
"matchExact": true,
161+
}
162+
163+
if args.AccountId != "" {
164+
query["accountId"] = args.AccountId
165+
}
166+
167+
payload := map[string]interface{}{
168+
"queries": []map[string]interface{}{query},
169+
"from": strconv.FormatInt(from.UnixMilli(), 10),
170+
"to": strconv.FormatInt(to.UnixMilli(), 10),
166171
}
167172

168173
payloadBytes, err := json.Marshal(payload)
@@ -375,7 +380,9 @@ Time formats: 'now-1h', '2026-02-02T19:00:00Z', '1738519200000' (Unix ms)
375380
376381
Common namespaces: AWS/EC2, AWS/ECS, AWS/RDS, AWS/Lambda, ECS/ContainerInsights
377382
378-
Example dimensions: ECS: {ClusterName, ServiceName}, EC2: {InstanceId}`,
383+
Example dimensions: ECS: {ClusterName, ServiceName}, EC2: {InstanceId}
384+
385+
Cross-account monitoring: Use accountId to query metrics from a specific source account (e.g. '123456789012') or 'all' to query all linked accounts. Only applicable when using a CloudWatch monitoring account datasource.`,
379386
queryCloudWatch,
380387
mcp.WithTitleAnnotation("Query CloudWatch"),
381388
mcp.WithIdempotentHintAnnotation(true),
@@ -386,6 +393,7 @@ Example dimensions: ECS: {ClusterName, ServiceName}, EC2: {InstanceId}`,
386393
type ListCloudWatchNamespacesParams struct {
387394
DatasourceUID string `json:"datasourceUid" jsonschema:"required,description=The UID of the CloudWatch datasource"`
388395
Region string `json:"region" jsonschema:"required,description=AWS region (e.g. us-east-1)"`
396+
AccountId string `json:"accountId,omitempty" jsonschema:"description=AWS account ID for cross-account monitoring. Specify an account ID to filter namespaces from a specific source account\\, or 'all' for all linked accounts."`
389397
}
390398

391399
// cloudWatchResourceItem represents an item returned by CloudWatch resource APIs
@@ -444,6 +452,9 @@ func listCloudWatchNamespaces(ctx context.Context, args ListCloudWatchNamespaces
444452
if args.Region != "" {
445453
params.Set("region", args.Region)
446454
}
455+
if args.AccountId != "" {
456+
params.Set("accountId", args.AccountId)
457+
}
447458

448459
resourceURL := client.baseURL + "/api/datasources/uid/" + args.DatasourceUID + "/resources/namespaces"
449460
if len(params) > 0 {
@@ -477,7 +488,7 @@ func listCloudWatchNamespaces(ctx context.Context, args ListCloudWatchNamespaces
477488
// ListCloudWatchNamespaces is a tool for listing CloudWatch namespaces
478489
var ListCloudWatchNamespaces = mcpgrafana.MustTool(
479490
"list_cloudwatch_namespaces",
480-
"START HERE for CloudWatch: List available namespaces (AWS/EC2, AWS/ECS, AWS/RDS, etc.). Requires region. NEXT: Use list_cloudwatch_metrics with a namespace.",
491+
"START HERE for CloudWatch: List available namespaces (AWS/EC2, AWS/ECS, AWS/RDS, etc.). Requires region. Supports cross-account monitoring via optional accountId parameter. NEXT: Use list_cloudwatch_metrics with a namespace.",
481492
listCloudWatchNamespaces,
482493
mcp.WithTitleAnnotation("List CloudWatch namespaces"),
483494
mcp.WithIdempotentHintAnnotation(true),
@@ -489,6 +500,7 @@ type ListCloudWatchMetricsParams struct {
489500
DatasourceUID string `json:"datasourceUid" jsonschema:"required,description=The UID of the CloudWatch datasource"`
490501
Namespace string `json:"namespace" jsonschema:"required,description=CloudWatch namespace (e.g. AWS/ECS\\, AWS/EC2)"`
491502
Region string `json:"region" jsonschema:"required,description=AWS region (e.g. us-east-1)"`
503+
AccountId string `json:"accountId,omitempty" jsonschema:"description=AWS account ID for cross-account monitoring. Specify an account ID to filter metrics from a specific source account\\, or 'all' for all linked accounts."`
492504
}
493505

494506
// listCloudWatchMetrics lists available metrics for a CloudWatch namespace
@@ -504,6 +516,9 @@ func listCloudWatchMetrics(ctx context.Context, args ListCloudWatchMetricsParams
504516
if args.Region != "" {
505517
params.Set("region", args.Region)
506518
}
519+
if args.AccountId != "" {
520+
params.Set("accountId", args.AccountId)
521+
}
507522

508523
resourceURL := client.baseURL + "/api/datasources/uid/" + args.DatasourceUID + "/resources/metrics?" + params.Encode()
509524
req, err := http.NewRequestWithContext(ctx, http.MethodGet, resourceURL, nil)
@@ -534,7 +549,7 @@ func listCloudWatchMetrics(ctx context.Context, args ListCloudWatchMetricsParams
534549
// ListCloudWatchMetrics is a tool for listing CloudWatch metrics
535550
var ListCloudWatchMetrics = mcpgrafana.MustTool(
536551
"list_cloudwatch_metrics",
537-
"List metrics for a CloudWatch namespace. Requires region. Use after list_cloudwatch_namespaces. NEXT: Use list_cloudwatch_dimensions\\, then query_cloudwatch.",
552+
"List metrics for a CloudWatch namespace. Requires region. Supports cross-account monitoring via optional accountId parameter. Use after list_cloudwatch_namespaces. NEXT: Use list_cloudwatch_dimensions\\, then query_cloudwatch.",
538553
listCloudWatchMetrics,
539554
mcp.WithTitleAnnotation("List CloudWatch metrics"),
540555
mcp.WithIdempotentHintAnnotation(true),
@@ -547,6 +562,7 @@ type ListCloudWatchDimensionsParams struct {
547562
Namespace string `json:"namespace" jsonschema:"required,description=CloudWatch namespace (e.g. AWS/ECS)"`
548563
MetricName string `json:"metricName" jsonschema:"required,description=Metric name (e.g. CPUUtilization)"`
549564
Region string `json:"region" jsonschema:"required,description=AWS region (e.g. us-east-1)"`
565+
AccountId string `json:"accountId,omitempty" jsonschema:"description=AWS account ID for cross-account monitoring. Specify an account ID to filter dimensions from a specific source account\\, or 'all' for all linked accounts."`
550566
}
551567

552568
// listCloudWatchDimensions lists available dimension keys for a CloudWatch metric
@@ -563,6 +579,9 @@ func listCloudWatchDimensions(ctx context.Context, args ListCloudWatchDimensions
563579
if args.Region != "" {
564580
params.Set("region", args.Region)
565581
}
582+
if args.AccountId != "" {
583+
params.Set("accountId", args.AccountId)
584+
}
566585

567586
resourceURL := client.baseURL + "/api/datasources/uid/" + args.DatasourceUID + "/resources/dimension-keys?" + params.Encode()
568587
req, err := http.NewRequestWithContext(ctx, http.MethodGet, resourceURL, nil)
@@ -593,7 +612,7 @@ func listCloudWatchDimensions(ctx context.Context, args ListCloudWatchDimensions
593612
// ListCloudWatchDimensions is a tool for listing CloudWatch dimension keys
594613
var ListCloudWatchDimensions = mcpgrafana.MustTool(
595614
"list_cloudwatch_dimensions",
596-
"List dimension keys for a CloudWatch metric. Requires region. Use after list_cloudwatch_metrics. NEXT: Use query_cloudwatch with discovered dimensions.",
615+
"List dimension keys for a CloudWatch metric. Requires region. Supports cross-account monitoring via optional accountId parameter. Use after list_cloudwatch_metrics. NEXT: Use query_cloudwatch with discovered dimensions.",
597616
listCloudWatchDimensions,
598617
mcp.WithTitleAnnotation("List CloudWatch dimensions"),
599618
mcp.WithIdempotentHintAnnotation(true),

tools/cloudwatch_test.go

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
package tools
44

55
import (
6+
"encoding/json"
67
"net/url"
78
"testing"
89

@@ -25,6 +26,7 @@ func TestCloudWatchQueryParams_Validation(t *testing.T) {
2526
Start: "now-1h",
2627
End: "now",
2728
Region: "us-east-1",
29+
AccountId: "123456789012",
2830
}
2931

3032
assert.Equal(t, "test-uid", params.DatasourceUID)
@@ -37,6 +39,32 @@ func TestCloudWatchQueryParams_Validation(t *testing.T) {
3739
assert.Equal(t, "now-1h", params.Start)
3840
assert.Equal(t, "now", params.End)
3941
assert.Equal(t, "us-east-1", params.Region)
42+
assert.Equal(t, "123456789012", params.AccountId)
43+
}
44+
45+
func TestCloudWatchQueryParams_AccountIdAll(t *testing.T) {
46+
// Test that AccountId supports the "all" wildcard value
47+
params := CloudWatchQueryParams{
48+
DatasourceUID: "test-uid",
49+
Namespace: "AWS/EC2",
50+
MetricName: "CPUUtilization",
51+
Region: "us-east-1",
52+
AccountId: "all",
53+
}
54+
55+
assert.Equal(t, "all", params.AccountId)
56+
}
57+
58+
func TestCloudWatchQueryParams_AccountIdEmpty(t *testing.T) {
59+
// Test that AccountId is optional and defaults to empty
60+
params := CloudWatchQueryParams{
61+
DatasourceUID: "test-uid",
62+
Namespace: "AWS/EC2",
63+
MetricName: "CPUUtilization",
64+
Region: "us-east-1",
65+
}
66+
67+
assert.Empty(t, params.AccountId)
4068
}
4169

4270
func TestCloudWatchQueryResult_Structure(t *testing.T) {
@@ -72,22 +100,26 @@ func TestListCloudWatchNamespacesParams_Structure(t *testing.T) {
72100
params := ListCloudWatchNamespacesParams{
73101
DatasourceUID: "test-uid",
74102
Region: "us-west-2",
103+
AccountId: "123456789012",
75104
}
76105

77106
assert.Equal(t, "test-uid", params.DatasourceUID)
78107
assert.Equal(t, "us-west-2", params.Region)
108+
assert.Equal(t, "123456789012", params.AccountId)
79109
}
80110

81111
func TestListCloudWatchMetricsParams_Structure(t *testing.T) {
82112
params := ListCloudWatchMetricsParams{
83113
DatasourceUID: "test-uid",
84114
Namespace: "AWS/EC2",
85115
Region: "eu-west-1",
116+
AccountId: "all",
86117
}
87118

88119
assert.Equal(t, "test-uid", params.DatasourceUID)
89120
assert.Equal(t, "AWS/EC2", params.Namespace)
90121
assert.Equal(t, "eu-west-1", params.Region)
122+
assert.Equal(t, "all", params.AccountId)
91123
}
92124

93125
func TestListCloudWatchDimensionsParams_Structure(t *testing.T) {
@@ -96,12 +128,14 @@ func TestListCloudWatchDimensionsParams_Structure(t *testing.T) {
96128
Namespace: "AWS/RDS",
97129
MetricName: "DatabaseConnections",
98130
Region: "ap-southeast-1",
131+
AccountId: "987654321098",
99132
}
100133

101134
assert.Equal(t, "test-uid", params.DatasourceUID)
102135
assert.Equal(t, "AWS/RDS", params.Namespace)
103136
assert.Equal(t, "DatabaseConnections", params.MetricName)
104137
assert.Equal(t, "ap-southeast-1", params.Region)
138+
assert.Equal(t, "987654321098", params.AccountId)
105139
}
106140

107141
func TestCloudWatchQueryResult_Hints(t *testing.T) {
@@ -471,6 +505,80 @@ func TestCloudWatchURLEncoding(t *testing.T) {
471505
}
472506
}
473507

508+
func TestCloudWatchQueryParams_JSONSerialization(t *testing.T) {
509+
t.Run("accountId included when set", func(t *testing.T) {
510+
params := CloudWatchQueryParams{
511+
DatasourceUID: "test-uid",
512+
Namespace: "AWS/EC2",
513+
MetricName: "CPUUtilization",
514+
Region: "us-east-1",
515+
AccountId: "123456789012",
516+
}
517+
518+
data, err := json.Marshal(params)
519+
require.NoError(t, err)
520+
521+
var raw map[string]interface{}
522+
err = json.Unmarshal(data, &raw)
523+
require.NoError(t, err)
524+
525+
assert.Equal(t, "123456789012", raw["accountId"])
526+
})
527+
528+
t.Run("accountId omitted when empty", func(t *testing.T) {
529+
params := CloudWatchQueryParams{
530+
DatasourceUID: "test-uid",
531+
Namespace: "AWS/EC2",
532+
MetricName: "CPUUtilization",
533+
Region: "us-east-1",
534+
}
535+
536+
data, err := json.Marshal(params)
537+
require.NoError(t, err)
538+
539+
var raw map[string]interface{}
540+
err = json.Unmarshal(data, &raw)
541+
require.NoError(t, err)
542+
543+
_, exists := raw["accountId"]
544+
assert.False(t, exists, "accountId should be omitted from JSON when empty")
545+
})
546+
}
547+
548+
func TestCloudWatchAccountIdURLEncoding(t *testing.T) {
549+
tests := []struct {
550+
name string
551+
accountId string
552+
wantParam string
553+
}{
554+
{
555+
name: "specific account ID",
556+
accountId: "123456789012",
557+
wantParam: "123456789012",
558+
},
559+
{
560+
name: "all accounts wildcard",
561+
accountId: "all",
562+
wantParam: "all",
563+
},
564+
}
565+
566+
for _, tt := range tests {
567+
t.Run(tt.name, func(t *testing.T) {
568+
params := url.Values{}
569+
params.Set("region", "us-east-1")
570+
if tt.accountId != "" {
571+
params.Set("accountId", tt.accountId)
572+
}
573+
574+
encoded := params.Encode()
575+
parsed, err := url.ParseQuery(encoded)
576+
require.NoError(t, err)
577+
assert.Equal(t, tt.wantParam, parsed.Get("accountId"))
578+
})
579+
}
580+
}
581+
474582
func TestGenerateCloudWatchEmptyResultHints(t *testing.T) {
475583
hints := generateCloudWatchEmptyResultHints()
476584

0 commit comments

Comments
 (0)