Skip to content

Commit e166617

Browse files
authored
Merge pull request #57 from kiranmam/feat/mcp-dynamic-apigateway
feat: API Gateway transport + dynamic multi-cluster MCP
2 parents effbe1a + 0579fe7 commit e166617

5 files changed

Lines changed: 245 additions & 72 deletions

File tree

mcp/rds-aidba/README.md

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,15 +27,31 @@ Custom MCP server for AWS DevOps Agent providing safe, query-allowlisted diagnos
2727
- No VPC required (RDS Data API)
2828
- Function URL with AWS_IAM auth
2929

30+
## ⚠️ Account-level API Gateway setting
31+
32+
This stack creates an `AWS::ApiGateway::Account` resource to set the CloudWatch
33+
Logs role API Gateway uses for access logging/metrics. **This is an
34+
account-wide, region-wide setting** — it applies to every API Gateway REST API
35+
in the account/region, not just this stack.
36+
37+
- If your account already has this role configured, pass its ARN via the
38+
`ExistingApiGatewayCloudWatchRoleArn` parameter so the stack reuses it instead
39+
of creating a new one.
40+
- Deleting this stack can reset the account's CloudWatch role, which may affect
41+
access logging for unrelated APIs.
42+
43+
Deploy into a dedicated/sandbox account, or coordinate with your account owner,
44+
before deploying into a shared account.
45+
3046
## Deploy
3147

3248
sam build
3349
sam deploy --stack-name rds-aidba-mcp --capabilities CAPABILITY_NAMED_IAM --resolve-s3 --no-confirm-changeset
3450

3551
## Register in DevOps Agent
3652

37-
- URL: Function URL from stack output (use as-is, already includes /mcp)
38-
- Service Name: lambda
53+
- URL: McpEndpointUrl from stack output (already includes /Prod/mcp)
54+
- Service Name: execute-api
3955
- Auth: IAM (SigV4)
4056

4157
## Disclaimer
Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1-
mcp-proxy>=0.5.0
2-
fastmcp>=2.0.0,<4.0.0
1+
mcp==1.29.0
2+
mcp-proxy==0.12.0
3+
fastmcp==3.4.7
34
boto3>=1.34.0

mcp/rds-aidba/src/run.sh

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
1-
#!/bin/bash
2-
export PYTHONPATH="/var/task:${PYTHONPATH}"
3-
cd /var/task
4-
exec python3 -m mcp_proxy --port=8000 --stateless --pass-environment -- \
5-
python3 server.py
1+
#!/bin/sh
2+
set -e
3+
export PYTHONPATH="/opt/python:/var/task"
4+
exec python3 -m mcp_proxy --port=8000 --host=0.0.0.0 --stateless --pass-environment -- python3 server.py

mcp/rds-aidba/src/server.py

Lines changed: 136 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,13 @@
55
access to Aurora MySQL and Aurora PostgreSQL clusters (RDS Data API required).
66
Includes CloudWatch metrics, Performance Insights, RDS Proxy, and Serverless v2.
77
8+
Dynamic multi-cluster: data-plane tools take a cluster_identifier and auto-discover
9+
the cluster ARN, engine, and credentials (MasterUserSecret). No per-cluster config.
10+
811
Engines: Aurora MySQL, Aurora PostgreSQL (Data API enabled clusters only)
912
Queries: 54 predefined (24 MySQL + 30 PostgreSQL) across 10 categories
1013
Data Sources: CloudWatch, Performance Insights, RDS Data API
11-
Transport: Streamable HTTP (Lambda Web Adapter + FastMCP)
14+
Transport: Streamable HTTP (Lambda Web Adapter + FastMCP) behind API Gateway
1215
1316
Safety:
1417
+ Query allowlist only — no dynamic SQL
@@ -34,9 +37,11 @@
3437
# CONFIGURATION
3538
# =============================================================================
3639

40+
# CLUSTER_ARN / SECRET_ARN are legacy/optional — data-plane tools now resolve the
41+
# cluster dynamically from the cluster_identifier passed to each tool.
3742
CLUSTER_ARN = os.environ.get("CLUSTER_ARN", "")
3843
SECRET_ARN = os.environ.get("SECRET_ARN", "")
39-
DATABASE = os.environ.get("DATABASE_NAME", "information_schema")
44+
DEFAULT_DATABASE = os.environ.get("DATABASE_NAME", "") # optional default DB override
4045
REGION = os.environ.get("AWS_REGION_NAME", os.environ.get("AWS_REGION", "us-east-1"))
4146
STAGE = os.environ.get("STAGE_NAME", "dev")
4247

@@ -104,22 +109,94 @@ def validate_proxy(proxy_name: str) -> tuple:
104109
return False, f"ERROR: Proxy '{proxy_name}' not in allowed list."
105110

106111

112+
# =============================================================================
113+
# CLUSTER RESOLUTION (dynamic — auto-discovers engine + credentials)
114+
# =============================================================================
115+
116+
117+
def _engine_family(engine: str) -> str:
118+
"""Map an RDS engine string to a query family."""
119+
if engine.startswith("aurora-mysql"):
120+
return "mysql"
121+
if engine.startswith("aurora-postgresql"):
122+
return "postgresql"
123+
return ""
124+
125+
126+
def _default_database(family: str, override: str = None) -> str:
127+
"""Pick the default database per engine unless overridden."""
128+
if override:
129+
return override
130+
if DEFAULT_DATABASE:
131+
return DEFAULT_DATABASE
132+
return "information_schema" if family == "mysql" else "postgres"
133+
134+
135+
def _resolve_cluster(cluster_identifier: str, secret_arn_override: str = None) -> dict:
136+
"""
137+
Resolve a cluster identifier to its ARN, engine family, and credentials.
138+
Auto-discovers the Secrets Manager ARN from the cluster's MasterUserSecret
139+
(AWS-managed master credentials). No hardcoding or per-cluster config needed.
140+
"""
141+
ok, msg = validate_cluster(cluster_identifier)
142+
if not ok:
143+
return {"ok": False, "error": msg}
144+
try:
145+
c = rds_client.describe_db_clusters(
146+
DBClusterIdentifier=cluster_identifier
147+
)["DBClusters"][0]
148+
except Exception as e:
149+
return {"ok": False, "error": f"ERROR: Cannot describe cluster '{cluster_identifier}': {e}"}
150+
151+
engine = c.get("Engine", "")
152+
family = _engine_family(engine)
153+
if not family:
154+
return {"ok": False, "error": (
155+
f"ERROR: '{cluster_identifier}' engine '{engine}' is not Aurora MySQL or "
156+
"Aurora PostgreSQL. Only Aurora clusters with the RDS Data API are supported."
157+
)}
158+
159+
if not c.get("HttpEndpointEnabled", False):
160+
return {"ok": False, "error": (
161+
f"ERROR: RDS Data API is not enabled on '{cluster_identifier}'. Enable it with:\n"
162+
f"aws rds modify-db-cluster --db-cluster-identifier {cluster_identifier} "
163+
"--enable-http-endpoint"
164+
)}
165+
166+
secret_arn = secret_arn_override or c.get("MasterUserSecret", {}).get("SecretArn")
167+
if not secret_arn:
168+
return {"ok": False, "error": (
169+
f"ERROR: No discoverable credentials for '{cluster_identifier}'. The cluster has no "
170+
"AWS-managed MasterUserSecret. Either enable managed master credentials, or pass a "
171+
"secret_arn override (note: a customer-managed secret ARN must also be permitted by "
172+
"the Lambda role's secretsmanager policy)."
173+
)}
174+
175+
return {
176+
"ok": True,
177+
"cluster_arn": c["DBClusterArn"],
178+
"secret_arn": secret_arn,
179+
"engine": engine,
180+
"family": family,
181+
}
182+
183+
107184
# =============================================================================
108185
# RDS DATA API EXECUTION
109186
# =============================================================================
110187

111188

112-
def _execute_sql(sql: str, database: str = None) -> dict:
113-
"""Execute read-only SQL via RDS Data API."""
114-
db = database or DATABASE
115-
ok, msg = validate_database(db)
189+
def _execute_sql(sql: str, cluster_arn: str, secret_arn: str, database: str) -> dict:
190+
"""Execute read-only SQL via RDS Data API against a resolved cluster."""
191+
ok, msg = validate_database(database)
116192
if not ok:
117193
return {"success": False, "error": msg, "columns": [], "rows": [], "rowCount": 0}
118194
try:
119195
response = rds_data.execute_statement(
120-
resourceArn=CLUSTER_ARN, secretArn=SECRET_ARN, database=db, sql=sql,
196+
resourceArn=cluster_arn, secretArn=secret_arn, database=database, sql=sql, includeResultMetadata=True,
121197
)
122-
columns = [col["name"] for col in response.get("columnMetadata", [])]
198+
columns = [col.get("label") or col.get("name") or f"col_{i}"
199+
for i, col in enumerate(response.get("columnMetadata", []))]
123200
rows = []
124201
for record in response.get("records", []):
125202
row = {}
@@ -289,37 +366,49 @@ def _format_table(result: dict) -> str:
289366
"and Aurora PostgreSQL. Provides 54 predefined health check "
290367
"queries (24 MySQL + 30 PostgreSQL) across 10 categories, plus CloudWatch "
291368
"metrics, Performance Insights, RDS Proxy health, and Serverless v2 capacity. "
369+
"Data-plane tools take a cluster_identifier and auto-detect the engine and "
370+
"credentials — any allowlisted Aurora cluster, no per-cluster config. "
292371
"Only allowlisted queries — no arbitrary SQL."
293372
),
294373
)
295374

296375

297376
@mcp.tool()
298-
def execute_health_query(engine: str, category: str, query_id: str) -> str:
377+
def execute_health_query(cluster_identifier: str, category: str, query_id: str,
378+
database: str = None, secret_arn: str = None) -> str:
299379
"""
300-
Run a predefined health check query by engine, category, and query ID.
380+
Run a predefined health check query against an Aurora cluster.
381+
Engine and credentials are auto-detected from the cluster — no config needed.
301382
302383
Args:
303-
engine: "mysql" or "postgresql"
304-
category: Category number (1-10)
305-
query_id: Query ID (e.g., "3.1", "6.2")
384+
cluster_identifier: Aurora cluster identifier (engine auto-detected).
385+
category: Category number (1-10).
386+
query_id: Query ID (e.g., "3.1", "6.2").
387+
database: Optional database override (defaults per engine).
388+
secret_arn: Optional Secrets Manager ARN override (defaults to the
389+
cluster's AWS-managed MasterUserSecret).
306390
"""
307-
queries = MYSQL_QUERIES if engine == "mysql" else PG_QUERIES
391+
r = _resolve_cluster(cluster_identifier, secret_arn)
392+
if not r["ok"]:
393+
return r["error"]
394+
queries = MYSQL_QUERIES if r["family"] == "mysql" else PG_QUERIES
308395
if category not in queries:
309-
return f"ERROR: Unknown category '{category}' for {engine}. Available: {', '.join(sorted(queries.keys()))}"
396+
return f"ERROR: Unknown category '{category}' for {r['family']}. Available: {', '.join(sorted(queries.keys()))}"
310397
cat = queries[category]
311398
if query_id not in cat:
312399
available = [k for k in cat if not k.startswith("_")]
313400
return f"ERROR: Unknown query_id '{query_id}'. Available: {', '.join(available)}"
314401
query = cat[query_id]
315-
result = _execute_sql(query["sql"])
316-
return f"## {query_id}: {query['name']}\n**Category {category}: {cat['_category']}** | Engine: {engine}\n\n{_format_table(result)}"
402+
db = _default_database(r["family"], database)
403+
result = _execute_sql(query["sql"], r["cluster_arn"], r["secret_arn"], db)
404+
return (f"## {query_id}: {query['name']}\n**Category {category}: {cat['_category']}** | "
405+
f"Engine: {r['engine']} | Cluster: {cluster_identifier}\n\n{_format_table(result)}")
317406

318407

319408
@mcp.tool()
320409
def list_health_queries(engine: str = "mysql") -> str:
321410
"""
322-
List all available health check queries for an engine.
411+
List all available health check queries for an engine (static reference — no DB access).
323412
324413
Args:
325414
engine: "mysql" (24 queries) or "postgresql" (30 queries)
@@ -338,48 +427,61 @@ def list_health_queries(engine: str = "mysql") -> str:
338427

339428

340429
@mcp.tool()
341-
def run_category_check(engine: str, category: str) -> str:
430+
def run_category_check(cluster_identifier: str, category: str,
431+
database: str = None, secret_arn: str = None) -> str:
342432
"""
343-
Run all health checks in a category.
433+
Run all health checks in a category against an Aurora cluster (engine auto-detected).
344434
345435
Args:
346-
engine: "mysql" or "postgresql"
347-
category: Category number (1-10)
436+
cluster_identifier: Aurora cluster identifier.
437+
category: Category number (1-10).
438+
database: Optional database override.
439+
secret_arn: Optional secret ARN override.
348440
"""
349-
queries = MYSQL_QUERIES if engine == "mysql" else PG_QUERIES
441+
r = _resolve_cluster(cluster_identifier, secret_arn)
442+
if not r["ok"]:
443+
return r["error"]
444+
queries = MYSQL_QUERIES if r["family"] == "mysql" else PG_QUERIES
350445
if category not in queries:
351446
return f"ERROR: Unknown category '{category}'."
352447
cat = queries[category]
353-
output = f"# Category {category}: {cat['_category']} ({engine})\n\n"
448+
db = _default_database(r["family"], database)
449+
output = f"# Category {category}: {cat['_category']} ({r['engine']}) | Cluster: {cluster_identifier}\n\n"
354450
for qid, qdef in cat.items():
355451
if qid.startswith("_"):
356452
continue
357-
result = _execute_sql(qdef["sql"])
453+
result = _execute_sql(qdef["sql"], r["cluster_arn"], r["secret_arn"], db)
358454
output += f"## {qid}: {qdef['name']}\n{_format_table(result)}\n\n"
359455
return output
360456

361457

362458
@mcp.tool()
363-
def run_full_health_check(engine: str = "mysql") -> str:
459+
def run_full_health_check(cluster_identifier: str,
460+
database: str = None, secret_arn: str = None) -> str:
364461
"""
365-
Run key queries from all categories for a comprehensive assessment.
462+
Run key queries from all categories against an Aurora cluster (engine auto-detected).
366463
367464
Args:
368-
engine: "mysql" or "postgresql"
465+
cluster_identifier: Aurora cluster identifier.
466+
database: Optional database override.
467+
secret_arn: Optional secret ARN override.
369468
"""
370-
if engine == "mysql":
469+
r = _resolve_cluster(cluster_identifier, secret_arn)
470+
if not r["ok"]:
471+
return r["error"]
472+
if r["family"] == "mysql":
371473
key_queries = ["1.1", "2.2", "3.1", "5.3", "6.1", "7.1", "8.1", "9.1", "10.4"]
372474
else:
373475
key_queries = ["1.1", "2.1", "3.1", "5.2", "6.1", "7.2", "8.1", "9.1", "10.2"]
374-
queries = MYSQL_QUERIES if engine == "mysql" else PG_QUERIES
375-
output = f"# Full Health Check ({engine})\n\n"
476+
queries = MYSQL_QUERIES if r["family"] == "mysql" else PG_QUERIES
477+
db = _default_database(r["family"], database)
478+
output = f"# Full Health Check ({r['engine']}) | Cluster: {cluster_identifier}\n\n"
376479
for qid in key_queries:
377480
cat_num = qid.split(".")[0]
378-
cat = queries.get(cat_num, {})
379-
qdef = cat.get(qid)
481+
qdef = queries.get(cat_num, {}).get(qid)
380482
if not qdef:
381483
continue
382-
result = _execute_sql(qdef["sql"])
484+
result = _execute_sql(qdef["sql"], r["cluster_arn"], r["secret_arn"], db)
383485
output += f"## {qid}: {qdef['name']}\n{_format_table(result)}\n\n"
384486
return output
385487

@@ -455,7 +557,6 @@ def get_cluster_metrics(cluster_identifier: str, hours_back: int = 3) -> str:
455557
("FreeableMemory", "bytes"), ("ReadIOPS", "count/sec"),
456558
("WriteIOPS", "count/sec"), ("AuroraReplicaLag", "ms"),
457559
]
458-
# Get cluster members to query instance-level metrics
459560
try:
460561
cluster_resp = rds_client.describe_db_clusters(DBClusterIdentifier=cluster_identifier)
461562
members = cluster_resp["DBClusters"][0].get("DBClusterMembers", [])
@@ -502,7 +603,6 @@ def get_performance_insights(instance_identifier: str) -> str:
502603
return msg
503604
try:
504605
resource_id = f"db-{instance_identifier}"
505-
# Try to get the actual DbiResourceId
506606
try:
507607
inst = rds_client.describe_db_instances(DBInstanceIdentifier=instance_identifier)
508608
resource_id = inst["DBInstances"][0]["DbiResourceId"]
@@ -567,7 +667,6 @@ def get_proxy_health(proxy_name: str) -> str:
567667
| Auth | {proxy.get('Auth', [{}])[0].get('AuthScheme', 'N/A')} |
568668
| Idle Timeout | {proxy.get('IdleClientTimeout')} sec |
569669
"""
570-
# Get targets
571670
try:
572671
targets = rds_client.describe_db_proxy_targets(DBProxyName=proxy_name)
573672
output += "\n### Targets\n| Target | Type | State | Health |\n| --- | --- | --- | --- |\n"

0 commit comments

Comments
 (0)