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::/);
+ }
+ });
+});