From de1e630019d75f122ed96300837c91e2f17668f3 Mon Sep 17 00:00:00 2001 From: Kiran Mayee Mulupuru Date: Fri, 14 Aug 2026 17:35:46 -0500 Subject: [PATCH 1/3] feat: API Gateway transport + dynamic multi-cluster support + column metadata fix --- .../layers/dependencies/requirements.txt | 5 +- mcp/rds-aidba/src/run.sh | 9 +- mcp/rds-aidba/src/server.py | 173 ++++++++++++++---- mcp/rds-aidba/template.yaml | 97 +++++++--- 4 files changed, 213 insertions(+), 71 deletions(-) diff --git a/mcp/rds-aidba/layers/dependencies/requirements.txt b/mcp/rds-aidba/layers/dependencies/requirements.txt index 0c7e9a9..8a055f4 100644 --- a/mcp/rds-aidba/layers/dependencies/requirements.txt +++ b/mcp/rds-aidba/layers/dependencies/requirements.txt @@ -1,3 +1,4 @@ -mcp-proxy>=0.5.0 -fastmcp>=2.0.0,<4.0.0 +mcp==1.29.0 +mcp-proxy==0.12.0 +fastmcp==3.4.7 boto3>=1.34.0 diff --git a/mcp/rds-aidba/src/run.sh b/mcp/rds-aidba/src/run.sh index 9beef77..0a4d976 100755 --- a/mcp/rds-aidba/src/run.sh +++ b/mcp/rds-aidba/src/run.sh @@ -1,5 +1,4 @@ -#!/bin/bash -export PYTHONPATH="/var/task:${PYTHONPATH}" -cd /var/task -exec python3 -m mcp_proxy --port=8000 --stateless --pass-environment -- \ - python3 server.py +#!/bin/sh +set -e +export PYTHONPATH="/opt/python:/var/task" +exec python3 -m mcp_proxy --port=8000 --host=0.0.0.0 --stateless --pass-environment -- python3 server.py diff --git a/mcp/rds-aidba/src/server.py b/mcp/rds-aidba/src/server.py index 29c0324..c5fcc16 100644 --- a/mcp/rds-aidba/src/server.py +++ b/mcp/rds-aidba/src/server.py @@ -5,10 +5,13 @@ access to Aurora MySQL and Aurora PostgreSQL clusters (RDS Data API required). Includes CloudWatch metrics, Performance Insights, RDS Proxy, and Serverless v2. +Dynamic multi-cluster: data-plane tools take a cluster_identifier and auto-discover +the cluster ARN, engine, and credentials (MasterUserSecret). No per-cluster config. + Engines: Aurora MySQL, Aurora PostgreSQL (Data API enabled clusters only) Queries: 54 predefined (24 MySQL + 30 PostgreSQL) across 10 categories Data Sources: CloudWatch, Performance Insights, RDS Data API -Transport: Streamable HTTP (Lambda Web Adapter + FastMCP) +Transport: Streamable HTTP (Lambda Web Adapter + FastMCP) behind API Gateway Safety: + Query allowlist only — no dynamic SQL @@ -34,9 +37,11 @@ # CONFIGURATION # ============================================================================= +# CLUSTER_ARN / SECRET_ARN are legacy/optional — data-plane tools now resolve the +# cluster dynamically from the cluster_identifier passed to each tool. CLUSTER_ARN = os.environ.get("CLUSTER_ARN", "") SECRET_ARN = os.environ.get("SECRET_ARN", "") -DATABASE = os.environ.get("DATABASE_NAME", "information_schema") +DEFAULT_DATABASE = os.environ.get("DATABASE_NAME", "") # optional default DB override REGION = os.environ.get("AWS_REGION_NAME", os.environ.get("AWS_REGION", "us-east-1")) STAGE = os.environ.get("STAGE_NAME", "dev") @@ -104,22 +109,94 @@ def validate_proxy(proxy_name: str) -> tuple: return False, f"ERROR: Proxy '{proxy_name}' not in allowed list." +# ============================================================================= +# CLUSTER RESOLUTION (dynamic — auto-discovers engine + credentials) +# ============================================================================= + + +def _engine_family(engine: str) -> str: + """Map an RDS engine string to a query family.""" + if engine.startswith("aurora-mysql"): + return "mysql" + if engine.startswith("aurora-postgresql"): + return "postgresql" + return "" + + +def _default_database(family: str, override: str = None) -> str: + """Pick the default database per engine unless overridden.""" + if override: + return override + if DEFAULT_DATABASE: + return DEFAULT_DATABASE + return "information_schema" if family == "mysql" else "postgres" + + +def _resolve_cluster(cluster_identifier: str, secret_arn_override: str = None) -> dict: + """ + Resolve a cluster identifier to its ARN, engine family, and credentials. + Auto-discovers the Secrets Manager ARN from the cluster's MasterUserSecret + (AWS-managed master credentials). No hardcoding or per-cluster config needed. + """ + ok, msg = validate_cluster(cluster_identifier) + if not ok: + return {"ok": False, "error": msg} + try: + c = rds_client.describe_db_clusters( + DBClusterIdentifier=cluster_identifier + )["DBClusters"][0] + except Exception as e: + return {"ok": False, "error": f"ERROR: Cannot describe cluster '{cluster_identifier}': {e}"} + + engine = c.get("Engine", "") + family = _engine_family(engine) + if not family: + return {"ok": False, "error": ( + f"ERROR: '{cluster_identifier}' engine '{engine}' is not Aurora MySQL or " + "Aurora PostgreSQL. Only Aurora clusters with the RDS Data API are supported." + )} + + if not c.get("HttpEndpointEnabled", False): + return {"ok": False, "error": ( + f"ERROR: RDS Data API is not enabled on '{cluster_identifier}'. Enable it with:\n" + f"aws rds modify-db-cluster --db-cluster-identifier {cluster_identifier} " + "--enable-http-endpoint" + )} + + secret_arn = secret_arn_override or c.get("MasterUserSecret", {}).get("SecretArn") + if not secret_arn: + return {"ok": False, "error": ( + f"ERROR: No discoverable credentials for '{cluster_identifier}'. The cluster has no " + "AWS-managed MasterUserSecret. Either enable managed master credentials, or pass a " + "secret_arn override (note: a customer-managed secret ARN must also be permitted by " + "the Lambda role's secretsmanager policy)." + )} + + return { + "ok": True, + "cluster_arn": c["DBClusterArn"], + "secret_arn": secret_arn, + "engine": engine, + "family": family, + } + + # ============================================================================= # RDS DATA API EXECUTION # ============================================================================= -def _execute_sql(sql: str, database: str = None) -> dict: - """Execute read-only SQL via RDS Data API.""" - db = database or DATABASE - ok, msg = validate_database(db) +def _execute_sql(sql: str, cluster_arn: str, secret_arn: str, database: str) -> dict: + """Execute read-only SQL via RDS Data API against a resolved cluster.""" + ok, msg = validate_database(database) if not ok: return {"success": False, "error": msg, "columns": [], "rows": [], "rowCount": 0} try: response = rds_data.execute_statement( - resourceArn=CLUSTER_ARN, secretArn=SECRET_ARN, database=db, sql=sql, + resourceArn=cluster_arn, secretArn=secret_arn, database=database, sql=sql,includeResultMetadata=True, ) - columns = [col["name"] for col in response.get("columnMetadata", [])] + columns = [col.get("label") or col.get("name") or f"col_{i}" + for i, col in enumerate(response.get("columnMetadata", []))] rows = [] for record in response.get("records", []): row = {} @@ -289,37 +366,49 @@ def _format_table(result: dict) -> str: "and Aurora PostgreSQL. Provides 54 predefined health check " "queries (24 MySQL + 30 PostgreSQL) across 10 categories, plus CloudWatch " "metrics, Performance Insights, RDS Proxy health, and Serverless v2 capacity. " + "Data-plane tools take a cluster_identifier and auto-detect the engine and " + "credentials — any allowlisted Aurora cluster, no per-cluster config. " "Only allowlisted queries — no arbitrary SQL." ), ) @mcp.tool() -def execute_health_query(engine: str, category: str, query_id: str) -> str: +def execute_health_query(cluster_identifier: str, category: str, query_id: str, + database: str = None, secret_arn: str = None) -> str: """ - Run a predefined health check query by engine, category, and query ID. + Run a predefined health check query against an Aurora cluster. + Engine and credentials are auto-detected from the cluster — no config needed. Args: - engine: "mysql" or "postgresql" - category: Category number (1-10) - query_id: Query ID (e.g., "3.1", "6.2") + cluster_identifier: Aurora cluster identifier (engine auto-detected). + category: Category number (1-10). + query_id: Query ID (e.g., "3.1", "6.2"). + database: Optional database override (defaults per engine). + secret_arn: Optional Secrets Manager ARN override (defaults to the + cluster's AWS-managed MasterUserSecret). """ - queries = MYSQL_QUERIES if engine == "mysql" else PG_QUERIES + r = _resolve_cluster(cluster_identifier, secret_arn) + if not r["ok"]: + return r["error"] + queries = MYSQL_QUERIES if r["family"] == "mysql" else PG_QUERIES if category not in queries: - return f"ERROR: Unknown category '{category}' for {engine}. Available: {', '.join(sorted(queries.keys()))}" + return f"ERROR: Unknown category '{category}' for {r['family']}. Available: {', '.join(sorted(queries.keys()))}" cat = queries[category] if query_id not in cat: available = [k for k in cat if not k.startswith("_")] return f"ERROR: Unknown query_id '{query_id}'. Available: {', '.join(available)}" query = cat[query_id] - result = _execute_sql(query["sql"]) - return f"## {query_id}: {query['name']}\n**Category {category}: {cat['_category']}** | Engine: {engine}\n\n{_format_table(result)}" + db = _default_database(r["family"], database) + result = _execute_sql(query["sql"], r["cluster_arn"], r["secret_arn"], db) + return (f"## {query_id}: {query['name']}\n**Category {category}: {cat['_category']}** | " + f"Engine: {r['engine']} | Cluster: {cluster_identifier}\n\n{_format_table(result)}") @mcp.tool() def list_health_queries(engine: str = "mysql") -> str: """ - List all available health check queries for an engine. + List all available health check queries for an engine (static reference — no DB access). Args: engine: "mysql" (24 queries) or "postgresql" (30 queries) @@ -338,48 +427,61 @@ def list_health_queries(engine: str = "mysql") -> str: @mcp.tool() -def run_category_check(engine: str, category: str) -> str: +def run_category_check(cluster_identifier: str, category: str, + database: str = None, secret_arn: str = None) -> str: """ - Run all health checks in a category. + Run all health checks in a category against an Aurora cluster (engine auto-detected). Args: - engine: "mysql" or "postgresql" - category: Category number (1-10) + cluster_identifier: Aurora cluster identifier. + category: Category number (1-10). + database: Optional database override. + secret_arn: Optional secret ARN override. """ - queries = MYSQL_QUERIES if engine == "mysql" else PG_QUERIES + r = _resolve_cluster(cluster_identifier, secret_arn) + if not r["ok"]: + return r["error"] + queries = MYSQL_QUERIES if r["family"] == "mysql" else PG_QUERIES if category not in queries: return f"ERROR: Unknown category '{category}'." cat = queries[category] - output = f"# Category {category}: {cat['_category']} ({engine})\n\n" + db = _default_database(r["family"], database) + output = f"# Category {category}: {cat['_category']} ({r['engine']}) | Cluster: {cluster_identifier}\n\n" for qid, qdef in cat.items(): if qid.startswith("_"): continue - result = _execute_sql(qdef["sql"]) + result = _execute_sql(qdef["sql"], r["cluster_arn"], r["secret_arn"], db) output += f"## {qid}: {qdef['name']}\n{_format_table(result)}\n\n" return output @mcp.tool() -def run_full_health_check(engine: str = "mysql") -> str: +def run_full_health_check(cluster_identifier: str, + database: str = None, secret_arn: str = None) -> str: """ - Run key queries from all categories for a comprehensive assessment. + Run key queries from all categories against an Aurora cluster (engine auto-detected). Args: - engine: "mysql" or "postgresql" + cluster_identifier: Aurora cluster identifier. + database: Optional database override. + secret_arn: Optional secret ARN override. """ - if engine == "mysql": + r = _resolve_cluster(cluster_identifier, secret_arn) + if not r["ok"]: + return r["error"] + if r["family"] == "mysql": key_queries = ["1.1", "2.2", "3.1", "5.3", "6.1", "7.1", "8.1", "9.1", "10.4"] else: key_queries = ["1.1", "2.1", "3.1", "5.2", "6.1", "7.2", "8.1", "9.1", "10.2"] - queries = MYSQL_QUERIES if engine == "mysql" else PG_QUERIES - output = f"# Full Health Check ({engine})\n\n" + queries = MYSQL_QUERIES if r["family"] == "mysql" else PG_QUERIES + db = _default_database(r["family"], database) + output = f"# Full Health Check ({r['engine']}) | Cluster: {cluster_identifier}\n\n" for qid in key_queries: cat_num = qid.split(".")[0] - cat = queries.get(cat_num, {}) - qdef = cat.get(qid) + qdef = queries.get(cat_num, {}).get(qid) if not qdef: continue - result = _execute_sql(qdef["sql"]) + result = _execute_sql(qdef["sql"], r["cluster_arn"], r["secret_arn"], db) output += f"## {qid}: {qdef['name']}\n{_format_table(result)}\n\n" return output @@ -455,7 +557,6 @@ def get_cluster_metrics(cluster_identifier: str, hours_back: int = 3) -> str: ("FreeableMemory", "bytes"), ("ReadIOPS", "count/sec"), ("WriteIOPS", "count/sec"), ("AuroraReplicaLag", "ms"), ] - # Get cluster members to query instance-level metrics try: cluster_resp = rds_client.describe_db_clusters(DBClusterIdentifier=cluster_identifier) members = cluster_resp["DBClusters"][0].get("DBClusterMembers", []) @@ -502,7 +603,6 @@ def get_performance_insights(instance_identifier: str) -> str: return msg try: resource_id = f"db-{instance_identifier}" - # Try to get the actual DbiResourceId try: inst = rds_client.describe_db_instances(DBInstanceIdentifier=instance_identifier) resource_id = inst["DBInstances"][0]["DbiResourceId"] @@ -567,7 +667,6 @@ def get_proxy_health(proxy_name: str) -> str: | Auth | {proxy.get('Auth', [{}])[0].get('AuthScheme', 'N/A')} | | Idle Timeout | {proxy.get('IdleClientTimeout')} sec | """ - # Get targets try: targets = rds_client.describe_db_proxy_targets(DBProxyName=proxy_name) output += "\n### Targets\n| Target | Type | State | Health |\n| --- | --- | --- | --- |\n" diff --git a/mcp/rds-aidba/template.yaml b/mcp/rds-aidba/template.yaml index acf9546..3aec5f2 100644 --- a/mcp/rds-aidba/template.yaml +++ b/mcp/rds-aidba/template.yaml @@ -1,23 +1,20 @@ AWSTemplateFormatVersion: '2010-09-09' Transform: AWS::Serverless-2016-10-31 Description: > - rds-aidba MCP Server for AWS DevOps Agent. - Uses mcp-proxy + Lambda Web Adapter for Streamable HTTP. Function URL with IAM auth. + rds-aidba MCP Server — Aurora MySQL/PostgreSQL read-only diagnostics via + RDS Data API. Custom FastMCP server behind mcp-proxy, fronted by an + API Gateway REST API (AWS_IAM/SigV4). Register the API Gateway endpoint + with DevOps Agent using Service Name = execute-api. Data-plane tools + auto-discover the target cluster's engine and credentials — deploy once, + diagnose any allowlisted Aurora cluster. Globals: Function: - Timeout: 180 + Timeout: 60 Runtime: python3.12 MemorySize: 1024 Parameters: - ClusterArn: - Type: String - SecretArn: - Type: String - DatabaseName: - Type: String - Default: information_schema StageName: Type: String Default: dev @@ -25,9 +22,22 @@ Parameters: AllowedClusters: Type: String Default: '*' + Description: Comma-separated cluster identifiers. Use '*' for dev only (prod requires explicit list). AllowedDatabases: Type: String Default: '*' + Description: Comma-separated database names. Use '*' for dev only. + DefaultDatabase: + Type: String + Default: '' + Description: Optional default database override (else information_schema/postgres per engine). + ExistingApiGatewayCloudWatchRoleArn: + Type: String + Default: '' + Description: Optional existing API GW CloudWatch role ARN. Leave empty to create one. + +Conditions: + ShouldCreateApiGatewayCloudWatchRole: !Equals [!Ref ExistingApiGatewayCloudWatchRoleArn, ''] Resources: RdsAidbaFunction: @@ -43,40 +53,73 @@ Resources: Environment: Variables: AWS_LAMBDA_EXEC_WRAPPER: /opt/bootstrap - AWS_LWA_PORT: '8000' + AWS_LWA_PORT: "8000" AWS_LWA_READINESS_CHECK_PATH: /mcp - AWS_LWA_READINESS_CHECK_MIN_UNHEALTHY_STATUS: '500' - AWS_LWA_INVOKE_MODE: response_stream - AWS_LWA_READINESS_CHECK_PROTOCOL: http - AWS_LWA_ASYNC_INIT: 'true' PYTHONPATH: /opt/python - CLUSTER_ARN: !Ref ClusterArn - SECRET_ARN: !Ref SecretArn - DATABASE_NAME: !Ref DatabaseName STAGE_NAME: !Ref StageName ALLOWED_CLUSTERS: !Ref AllowedClusters ALLOWED_DATABASES: !Ref AllowedDatabases - FunctionUrlConfig: - AuthType: AWS_IAM - InvokeMode: RESPONSE_STREAM + DATABASE_NAME: !Ref DefaultDatabase + Events: + McpApi: + Type: Api + Properties: + RestApiId: !Ref RdsAidbaApi + Path: /mcp + Method: POST Policies: - Statement: - Effect: Allow Action: [rds-data:ExecuteStatement, rds-data:BatchExecuteStatement] - Resource: !Ref ClusterArn + Resource: !Sub 'arn:aws:rds:${AWS::Region}:${AWS::AccountId}:cluster:*' - Statement: - Effect: Allow - Action: secretsmanager:GetSecretValue - Resource: !Ref SecretArn + Action: [secretsmanager:GetSecretValue] + Resource: !Sub 'arn:aws:secretsmanager:${AWS::Region}:${AWS::AccountId}:secret:rds!cluster-*' - Statement: - Effect: Allow - Action: [rds:DescribeDBClusters, rds:DescribeDBInstances, rds:DescribeDBEngineVersions, rds:DescribeDBProxies, rds:DescribeDBProxyTargets] + Action: [rds:DescribeDBClusters, rds:DescribeDBInstances, rds:DescribeDBEngineVersions] Resource: '*' - Statement: - Effect: Allow Action: [cloudwatch:GetMetricStatistics, cloudwatch:GetMetricData, pi:GetResourceMetrics, pi:DescribeDimensionKeys] Resource: '*' + RdsAidbaApi: + Type: AWS::Serverless::Api + DependsOn: ApiGatewayAccount + Properties: + Name: !Sub ${AWS::StackName}-rds-aidba-api + StageName: Prod + Auth: + DefaultAuthorizer: AWS_IAM + MethodSettings: + - ResourcePath: /* + HttpMethod: '*' + LoggingLevel: INFO + MetricsEnabled: true + + ApiGatewayCloudWatchRole: + Type: AWS::IAM::Role + Condition: ShouldCreateApiGatewayCloudWatchRole + Properties: + AssumeRolePolicyDocument: + Version: '2012-10-17' + Statement: + - Effect: Allow + Principal: { Service: apigateway.amazonaws.com } + Action: sts:AssumeRole + ManagedPolicyArns: + - arn:aws:iam::aws:policy/service-role/AmazonAPIGatewayPushToCloudWatchLogs + + ApiGatewayAccount: + Type: AWS::ApiGateway::Account + Properties: + CloudWatchRoleArn: !If + - ShouldCreateApiGatewayCloudWatchRole + - !GetAtt ApiGatewayCloudWatchRole.Arn + - !Ref ExistingApiGatewayCloudWatchRoleArn + DependenciesLayer: Type: AWS::Serverless::LayerVersion Properties: @@ -89,7 +132,7 @@ Resources: Outputs: McpEndpointUrl: - Description: Register in DevOps Agent (Service Name = lambda, Auth = SigV4) - Value: !Sub ${RdsAidbaFunctionUrl.FunctionUrl}mcp + Description: Register in DevOps Agent — Service Name = execute-api, Auth = SigV4 + Value: !Sub 'https://${RdsAidbaApi}.execute-api.${AWS::Region}.amazonaws.com/Prod/mcp' FunctionArn: Value: !GetAtt RdsAidbaFunction.Arn From 2e39acc621c3b3c0d843fb750b78d5c23cc279ae Mon Sep 17 00:00:00 2001 From: Kiran Mayee Mulupuru Date: Tue, 18 Aug 2026 19:14:35 -0500 Subject: [PATCH 2/3] Fix get_proxy_health IAM regression and cap Lambda timeout (PR #57 review) - Restore rds:DescribeDBProxies and rds:DescribeDBProxyTargets, which get_proxy_health calls and the README/instructions still advertise; without them the tool returned AccessDenied post-merge (regression). - Set Lambda Timeout to 29s to match the API Gateway REST integration timeout, so slow queries no longer 504 at 29s while Lambda bills to 60s. Optional items from the review (ApiGatewayAccount default, prod wildcard guard, StageName reconciliation, formatting) deferred per reviewer. --- mcp/rds-aidba/template.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/mcp/rds-aidba/template.yaml b/mcp/rds-aidba/template.yaml index 3aec5f2..776d2f2 100644 --- a/mcp/rds-aidba/template.yaml +++ b/mcp/rds-aidba/template.yaml @@ -10,7 +10,7 @@ Description: > Globals: Function: - Timeout: 60 + Timeout: 29 Runtime: python3.12 MemorySize: 1024 @@ -78,7 +78,7 @@ Resources: Resource: !Sub 'arn:aws:secretsmanager:${AWS::Region}:${AWS::AccountId}:secret:rds!cluster-*' - Statement: - Effect: Allow - Action: [rds:DescribeDBClusters, rds:DescribeDBInstances, rds:DescribeDBEngineVersions] + Action: [rds:DescribeDBClusters, rds:DescribeDBInstances, rds:DescribeDBEngineVersions, rds:DescribeDBProxies, rds:DescribeDBProxyTargets] Resource: '*' - Statement: - Effect: Allow From 0579fe7d73c1a8c1102409626ed999ff1ab129f6 Mon Sep 17 00:00:00 2001 From: Kiran Mayee Mulupuru Date: Tue, 18 Aug 2026 19:49:55 -0500 Subject: [PATCH 3/3] Address optional PR #57 review items (3-6) - Item 3: document that AWS::ApiGateway::Account is an account/region-wide setting; point users to ExistingApiGatewayCloudWatchRoleArn and warn about stack-delete side effects (README). - Item 4: add a CloudFormation Rule that fails the deploy when StageName is 'prod' and AllowedClusters is '*', enforcing the prod allowlist guidance. - Item 5: clarify what StageName controls (allowlist enforcement + resource naming; API stage path is always /Prod/). - Item 6: fix formatting nits in _execute_sql (space after comma; align the columns list continuation). - Also correct the README Register URL/Service Name to match the API Gateway (execute-api) transport. --- mcp/rds-aidba/README.md | 20 ++++++++++++++++++-- mcp/rds-aidba/src/server.py | 4 ++-- mcp/rds-aidba/template.yaml | 15 +++++++++++++++ 3 files changed, 35 insertions(+), 4 deletions(-) diff --git a/mcp/rds-aidba/README.md b/mcp/rds-aidba/README.md index 5e57a8e..bf4dc75 100644 --- a/mcp/rds-aidba/README.md +++ b/mcp/rds-aidba/README.md @@ -27,6 +27,22 @@ Custom MCP server for AWS DevOps Agent providing safe, query-allowlisted diagnos - No VPC required (RDS Data API) - Function URL with AWS_IAM auth +## ⚠️ Account-level API Gateway setting + +This stack creates an `AWS::ApiGateway::Account` resource to set the CloudWatch +Logs role API Gateway uses for access logging/metrics. **This is an +account-wide, region-wide setting** — it applies to every API Gateway REST API +in the account/region, not just this stack. + +- If your account already has this role configured, pass its ARN via the + `ExistingApiGatewayCloudWatchRoleArn` parameter so the stack reuses it instead + of creating a new one. +- Deleting this stack can reset the account's CloudWatch role, which may affect + access logging for unrelated APIs. + +Deploy into a dedicated/sandbox account, or coordinate with your account owner, +before deploying into a shared account. + ## Deploy sam build @@ -34,8 +50,8 @@ Custom MCP server for AWS DevOps Agent providing safe, query-allowlisted diagnos ## Register in DevOps Agent -- URL: Function URL from stack output (use as-is, already includes /mcp) -- Service Name: lambda +- URL: McpEndpointUrl from stack output (already includes /Prod/mcp) +- Service Name: execute-api - Auth: IAM (SigV4) ## Disclaimer diff --git a/mcp/rds-aidba/src/server.py b/mcp/rds-aidba/src/server.py index c5fcc16..8078044 100644 --- a/mcp/rds-aidba/src/server.py +++ b/mcp/rds-aidba/src/server.py @@ -193,10 +193,10 @@ def _execute_sql(sql: str, cluster_arn: str, secret_arn: str, database: str) -> return {"success": False, "error": msg, "columns": [], "rows": [], "rowCount": 0} try: response = rds_data.execute_statement( - resourceArn=cluster_arn, secretArn=secret_arn, database=database, sql=sql,includeResultMetadata=True, + resourceArn=cluster_arn, secretArn=secret_arn, database=database, sql=sql, includeResultMetadata=True, ) columns = [col.get("label") or col.get("name") or f"col_{i}" - for i, col in enumerate(response.get("columnMetadata", []))] + for i, col in enumerate(response.get("columnMetadata", []))] rows = [] for record in response.get("records", []): row = {} diff --git a/mcp/rds-aidba/template.yaml b/mcp/rds-aidba/template.yaml index 776d2f2..e16b50c 100644 --- a/mcp/rds-aidba/template.yaml +++ b/mcp/rds-aidba/template.yaml @@ -19,6 +19,11 @@ Parameters: Type: String Default: dev AllowedValues: [dev, staging, prod] + Description: > + Environment for allowlist enforcement (prod requires an explicit + AllowedClusters list) and resource naming (function/layer names, + STAGE_NAME env var). The API Gateway stage path is always /Prod/ + regardless of this value. AllowedClusters: Type: String Default: '*' @@ -39,6 +44,16 @@ Parameters: Conditions: ShouldCreateApiGatewayCloudWatchRole: !Equals [!Ref ExistingApiGatewayCloudWatchRoleArn, ''] +Rules: + ProdRequiresExplicitClusterList: + RuleCondition: !Equals [!Ref StageName, prod] + Assertions: + - Assert: !Not [!Equals [!Ref AllowedClusters, '*']] + AssertDescription: > + When StageName is 'prod', AllowedClusters must be an explicit + comma-separated list of cluster identifiers, not '*'. Wildcard + cluster access is only permitted for dev/staging. + Resources: RdsAidbaFunction: Type: AWS::Serverless::Function