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/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..8078044 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..e16b50c 100644 --- a/mcp/rds-aidba/template.yaml +++ b/mcp/rds-aidba/template.yaml @@ -1,33 +1,58 @@ 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: 29 Runtime: python3.12 MemorySize: 1024 Parameters: - ClusterArn: - Type: String - SecretArn: - Type: String - DatabaseName: - Type: String - Default: information_schema StageName: 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: '*' + 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, ''] + +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: @@ -43,31 +68,29 @@ 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] @@ -77,6 +100,41 @@ Resources: 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 +147,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