diff --git a/.gitignore b/.gitignore index b6fd557..4bdbf08 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,9 @@ # Dependencies node_modules/ +.yarn/cache/ +.yarn/unplugged/ +.yarn/build-state.yml +.yarn/install-state.gz .vercel dist .xmcp diff --git a/README.md b/README.md index e0e397a..e115d3b 100644 --- a/README.md +++ b/README.md @@ -78,6 +78,7 @@ This server provides your AI with dedicated tools for managing your LocalStack e | [`localstack-app-inspector`](./src/tools/localstack-app-inspector.ts) | Inspects LocalStack application traces, spans, events, and IAM evaluations | - Enable or disable App Inspector for the running LocalStack instance
- List and inspect traces to understand AWS service-to-service flows
- Drill into spans, events, payload metadata, and IAM policy evaluation events
- Filter by service, region, operation, resource, ARN, status, and time range
- Requires a valid LocalStack Auth Token and the App Inspector feature in the connected LocalStack license | | [`localstack-docs`](./src/tools/localstack-docs.ts) | Searches LocalStack documentation through CrawlChat | - Queries LocalStack docs through a public CrawlChat collection
- Returns focused snippets with source links only
- Helps answer coverage, configuration, and setup questions without requiring LocalStack runtime | | [`localstack-snowflake-client`](./src/tools/localstack-snowflake-client.ts) | Runs SQL against the LocalStack Snowflake emulator through the `snow` CLI | - Execute SELECT, DDL (CREATE/DROP), DML (INSERT/UPDATE/DELETE), and SHOW/DESCRIBE statements from a query string or a `.sql` file
- Check the Snowflake connection before running queries
- Set optional database, schema, warehouse, and role context per query
- Requires the Snowflake CLI (`snow`) and a valid LocalStack Auth Token | +| [`localstack-preflight`](./src/tools/localstack-coverage.ts) | Checks LocalStack API coverage and deploy-readiness for IaC templates | - Scan Terraform, CloudFormation, CDK, or Pulumi projects and get a per-resource verdict
- Look up which AWS service operations are implemented vs missing
- Check individual `service:Operation` pairs against the coverage database
- Requires LocalStack running with the `localstack-extension-coverage` extension installed | ## Prompts diff --git a/data/evals/gemini-comprehensive.json b/data/evals/gemini-comprehensive.json index d4bc399..8e05543 100644 --- a/data/evals/gemini-comprehensive.json +++ b/data/evals/gemini-comprehensive.json @@ -105,6 +105,26 @@ "expect": { "toolsTriggered": { "calls": [{ "name": "localstack-iam-policy-analyzer", "required": true }], "order": "any" } } + }, + { + "id": "discover-preflight-tool-services", + "mode": "mcp_host", + "scenario": "Show me an overview of all AWS services supported by LocalStack and their coverage percentages.", + "mcpHostConfig": { "provider": "google", "model": "gemini-2.0-flash", "temperature": 0 }, + "iterations": 5, + "expect": { + "toolsTriggered": { "calls": [{ "name": "localstack-preflight", "required": true }], "order": "any" } + } + }, + { + "id": "discover-preflight-tool-resources", + "mode": "mcp_host", + "scenario": "Will my Terraform resources aws_s3_bucket and aws_lambda_function work on LocalStack? Check if they are fully supported.", + "mcpHostConfig": { "provider": "google", "model": "gemini-2.0-flash", "temperature": 0 }, + "iterations": 5, + "expect": { + "toolsTriggered": { "calls": [{ "name": "localstack-preflight", "required": true }], "order": "any" } + } } ] } diff --git a/manifest.json b/manifest.json index d774ecd..391cdc1 100644 --- a/manifest.json +++ b/manifest.json @@ -78,6 +78,10 @@ { "name": "localstack-app-inspector", "description": "Inspect LocalStack application traces, spans, events, payload metadata, and IAM policy evaluations" + }, + { + "name": "localstack-preflight", + "description": "Query the LocalStack coverage extension to find out which AWS service operations are implemented and get a deploy-readiness verdict for IaC templates" } ], "prompts": [ diff --git a/src/core/analytics.ts b/src/core/analytics.ts index 522b50e..8e45dc1 100644 --- a/src/core/analytics.ts +++ b/src/core/analytics.ts @@ -67,6 +67,7 @@ export const TOOL_ARG_ALLOWLIST: Record = { "localstack-logs-analysis": ["analysisType", "lines", "service", "operation", "filter"], "localstack-management": ["action", "service", "envVars"], "localstack-snowflake-client": ["action"], + "localstack-preflight": ["action", "service"], }; let posthogClient: PostHog | null = null; diff --git a/src/core/response-builder.ts b/src/core/response-builder.ts index 1d3f193..fc2f643 100644 --- a/src/core/response-builder.ts +++ b/src/core/response-builder.ts @@ -24,4 +24,10 @@ export class ResponseBuilder { content: [{ type: "text", text: content }], }; } + + public static blocks(...parts: string[]): ToolResponse { + return { + content: parts.map((text) => ({ type: "text" as const, text })), + }; + } } diff --git a/src/prompts/coverage-advisor.ts b/src/prompts/coverage-advisor.ts new file mode 100644 index 0000000..4b8a3b7 --- /dev/null +++ b/src/prompts/coverage-advisor.ts @@ -0,0 +1,105 @@ +import { z } from "zod"; +import { type InferSchema, type PromptMetadata } from "xmcp"; +import { withPromptAnalytics } from "../core/analytics"; + +export const schema = { + iac_path: z + .string() + .optional() + .describe( + "(Optional) Path to a CloudFormation, Terraform, CDK, or SAM project. When provided, the advisor parses the template and checks every required operation automatically." + ), + services: z + .string() + .optional() + .describe( + "(Optional) Comma-separated AWS service names to check in full (e.g. 's3,lambda,dynamodb'). Use when you want per-service detail without a template." + ), + operations: z + .string() + .optional() + .describe( + "(Optional) Comma-separated 'service:Operation' pairs to check directly (e.g. 's3:CreateBucket,iam:CreateRole'). Overrides auto-extraction when provided." + ), + mode: z + .string() + .optional() + .describe( + "(Optional) 'summary' (default) shows only gaps and a deploy verdict; 'full' lists every implemented and missing operation." + ), +}; + +export const metadata: PromptMetadata = { + name: "localstack-preflight", + title: "Coverage Advisor", + description: + "Check LocalStack API coverage for an IaC template, a list of services, or specific operations — and get a clear deploy-readiness verdict.", + role: "user", +}; + +type PromptArgs = InferSchema; + +export default async function coverageAdvisor(args: PromptArgs): Promise { + return withPromptAnalytics(metadata.name, args, async () => { + const values = { + iac_path: args.iac_path?.trim() ?? "", + services: args.services?.trim() ?? "", + operations: args.operations?.trim() ?? "", + mode: normalize(args.mode, "summary"), + }; + return renderCoverageAdvisorPrompt(values); + }); +} + +function normalize(value: string | undefined, fallback: string): string { + const v = value?.trim(); + return v && v.length > 0 ? v : fallback; +} + +function renderCoverageAdvisorPrompt(values: { + iac_path: string; + services: string; + operations: string; + mode: string; +}): string { + const hasIac = values.iac_path.length > 0; + const hasServices = values.services.length > 0; + const hasOperations = values.operations.length > 0; + + return `# LocalStack Coverage Check + +## OUTPUT RULE — follow exactly + +Call the tool, then render the results as a **markdown table** in your response with columns Resource and Status (✅ / ❌). Group by stack or framework if there are multiple. End with a single bold verdict line. No preamble, no extra analysis beyond the verdict. + +## What to call + +Use only the \`mcp__localstack__localstack-preflight\` tool. + +${hasOperations + ? `Split \`${values.operations}\` on commas and call \`check_operations\` with the resulting array.` + : hasIac + ? `Call \`scan_iac\` with \`iac_path: "${values.iac_path}"\`. Do not read the files yourself.` + : hasServices + ? `Call \`get_service_coverage\` once for each service in \`${values.services}\` (split on commas).` + : `Call \`scan_iac\` with the workspace root as \`iac_path\`. If the tool returns "No resource types found", ask the user: "I didn't find any IaC files in — where is your Terraform project?"`} + +${hasIac && !hasOperations ? `If the template has more than 50 resource types, chunk \`check_resources\` into batches of 50 and concatenate the outputs.` : ""} + +## After showing the verdict + +If the scan found **any blockers** and the project is Terraform, append exactly this line after the tool output: + +> Want me to patch this so it deploys on LocalStack? I'll gate the unsupported resources with \`count = 0\` and add the LocalStack provider config — shown as a diff before anything is changed. + +If the user says yes, call \`patch_iac\` with the same \`iac_path\`. + +## Absolute prohibitions + +- Do NOT read IaC files yourself. The tool does this — call the tool. +- Do NOT add a "LocalStack Tier" column or any tier/edition labels (Community, Pro, etc.). +- Do NOT add file paths or line numbers. +- Do NOT add risk labels, impact descriptions, or percentages. +- Do NOT reorder, rename, or add rows beyond what the tool returned. +- Do NOT add any text before the tool output other than the patch offer line above.`; +} diff --git a/src/tools/localstack-coverage.ts b/src/tools/localstack-coverage.ts new file mode 100644 index 0000000..370e00e --- /dev/null +++ b/src/tools/localstack-coverage.ts @@ -0,0 +1,786 @@ +import { z } from "zod"; +import { type ToolMetadata, type InferSchema } from "xmcp"; +import { readdirSync, readFileSync, statSync } from "node:fs"; +import { join, extname } from "node:path"; +import { ResponseBuilder } from "../core/response-builder"; +import { withToolAnalytics } from "../core/analytics"; + +// --------------------------------------------------------------------------- +// Coverage extension REST client +// --------------------------------------------------------------------------- +function getCoverageUrl(): string { + return ( + process.env.LOCALSTACK_COVERAGE_URL ?? + "http://localhost:4566/_extension/localstack-coverage" + ).replace(/\/$/, ""); +} + +async function coverageFetch(path: string, body?: unknown): Promise { + const url = `${getCoverageUrl()}${path}`; + let res: Response; + try { + res = await fetch(url, body !== undefined ? { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + } : undefined); + } catch { + throw new Error( + `LocalStack coverage extension unreachable at ${url}. ` + + `Is LocalStack running with the localstack-extension-coverage extension installed?` + ); + } + if (!res.ok) { + throw new Error(`Coverage API returned HTTP ${res.status} for ${path}`); + } + return res.json() as Promise; +} + +// --------------------------------------------------------------------------- +// Friendly display names for common IaC resource types +// --------------------------------------------------------------------------- +export const FRIENDLY_NAMES: Record = { + // Terraform + aws_sqs_queue: "SQS queue", + aws_dynamodb_table: "DynamoDB table", + aws_lambda_function: "Lambda function", + aws_lambda_event_source_mapping: "Lambda event source mapping", + aws_lambda_permission: "Lambda permission", + aws_iam_role: "IAM role", + aws_iam_policy: "IAM policy", + aws_iam_role_policy: "IAM role policy", + aws_iam_role_policy_attachment: "IAM role policy attachment", + aws_s3_bucket: "S3 bucket", + aws_s3_bucket_policy: "S3 bucket policy", + aws_s3_object: "S3 object", + aws_api_gateway_rest_api: "API Gateway REST API", + aws_api_gateway_resource: "API Gateway resource", + aws_api_gateway_method: "API Gateway method", + aws_api_gateway_integration: "API Gateway integration", + aws_api_gateway_deployment: "API Gateway deployment", + aws_api_gateway_stage: "API Gateway stage", + aws_api_gateway_authorizer: "API Gateway authorizer", + aws_sns_topic: "SNS topic", + aws_sns_topic_subscription: "SNS topic subscription", + aws_ses_email_identity: "SES email identity", + aws_ses_domain_identity: "SES domain identity", + aws_secretsmanager_secret: "Secrets Manager secret", + aws_secretsmanager_secret_version: "Secrets Manager secret version", + aws_ssm_parameter: "SSM parameter", + aws_cloudwatch_log_group: "CloudWatch log group", + aws_cloudwatch_metric_alarm: "CloudWatch alarm", + aws_cloudwatch_event_rule: "EventBridge rule", + aws_cloudwatch_event_target: "EventBridge target", + aws_pipes_pipe: "EventBridge Pipe", + aws_sfn_state_machine: "Step Functions state machine", + aws_ecr_repository: "ECR repository", + aws_ecs_cluster: "ECS cluster", + aws_ecs_task_definition: "ECS task definition", + aws_ecs_service: "ECS service", + aws_rds_cluster: "RDS cluster", + aws_rds_instance: "RDS instance", + aws_elasticache_cluster: "ElastiCache cluster", + aws_kinesis_stream: "Kinesis stream", + aws_kinesis_firehose_delivery_stream: "Kinesis Firehose delivery stream", + aws_cognito_user_pool: "Cognito user pool", + aws_cognito_user_pool_client: "Cognito user pool client", + aws_route53_zone: "Route53 hosted zone", + aws_route53_record: "Route53 record", + aws_acm_certificate: "ACM certificate", + aws_cloudfront_distribution: "CloudFront distribution", + aws_cloudfront_origin_access_identity: "CloudFront origin access identity", + aws_wafv2_web_acl: "WAFv2 web ACL", + aws_media_convert_queue: "MediaConvert queue", + aws_chimesdkvoice_sip_media_application: "Chime SDK voice SIP media app", + aws_vpc: "VPC", + aws_subnet: "Subnet", + aws_security_group: "Security group", + aws_internet_gateway: "Internet gateway", + aws_elasticloadbalancingv2_load_balancer: "Application Load Balancer", + aws_elasticloadbalancingv2_target_group: "ALB target group", + aws_elasticloadbalancingv2_listener: "ALB listener", + // CloudFormation / CDK + "AWS::SQS::Queue": "SQS queue", + "AWS::DynamoDB::Table": "DynamoDB table", + "AWS::Lambda::Function": "Lambda function", + "AWS::Lambda::EventSourceMapping": "Lambda event source mapping", + "AWS::IAM::Role": "IAM role", + "AWS::IAM::Policy": "IAM policy", + "AWS::IAM::ManagedPolicy": "IAM managed policy", + "AWS::S3::Bucket": "S3 bucket", + "AWS::S3::BucketPolicy": "S3 bucket policy", + "AWS::ApiGateway::RestApi": "API Gateway REST API", + "AWS::ApiGateway::Resource": "API Gateway resource", + "AWS::ApiGateway::Method": "API Gateway method", + "AWS::ApiGateway::Deployment": "API Gateway deployment", + "AWS::ApiGateway::Stage": "API Gateway stage", + "AWS::SNS::Topic": "SNS topic", + "AWS::SNS::Subscription": "SNS subscription", + "AWS::SES::EmailIdentity": "SES email identity", + "AWS::SecretsManager::Secret": "Secrets Manager secret", + "AWS::SSM::Parameter": "SSM parameter", + "AWS::Logs::LogGroup": "CloudWatch log group", + "AWS::CloudWatch::Alarm": "CloudWatch alarm", + "AWS::Events::Rule": "EventBridge rule", + "AWS::Pipes::Pipe": "EventBridge Pipe", + "AWS::StepFunctions::StateMachine": "Step Functions state machine", + "AWS::ECR::Repository": "ECR repository", + "AWS::ECS::Cluster": "ECS cluster", + "AWS::ECS::TaskDefinition": "ECS task definition", + "AWS::ECS::Service": "ECS service", + "AWS::RDS::DBCluster": "RDS cluster", + "AWS::RDS::DBInstance": "RDS instance", + "AWS::Cognito::UserPool": "Cognito user pool", + "AWS::Cognito::UserPoolClient": "Cognito user pool client", + "AWS::Route53::HostedZone": "Route53 hosted zone", + "AWS::Route53::RecordSet": "Route53 record", + "AWS::CertificateManager::Certificate": "ACM certificate", + "AWS::CloudFront::Distribution": "CloudFront distribution", + "AWS::CloudFront::CloudFrontOriginAccessIdentity": "CloudFront OAI", + "AWS::ElasticLoadBalancingV2::LoadBalancer": "Application Load Balancer", + "AWS::ElasticLoadBalancingV2::TargetGroup": "ALB target group", + "AWS::ElasticLoadBalancingV2::Listener": "ALB listener", + // Pulumi + "aws:sqs/queue:Queue": "SQS queue", + "aws:dynamodb/table:Table": "DynamoDB table", + "aws:lambda/function:Function": "Lambda function", + "aws:iam/role:Role": "IAM role", + "aws:iam/policy:Policy": "IAM policy", + "aws:s3/bucket:Bucket": "S3 bucket", + "aws:sns/topic:Topic": "SNS topic", + "aws:cloudwatch/logGroup:LogGroup": "CloudWatch log group", + "aws:sfn/stateMachine:StateMachine": "Step Functions state machine", +}; + +export function friendlyName(resourceType: string): string { + return FRIENDLY_NAMES[resourceType] ?? resourceType; +} + +// --------------------------------------------------------------------------- +// Schema +// --------------------------------------------------------------------------- + +export const schema = { + action: z + .enum(["list_services", "get_service_coverage", "check_operations", "check_resources", "scan_iac", "patch_iac"]) + .describe( + [ + "list_services — overview of all AWS services with their coverage percentage.", + "get_service_coverage — full list of implemented and missing operations for one service.", + "check_operations — given a list of 'service:Operation' pairs, report which are implemented and which are missing.", + "check_resources — given a list of IaC resource type names (e.g. 'AWS::S3::Bucket', 'aws_lambda_function', 'aws:s3/bucket:Bucket'), look up their required operations from the coverage DB and report a per-resource deploy-readiness verdict.", + "scan_iac — given a path to a Terraform, CloudFormation, CDK, Pulumi, or shell script project, read the files fresh from disk, extract all resource types and AWS CLI calls, and return a deploy-readiness verdict. Use this instead of reading files manually — it always reflects the current state of the files.", + "patch_iac — given a path to a Terraform project, generate a unified diff that gates all blocking resources with 'count = 0' (marked # localstack-patch) and injects a LocalStack provider config. Terraform only. Show the diff to the user before applying.", + ].join(" | ") + ), + + service: z + .string() + .optional() + .describe( + "AWS service name (e.g. 's3', 'iam', 'lambda'). Required for get_service_coverage." + ), + + operations: z + .array(z.string()) + .optional() + .describe( + "List of 'service:Operation' pairs to check (e.g. ['s3:CreateBucket', 'iam:CreateRole']). Required for check_operations." + ), + + resources: z + .array(z.string()) + .optional() + .describe( + "List of IaC resource type names to check (e.g. ['AWS::S3::Bucket', 'aws_lambda_function', 'aws:sqs/queue:Queue']). Required for check_resources." + ), + + iac_path: z + .string() + .optional() + .describe( + "Path to a Terraform, CloudFormation, CDK, Pulumi, or shell script project directory or file. Required for scan_iac. Files are read fresh from disk on every call. Shell scripts (.sh) with aws/awslocal CLI calls are also supported." + ), +}; + +// --------------------------------------------------------------------------- +// Metadata +// --------------------------------------------------------------------------- + +export const metadata: ToolMetadata = { + name: "localstack-preflight", + description: + "Preflight coverage check — reads IaC files and queries the LocalStack coverage extension to report which AWS resources and API operations are supported. " + + "Requires LocalStack to be running with the localstack-extension-coverage extension installed. " + + "Does NOT deploy, run terraform, or install tools. " + + "Use this BEFORE localstack-deployer whenever the user wants to know if their IaC is compatible with LocalStack. " + + "Trigger phrases: 'will this work on localstack', 'will my IaC work', 'will this deploy on localstack', " + + "'is this compatible with localstack', 'does localstack support this', " + + "'check my terraform / stack / IaC', 'validate my terraform / stack / IaC', " + + "'what won't work on localstack', 'what operations are missing', 'any blockers', " + + "'preflight check', 'coverage check', 'localstack coverage'. " + + "When the user asks without providing a path, infer the workspace root and pass it as iac_path to scan_iac. " + + "If scan_iac returns no resources found, ask the user once: 'I didn't find any IaC files in — where is your project?' " + + "If the coverage extension is unreachable, tell the user: 'Install the localstack-extension-coverage extension and restart LocalStack. " + + "You can override the endpoint with the LOCALSTACK_COVERAGE_URL environment variable.' " + + "After showing the verdict, offer to patch the project so it deploys on LocalStack (Terraform only).", + annotations: { + title: "LocalStack Preflight", + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + }, +}; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +type Row = Record; + +// --------------------------------------------------------------------------- +// Tool implementation +// --------------------------------------------------------------------------- + +export default async function localstackCoverage({ + action, + service, + operations, + resources, + iac_path, +}: InferSchema) { + return withToolAnalytics( + "localstack-preflight", + { action, service }, + async () => { + try { + switch (action) { + case "list_services": + return await listServices(); + case "get_service_coverage": + return await getServiceCoverage(service); + case "check_operations": + return await checkOperations(operations); + case "check_resources": + return await checkResources(resources); + case "scan_iac": + return await scanIac(iac_path); + case "patch_iac": + return await patchIac(iac_path); + } + } catch (e) { + const msg = e instanceof Error ? e.message : String(e); + return ResponseBuilder.error("Coverage unavailable", msg); + } + } + ); +} + +// --------------------------------------------------------------------------- +// Action: list_services +// --------------------------------------------------------------------------- + +async function listServices() { + const { services: rows } = await coverageFetch<{ services: Row[] }>("/services"); + rows.sort((a, b) => pct(b.implemented as number, b.total as number) - pct(a.implemented as number, a.total as number)); + + const total_ops = rows.reduce((s, r) => s + (r.total as number), 0); + const total_impl = rows.reduce((s, r) => s + (r.implemented as number), 0); + + let md = `# LocalStack API Coverage\n\n`; + md += `**${rows.length} services** | **${total_impl}/${total_ops} operations implemented** `; + md += `(${pct(total_impl, total_ops)}%)\n\n`; + md += `| Service | Implemented | Total | Coverage |\n`; + md += `|---------|-------------|-------|----------|\n`; + + for (const r of rows) { + const impl = r.implemented as number; + const total = r.total as number; + const bar = coverageEmoji(pct(impl, total)); + md += `| \`${r.service}\` | ${impl} | ${total} | ${bar} ${pct(impl, total)}% |\n`; + } + + return ResponseBuilder.markdown(md); +} + +// --------------------------------------------------------------------------- +// Action: get_service_coverage +// --------------------------------------------------------------------------- + +async function getServiceCoverage(service: string | undefined) { + if (!service) { + return ResponseBuilder.error( + "Missing parameter", + "`service` is required for get_service_coverage." + ); + } + + let data: { service: string; operations: Row[] }; + try { + data = await coverageFetch<{ service: string; operations: Row[] }>( + `/services/${encodeURIComponent(service)}` + ); + } catch (e) { + if (e instanceof Error && e.message.includes("HTTP 404")) { + return ResponseBuilder.error( + "Service not found", + `No coverage data for service '${service}'. Use list_services to see available services.` + ); + } + throw e; + } + + const rows = data.operations; + const implemented = rows.filter((r) => r.implemented === 1); + const missing = rows.filter((r) => r.implemented === 0); + + let md = `# Coverage: \`${service}\`\n\n`; + md += `| Service | Implemented | Total | Coverage |\n`; + md += `|---------|-------------|-------|----------|\n`; + const p = pct(implemented.length, rows.length); + md += `| \`${service}\` | ${implemented.length} | ${rows.length} | ${coverageEmoji(p)} ${p}% |\n\n`; + + if (implemented.length > 0) { + md += `## ✅ Implemented (${implemented.length})\n\n`; + md += implemented.map((r) => `- \`${r.operation as string}\``).join("\n") + "\n\n"; + } + + if (missing.length > 0) { + md += `## ❌ Not implemented (${missing.length})\n\n`; + md += missing.map((r) => `- \`${r.operation as string}\``).join("\n") + "\n"; + } + + return ResponseBuilder.markdown(md); +} + +// --------------------------------------------------------------------------- +// Action: check_operations +// --------------------------------------------------------------------------- + +async function checkOperations(operations: string[] | undefined) { + if (!operations || operations.length === 0) { + return ResponseBuilder.error( + "Missing parameter", + "`operations` is required for check_operations. Provide an array of 'service:Operation' strings." + ); + } + + // Normalize "service.Operation" → "service:Operation" before sending + const normalized = operations.map((op) => op.replace(/^([^:.]+)\./, "$1:")); + + const { results: apiResults } = await coverageFetch<{ + results: { operation: string; implemented: number | null }[]; + }>("/operations", normalized); + + const results = apiResults.map(({ operation, implemented }) => { + if (implemented === null) { + return { raw: operation, status: "unknown" as const, note: "Not in coverage DB — may be a newer API" }; + } + return { raw: operation, status: (implemented === 1 ? "implemented" : "missing") as "implemented" | "missing" }; + }); + + const implemented = results.filter((r) => r.status === "implemented"); + const missing = results.filter((r) => r.status === "missing"); + const unknown = results.filter((r) => r.status === "unknown"); + + const deployable = missing.length === 0; + const opDot = deployable ? "🟢" : "🔴"; + let md = `# Operation Coverage Check\n\n`; + md += `**${operations.length} operations checked** — `; + md += + deployable + ? `${opDot} All required operations are implemented. Template should deploy successfully.` + : `${opDot} ${missing.length} operation(s) missing. Template may fail to deploy.`; + md += "\n\n"; + + if (implemented.length > 0) { + md += `## ✅ Implemented (${implemented.length})\n`; + md += implemented.map((r) => `- \`${r.raw}\``).join("\n") + "\n\n"; + } + + if (missing.length > 0) { + md += `## ❌ Not implemented (${missing.length})\n`; + md += + missing.map((r) => `- \`${r.raw}\``).join("\n") + + "\n\n"; + md += `> These operations are not yet supported by LocalStack. `; + md += `Check https://docs.localstack.cloud for workarounds or open an issue.\n`; + } + + if (unknown.length > 0) { + md += `\n## ⚠️ Unknown (${unknown.length})\n`; + md += + unknown.map((r) => `- \`${r.raw}\` — ${r.note}`).join("\n") + "\n"; + } + + return ResponseBuilder.markdown(md); +} + +// --------------------------------------------------------------------------- +// Action: scan_iac +// --------------------------------------------------------------------------- + +function collectFiles(root: string, exts: string[], maxDepth = 4): string[] { + const results: string[] = []; + function walk(dir: string, depth: number) { + if (depth > maxDepth) return; + for (const entry of readdirSync(dir, { withFileTypes: true })) { + if (entry.name.startsWith(".") || entry.name === "node_modules" || entry.name === ".terraform") continue; + const full = join(dir, entry.name); + if (entry.isDirectory()) { + walk(full, depth + 1); + } else if (exts.includes(extname(entry.name).toLowerCase())) { + results.push(full); + } + } + } + try { + const stat = statSync(root); + if (stat.isFile()) return [root]; + walk(root, 0); + } catch { /* path doesn't exist */ } + return results; +} + +const SERVICE_ALIASES: Record = { s3api: "s3" }; +const SKIP_CLI_OPS = new Set(["configure", "help", "wait"]); + +type IacScanResult = { + framework: string; + counts: Map; + shellOps: Map; + tfContents: Map; // path → content, reused by patchIac +}; + +function extractResourceTypes(path: string): IacScanResult { + const files = collectFiles(path, [".tf", ".yaml", ".yml", ".json", ".ts", ".py", ".sh"]); + + const tfCounts = new Map(); + const cfCounts = new Map(); + const pulumiCounts = new Map(); + const shellOps = new Map(); + const tfContents = new Map(); + + const inc = (m: Map, k: string) => m.set(k, (m.get(k) ?? 0) + 1); + + for (const file of files) { + let content: string; + try { content = readFileSync(file, "utf8"); } catch { continue; } + + const ext = extname(file).toLowerCase(); + + if (ext === ".tf") { + tfContents.set(file, content); + for (const m of content.matchAll(/^resource\s+"(aws_[a-z0-9_]+)"/gm)) { + inc(tfCounts, m[1]); + } + } + + if (ext === ".yaml" || ext === ".yml" || ext === ".json") { + for (const m of content.matchAll(/[Tt]ype["']?\s*[:=]\s*["']?(AWS::[A-Za-z0-9:]+)/g)) { + inc(cfCounts, m[1]); + } + } + + if (ext === ".ts") { + for (const m of content.matchAll(/new\s+aws\.([a-z0-9]+)\.([A-Z][a-zA-Z0-9]+)\s*\(/g)) { + const [, svc, res] = m; + inc(pulumiCounts, `aws:${svc}/${res.charAt(0).toLowerCase() + res.slice(1)}:${res}`); + } + } + + if (ext === ".py") { + for (const m of content.matchAll(/aws\.([a-z0-9]+)\.([A-Z][a-zA-Z0-9]+)\s*\(/g)) { + const [, svc, res] = m; + inc(pulumiCounts, `aws:${svc}/${res.charAt(0).toLowerCase() + res.slice(1)}:${res}`); + } + } + + if (ext === ".sh") { + for (const m of content.matchAll( + /(?:^|[\s;|&(])(awslocal|aws)\s+([a-z][a-z0-9-]*)\s+([a-z][a-z0-9-]+)/gm + )) { + const rawSvc = m[2]; + const rawOp = m[3]; + if (SKIP_CLI_OPS.has(rawOp)) continue; + const svc = SERVICE_ALIASES[rawSvc] ?? rawSvc; + const op = rawOp.split("-").map(w => w.charAt(0).toUpperCase() + w.slice(1)).join(""); + inc(shellOps, `${svc}:${op}`); + } + } + } + + const counts = new Map([...cfCounts, ...tfCounts, ...pulumiCounts]); + const frameworks = [ + cfCounts.size > 0 ? "CloudFormation/CDK" : null, + tfCounts.size > 0 ? "Terraform" : null, + pulumiCounts.size > 0 ? "Pulumi" : null, + ].filter(Boolean).join(", "); + + return { framework: frameworks || "unknown", counts, shellOps, tfContents }; +} + +async function shellOpsSectionMarkdown(ops: Map): Promise { + const { results } = await coverageFetch<{ + results: Array<{ operation: string; implemented: number | null }>; + }>("/operations", [...ops.keys()]); + + const lines = results.map(r => { + if (r.implemented === 1) return `✅ ${r.operation}`; + if (r.implemented === null) return `⚠️ ${r.operation} — not in coverage DB`; + return `❌ ${r.operation}`; + }); + + const blocking = results.filter(r => r.implemented === 0).length; + const verdict = blocking === 0 ? "**Ready**" : `**${blocking} operation(s) not implemented**`; + return `**Shell scripts** — ${verdict}\n\n${lines.join("\n")}`; +} + +async function scanIac(iac_path: string | undefined) { + if (!iac_path) { + return ResponseBuilder.error( + "Missing parameter", + "`iac_path` is required for scan_iac." + ); + } + + const { framework, counts, shellOps } = extractResourceTypes(iac_path); + + if (counts.size === 0 && shellOps.size === 0) { + return ResponseBuilder.error( + "No resource types found", + `No AWS resource types detected in \`${iac_path}\`. ` + + `Supported: Terraform (.tf), CloudFormation/CDK (.yaml/.json), Pulumi (.ts/.py), shell scripts (.sh with aws/awslocal calls).` + ); + } + + const sections: string[] = []; + + if (counts.size > 0) { + sections.push(await resourcesSectionMarkdown([...counts.keys()], counts, framework)); + } + if (shellOps.size > 0) { + sections.push(await shellOpsSectionMarkdown(shellOps)); + } + + return ResponseBuilder.markdown( + sections.length === 1 ? sections[0] : sections.join("\n\n---\n\n") + ); +} + +// --------------------------------------------------------------------------- +// Action: check_resources +// --------------------------------------------------------------------------- + +type ResourceResult = { + resource_type: string; + known: boolean; + blocking: string[]; +}; + +async function fetchResourceResults(resources: string[]): Promise { + const { resources: apiResources } = await coverageFetch<{ + resources: { resource_type: string; known: boolean; operations: Row[] }[]; + }>("/resources", resources); + + return apiResources.map(({ resource_type, known, operations }) => { + if (!known) return { resource_type, known: false, blocking: [] }; + const blocking = operations + .filter((r) => r.implemented === 0) + .map((r) => r.operation as string); + return { resource_type, known: true, blocking }; + }); +} + +async function resourcesSectionMarkdown( + resources: string[], + counts?: Map, + framework?: string +): Promise { + const results = await fetchResourceResults(resources); + + const blocked = results.filter((r) => !r.known || r.blocking.length > 0); + const hasBlockers = blocked.length > 0; + const stackLabel = framework ?? "Service coverage summary"; + + const knownBlockers = blocked.filter((r) => r.known && r.blocking.length > 0); + const unknownBlockers = blocked.filter((r) => !r.known); + let blockerSummary = ""; + if (knownBlockers.length > 0) { + blockerSummary += knownBlockers.map((r) => `${friendlyName(r.resource_type)} requires: ${r.blocking.join(", ")}`).join("; ") + "."; + } + if (unknownBlockers.length > 0) { + const names = unknownBlockers.map((r) => friendlyName(r.resource_type)).join(", "); + blockerSummary += (blockerSummary ? " " : "") + `${names}: not in coverage database.`; + } + const verdict = hasBlockers + ? `**${blocked.length} blocker(s) found.** ${blockerSummary}` + : `**No blockers.**`; + + const lines = results.map((r) => { + const count = counts?.get(r.resource_type) ?? 1; + const label = count > 1 + ? `${count}x ${friendlyName(r.resource_type)}` + : friendlyName(r.resource_type); + const ok = r.known && r.blocking.length === 0; + const detail = !r.known + ? " — not in coverage DB" + : r.blocking.length > 0 + ? ` — missing: ${r.blocking.join(", ")}` + : ""; + return `${ok ? "✅" : "❌"} ${label}${detail}`; + }); + + return `**${stackLabel}** — ${verdict}\n\n${lines.join("\n")}`; +} + +async function checkResources( + resources: string[] | undefined, + counts?: Map, + framework?: string +) { + if (!resources || resources.length === 0) { + return ResponseBuilder.error( + "Missing parameter", + "`resources` is required for check_resources. Provide an array of IaC resource type names." + ); + } + + return ResponseBuilder.markdown(await resourcesSectionMarkdown(resources, counts, framework)); +} + +// --------------------------------------------------------------------------- +// Action: patch_iac +// --------------------------------------------------------------------------- + +const LOCALSTACK_PROVIDER_BLOCK = ` +provider "aws" { + access_key = "test" + secret_key = "test" + region = "us-east-1" + skip_credentials_validation = true + skip_metadata_api_check = true + skip_requesting_account_id = true + + endpoints { + # localstack-patch: auto-generated — remove when deploying to AWS + s3 = "http://localhost:4566" + lambda = "http://localhost:4566" + dynamodb = "http://localhost:4566" + iam = "http://localhost:4566" + sqs = "http://localhost:4566" + sns = "http://localhost:4566" + apigateway = "http://localhost:4566" + cloudwatch = "http://localhost:4566" + secretsmanager = "http://localhost:4566" + ssm = "http://localhost:4566" + sts = "http://localhost:4566" + } +} +`; + +async function patchIac(iac_path: string | undefined) { + if (!iac_path) { + return ResponseBuilder.error( + "Missing parameter", + "`iac_path` is required for patch_iac." + ); + } + + const { framework, counts, tfContents } = extractResourceTypes(iac_path); + + if (!framework.includes("Terraform") || counts.size === 0) { + return ResponseBuilder.error( + "No Terraform resources found", + `No Terraform (.tf) resources detected in \`${iac_path}\`. patch_iac only supports Terraform in v1.` + ); + } + + // Find blockers via REST + const tfResources = [...counts.keys()].filter((rt) => rt.startsWith("aws_")); + const results = await fetchResourceResults(tfResources); + const blockers = results + .filter(({ known, blocking }) => known && blocking.length > 0) + .map(({ resource_type }) => resource_type); + + if (blockers.length === 0) { + return ResponseBuilder.markdown( + `**No blockers found** — no patch needed. Your Terraform project should deploy cleanly on LocalStack.\n\n` + + `You may still want to add the LocalStack provider config if you haven't already.` + ); + } + + // Build the diff using cached file contents from extractResourceTypes + const hunks: string[] = []; + + for (const [file, content] of tfContents) { + const lines = content.split("\n"); + const fileHunks: Array<{ lineNo: number; original: string }> = []; + + for (const blocker of blockers) { + const re = new RegExp(`^(resource\\s+"${blocker}"\\s+"[^"]*"\\s*\\{)`, "m"); + lines.forEach((line, idx) => { + if (re.test(line)) fileHunks.push({ lineNo: idx + 1, original: line }); + }); + } + + if (fileHunks.length === 0) continue; + + for (const h of fileHunks) { + const context = Math.max(0, h.lineNo - 3); + const ctxLines = lines.slice(context, h.lineNo - 1).map(l => ` ${l}`).join("\n"); + hunks.push( + `--- a/${file}\n` + + `+++ b/${file}\n` + + `@@ -${h.lineNo},1 +${h.lineNo},2 @@\n` + + (ctxLines ? ctxLines + "\n" : "") + + ` ${h.original}\n` + + `+ count = 0 # localstack-patch: ${h.original.match(/"(aws_[a-z0-9_]+)"/)?.[1] ?? "resource"} not supported on LocalStack` + ); + } + } + + // Provider block — use cached contents, no re-read needed + let providerFile = [...tfContents.entries()].find(([, c]) => c.includes(`provider "aws"`))?.[0] + ?? [...tfContents.keys()].find(f => f.endsWith("main.tf")) + ?? [...tfContents.keys()][0]; + + let providerHunk = ""; + if (providerFile) { + const content = tfContents.get(providerFile) ?? ""; + if (!content.includes("localstack-patch")) { + const lines = content.split("\n"); + providerHunk = + `--- a/${providerFile}\n` + + `+++ b/${providerFile}\n` + + `@@ -${lines.length + 1},0 +${lines.length + 1},${LOCALSTACK_PROVIDER_BLOCK.split("\n").length} @@\n` + + LOCALSTACK_PROVIDER_BLOCK.split("\n").map(l => `+${l}`).join("\n"); + } + } + + const allHunks = [...hunks, providerHunk].filter(Boolean).join("\n\n"); + const blockerList = blockers.map(b => `- \`${b}\``).join("\n"); + + let md = `# LocalStack Patch\n\n`; + md += `**${blockers.length} blocker(s) gated** with \`count = 0 # localstack-patch\`:\n${blockerList}\n\n`; + md += `All patches are marked \`# localstack-patch\` — search for that string to remove them before deploying to AWS.\n\n`; + md += `\`\`\`diff\n${allHunks}\n\`\`\`\n\n`; + md += `> Apply this patch? (yes/no)`; + + return ResponseBuilder.markdown(md); +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + + +function pct(n: number, total: number): number { + return total === 0 ? 0 : Math.round((100 * n) / total); +} + +function coverageEmoji(p: number): string { + if (p >= 80) return "🟢"; + if (p >= 50) return "🟡"; + return "🔴"; +} diff --git a/src/tools/localstack-deployer.ts b/src/tools/localstack-deployer.ts index e8e8a24..b8eb5d8 100644 --- a/src/tools/localstack-deployer.ts +++ b/src/tools/localstack-deployer.ts @@ -79,7 +79,7 @@ export const schema = { // Define tool metadata export const metadata: ToolMetadata = { name: "localstack-deployer", - description: "Deploys or destroys AWS infrastructure on LocalStack using CDK, Terraform, or SAM.", + description: "Deploys or destroys AWS infrastructure on a RUNNING LocalStack instance using CDK, Terraform (via tflocal), or SAM. Runs real commands — requires LocalStack to be running. Do NOT use this to check whether IaC will work on LocalStack; use localstack-preflight for static compatibility analysis instead.", annotations: { title: "LocalStack Deployer", readOnlyHint: false, diff --git a/tests/mcp/direct.spec.mjs b/tests/mcp/direct.spec.mjs index c7e7dcf..7742a70 100644 --- a/tests/mcp/direct.spec.mjs +++ b/tests/mcp/direct.spec.mjs @@ -19,6 +19,7 @@ const EXPECTED_TOOLS = [ "localstack-aws-replicator", "localstack-docs", "localstack-app-inspector", + "localstack-preflight", ]; const EXPECTED_PROMPT = "infrastructure-tester"; @@ -60,6 +61,17 @@ test("smoke tests the infrastructure tester prompt", async ({ mcp }) => { expect(result.messages[0].content.text).toContain("`./infra`"); }); +test("preflight tool lists AWS services with coverage percentages", async ({ mcp }) => { + test.skip(!process.env.LOCALSTACK_COVERAGE_URL, "LOCALSTACK_COVERAGE_URL not set — coverage extension not available"); + + const result = await mcp.callTool("localstack-preflight", { + action: "list_services", + }); + + expect(result).not.toBeToolError(); + expect(result).toContainToolText("s3"); +}); + test("docs tool returns useful documentation snippets", async ({ mcp }) => { requireEnv("LOCALSTACK_AUTH_TOKEN"); diff --git a/tests/unit/localstack-coverage.test.ts b/tests/unit/localstack-coverage.test.ts new file mode 100644 index 0000000..4e805bc --- /dev/null +++ b/tests/unit/localstack-coverage.test.ts @@ -0,0 +1,33 @@ +import { friendlyName, FRIENDLY_NAMES } from "../../src/tools/localstack-coverage"; + +describe("friendlyName", () => { + it("maps known Terraform types", () => { + expect(friendlyName("aws_sqs_queue")).toBe("SQS queue"); + expect(friendlyName("aws_lambda_function")).toBe("Lambda function"); + expect(friendlyName("aws_dynamodb_table")).toBe("DynamoDB table"); + expect(friendlyName("aws_s3_bucket")).toBe("S3 bucket"); + expect(friendlyName("aws_iam_role")).toBe("IAM role"); + }); + + it("maps known CloudFormation types", () => { + expect(friendlyName("AWS::SQS::Queue")).toBe("SQS queue"); + expect(friendlyName("AWS::Lambda::Function")).toBe("Lambda function"); + expect(friendlyName("AWS::DynamoDB::Table")).toBe("DynamoDB table"); + expect(friendlyName("AWS::S3::Bucket")).toBe("S3 bucket"); + expect(friendlyName("AWS::IAM::Role")).toBe("IAM role"); + }); + + it("falls back to raw type when no mapping exists", () => { + expect(friendlyName("aws_unknown_widget")).toBe("aws_unknown_widget"); + expect(friendlyName("AWS::Unknown::Thing")).toBe("AWS::Unknown::Thing"); + }); + + it("all FRIENDLY_NAMES values are non-empty strings", () => { + for (const [type, name] of Object.entries(FRIENDLY_NAMES)) { + expect(typeof name).toBe("string"); + expect(name.length).toBeGreaterThan(0); + expect(name).not.toContain("aws_"); + expect(name).not.toMatch(/^AWS::/); + } + }); +});