diff --git a/.dockerignore b/.dockerignore
new file mode 100644
index 0000000..0936eb9
--- /dev/null
+++ b/.dockerignore
@@ -0,0 +1,30 @@
+# Build artifacts (rebuilt inside the image)
+node_modules
+**/node_modules
+dist
+.xmcp
+xmcp-env.d.ts
+**/cdk.out
+
+# VCS / CI
+.git
+.github
+
+# Test output
+test-results
+.mcp-test-results
+data
+**/.terraform
+**/.terraform.lock.hcl
+
+# Secrets / local registry tokens (never bake into the image)
+.mcpregistry*
+.env
+.env.*
+
+# Editor / OS noise
+.DS_Store
+*.log
+
+# Docs/meta not needed at runtime
+CLAUDE.md
diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml
new file mode 100644
index 0000000..3558374
--- /dev/null
+++ b/.github/workflows/docker.yml
@@ -0,0 +1,75 @@
+name: Docker Image (Build & Test)
+
+on:
+ pull_request:
+ branches: [main]
+ paths:
+ - "Dockerfile"
+ - ".dockerignore"
+ - "src/**"
+ - "package.json"
+ - "yarn.lock"
+ - "tests/docker/**"
+ - ".github/workflows/docker.yml"
+ push:
+ branches: [main]
+ # release:
+ # types: [published]
+ # workflow_dispatch:
+
+env:
+ IMAGE_NAME: localstack/localstack-mcp-server
+
+jobs:
+ smoke:
+ runs-on: ubuntu-latest
+ env:
+ CI_LOCALSTACK_AUTH_TOKEN: ${{ secrets.LOCALSTACK_AUTH_TOKEN }}
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Set up Docker Buildx
+ uses: docker/setup-buildx-action@v3
+
+ - name: Build image (single-arch, load locally)
+ uses: docker/build-push-action@v6
+ with:
+ context: .
+ load: true
+ tags: ${{ env.IMAGE_NAME }}:ci
+ cache-from: type=gha
+ cache-to: type=gha,mode=max
+
+ - name: Use Node.js 22
+ uses: actions/setup-node@v4
+ with:
+ node-version: 22.x
+
+ - name: Smoke test MCP startup
+ env:
+ HARNESS_SKIP: prompt,docs,status,start,aws,logs,state,cloudpods,appinspector,chaos,iam,replicator,ephemeral,deploy,deploy-cdk,extensions,restart,stop,snowflake
+ run: |
+ node tests/docker/validate-image.mjs -- \
+ docker run -i --rm \
+ ${{ env.IMAGE_NAME }}:ci
+
+ - name: Integration test Docker runtime
+ env:
+ LOCALSTACK_AUTH_TOKEN: ${{ secrets.LOCALSTACK_AUTH_TOKEN }}
+ HARNESS_TOKEN_REAL: "1"
+ HARNESS_SKIP: cloudpods,ephemeral,replicator,chaos
+ run: |
+ npm ci --prefix data/sample-cdk
+ mkdir -p "$HOME/.localstack-mcp"
+ node tests/docker/validate-image.mjs -- \
+ docker run -i --rm \
+ -v /var/run/docker.sock:/var/run/docker.sock \
+ -v "$HOME/.localstack-mcp:$HOME/.localstack-mcp" \
+ -e XDG_CACHE_HOME="$HOME/.localstack-mcp" \
+ --add-host host.docker.internal:host-gateway \
+ --add-host s3.host.docker.internal:host-gateway \
+ --add-host snowflake.localhost.localstack.cloud:host-gateway \
+ -e LOCALSTACK_AUTH_TOKEN \
+ -e LOCALSTACK_HOSTNAME=host.docker.internal \
+ -v "$PWD/data:/work/data" \
+ ${{ env.IMAGE_NAME }}:ci
diff --git a/Dockerfile b/Dockerfile
new file mode 100644
index 0000000..0037552
--- /dev/null
+++ b/Dockerfile
@@ -0,0 +1,94 @@
+# syntax=docker/dockerfile:1
+
+FROM node:22-bookworm-slim AS builder
+WORKDIR /app
+
+COPY package.json yarn.lock ./
+RUN yarn install --frozen-lockfile
+COPY . .
+RUN yarn build
+
+FROM python:3.12-slim-bookworm AS runtime
+ENV DEBIAN_FRONTEND=noninteractive \
+ PYTHONDONTWRITEBYTECODE=1
+
+RUN set -eux; \
+ apt-get update; \
+ apt-get install -y --no-install-recommends ca-certificates curl gnupg unzip git; \
+ install -m 0755 -d /usr/share/keyrings; \
+ curl -fsSL https://deb.nodesource.com/setup_22.x | bash -; \
+ curl -fsSL https://apt.releases.hashicorp.com/gpg \
+ | gpg --dearmor -o /usr/share/keyrings/hashicorp-archive-keyring.gpg; \
+ chmod a+r /usr/share/keyrings/hashicorp-archive-keyring.gpg; \
+ echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/hashicorp-archive-keyring.gpg] https://apt.releases.hashicorp.com bookworm main" \
+ > /etc/apt/sources.list.d/hashicorp.list; \
+ curl -fsSL https://download.docker.com/linux/debian/gpg \
+ | gpg --dearmor -o /usr/share/keyrings/docker-archive-keyring.gpg; \
+ chmod a+r /usr/share/keyrings/docker-archive-keyring.gpg; \
+ echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/docker-archive-keyring.gpg] https://download.docker.com/linux/debian bookworm stable" \
+ > /etc/apt/sources.list.d/docker.list; \
+ apt-get update; \
+ apt-get install -y --no-install-recommends nodejs terraform docker-ce-cli; \
+ apt-get clean; \
+ rm -rf /var/lib/apt/lists/*
+
+RUN pip install --no-cache-dir --upgrade pip \
+ && pip install --no-cache-dir --no-compile \
+ localstack \
+ awscli \
+ awscli-local \
+ terraform-local \
+ aws-sam-cli \
+ aws-sam-cli-local \
+ snowflake-cli \
+ && find /usr/local/lib/python3.12/site-packages \
+ \( -type d \( -name __pycache__ -o -name tests -o -name test \) -o -type f \( -name '*.pyc' -o -name '*.pyo' \) \) \
+ -prune -exec rm -rf '{}' +
+
+RUN npm install -g aws-cdk@2.1114.0 aws-cdk-local \
+ && npm cache clean --force
+
+RUN node <<'NODE'
+const fs = require("fs");
+const file = "/usr/lib/node_modules/aws-cdk/lib/index.js";
+const source = fs.readFileSync(file, "utf8");
+const target = ` s3() {\n const client = new import_client_s33.S3Client(this.config);`;
+const replacement = ` s3() {\n if (/^(1|true|yes)$/i.test(process.env.AWS_S3_FORCE_PATH_STYLE || "")) {\n this.config.forcePathStyle = true;\n }\n const client = new import_client_s33.S3Client(this.config);`;
+
+if (!source.includes(replacement)) {
+ if (!source.includes(target)) {
+ throw new Error("Could not patch aws-cdk S3 forcePathStyle hook");
+ }
+ fs.writeFileSync(file, source.replace(target, replacement));
+}
+NODE
+
+WORKDIR /app
+RUN mkdir -p /usr/lib/localstack /tmp/dockerode-deps \
+ && npm install --prefix /tmp/dockerode-deps --omit=dev --ignore-scripts --no-audit --no-fund dockerode@4.0.7 \
+ && mkdir -p /app/node_modules \
+ && cp -R /tmp/dockerode-deps/node_modules/. /app/node_modules/ \
+ && rm -rf /tmp/dockerode-deps \
+ && npm cache clean --force
+COPY --from=builder /app/dist ./dist
+COPY --from=builder /app/package.json ./package.json
+
+RUN set -eux; \
+ localstack --version; \
+ aws --version; \
+ awslocal --version; \
+ terraform version; \
+ tflocal --version; \
+ sam --version; \
+ command -v samlocal; \
+ cdklocal --version; \
+ snow --version; \
+ docker --version; \
+ node -e "require('dockerode'); console.log('dockerode ok')"
+
+LABEL org.opencontainers.image.title="LocalStack MCP Server" \
+ org.opencontainers.image.description="Self-contained MCP server for managing LocalStack (CLI, CDK, Terraform, SAM, awslocal baked in)" \
+ org.opencontainers.image.source="https://github.com/localstack/localstack-mcp-server" \
+ org.opencontainers.image.licenses="Apache-2.0"
+
+ENTRYPOINT ["node", "dist/stdio.js"]
diff --git a/README.md b/README.md
index e2a7e0f..487bcda 100644
--- a/README.md
+++ b/README.md
@@ -37,7 +37,7 @@ This server provides your AI with dedicated tools for managing your LocalStack e
| [`localstack-chaos-injector`](./src/tools/localstack-chaos-injector.ts) | Injects and manages chaos experiment faults for system resilience testing | - Inject, add, remove, and clear service fault rules
- Configure network latency effects
- Comprehensive fault targeting by service, region, and operation
- Built-in workflow guidance for chaos experiments
- Requires a valid LocalStack Auth Token |
| [`localstack-cloud-pods`](./src/tools/localstack-cloud-pods.ts) | Manages remote LocalStack Cloud Pods for development workflows | - Save current state as a Cloud Pod
- Load previously saved Cloud Pods instantly
- Delete Cloud Pods from remote cloud-backed storage
- Use this for managed remote state snapshots, not local export/import files
- Requires a valid LocalStack Auth Token |
| [`localstack-state-management`](./src/tools/localstack-state-management.ts) | Manages local file-based LocalStack state export/import workflows | - Export LocalStack state to a local file on disk through the LocalStack State REST API
- Import LocalStack state from a local file
- Inspect current LocalStack state as JSON metamodel data
- Reset all state or only selected services
- Supports service-level granularity for export, reset, and inspect
- Use this for local disk workflows; use Cloud Pods for remote cloud-backed snapshots
- Requires a valid LocalStack Auth Token |
-| [`localstack-extensions`](./src/tools/localstack-extensions.ts) | Installs, uninstalls, lists, and discovers LocalStack Extensions | - Manage installed extensions via CLI actions (`list`, `install`, `uninstall`)
- Browse the LocalStack Extensions marketplace (`available`)
- Requires a valid LocalStack Auth Token support |
+| [`localstack-extensions`](./src/tools/localstack-extensions.ts) | Installs, uninstalls, lists, and discovers LocalStack Extensions | - Manage installed extensions via CLI actions (`list`, `install`, `uninstall`)
- Browse the LocalStack Extensions marketplace (`available`)
- Requires a valid LocalStack Auth Token |
| [`localstack-ephemeral-instances`](./src/tools/localstack-ephemeral-instances.ts) | Manages cloud-hosted LocalStack Ephemeral Instances | - Create temporary cloud-hosted LocalStack instances and get an endpoint URL
- List available ephemeral instances, fetch logs, and delete instances
- Supports lifetime, extension preload, Cloud Pod preload, and custom env vars on create
- Requires a valid LocalStack Auth Token and LocalStack CLI |
| [`localstack-aws-client`](./src/tools/localstack-aws-client.ts) | Runs AWS CLI commands inside the LocalStack for AWS container | - Executes commands via `awslocal` inside the running container
- Sanitizes commands to block shell chaining
- Auto-detects LocalStack coverage errors and links to docs |
| [`localstack-aws-replicator`](./src/tools/localstack-aws-replicator.ts) | Replicates external AWS resources into a running LocalStack instance | - Start single-resource replication jobs with a resource type and identifier or ARN
- Start batch replication jobs, such as SSM parameters under a path prefix
- Poll job status by job ID and list existing jobs
- List resource types supported by the running Replicator extension
- Reads source AWS credentials from the MCP server environment and supports optional target account or region overrides |
@@ -63,13 +63,14 @@ For other MCP Clients, refer to the [configuration guide](#configuration).
### Prerequisites
- [LocalStack CLI](https://docs.localstack.cloud/getting-started/installation/#localstack-cli) and Docker installed in your system path
-- [`cdklocal`](https://github.com/localstack/aws-cdk-local), [`tflocal`](https://github.com/localstack/terraform-local), or [`samlocal`](https://github.com/localstack/aws-sam-cli-local) installed in your system path for running infrastructure deployment tooling
+- [`cdklocal`](https://github.com/localstack/aws-cdk-local), [`tflocal`](https://github.com/localstack/terraform-local), or [`samlocal`](https://github.com/localstack/aws-sam-cli-local) installed in your system path if you want to deploy CDK, Terraform, or SAM projects
+- Snowflake CLI (`snow`) installed in your system path if you want to use the Snowflake tool
- A [valid LocalStack Auth Token](https://docs.localstack.cloud/aws/getting-started/auth-token/) configured as `LOCALSTACK_AUTH_TOKEN` (**required for all MCP tools**)
- [Node.js v22.x](https://nodejs.org/en/download/) or higher installed in your system path
### Configuration
-Add the following to your MCP client's configuration file (e.g., `~/.cursor/mcp.json`). This configuration uses `npx` to run the server, which will automatically download & install the package if not already present:
+Add the following to your MCP client's configuration file (e.g., `~/.cursor/mcp.json`). This configuration uses `npx` to run the server, which will automatically download and install the package if needed. LocalStack and any deployment CLIs used by tools run from your host PATH.
```json
{
@@ -103,6 +104,35 @@ If you installed from source, change `command` and `args` to point to your local
}
```
+### Run with Docker
+
+The `localstack/localstack-mcp-server` Docker image bundles the LocalStack CLI, `awslocal`, Terraform/`tflocal`, CDK/`cdklocal`, SAM/`samlocal`, Snowflake CLI, and Docker CLI. The only required host dependency is Docker. The container uses the mounted Docker socket to run LocalStack as a sibling container on the host.
+
+```json
+{
+ "mcpServers": {
+ "localstack-mcp-server": {
+ "command": "docker",
+ "args": [
+ "run", "-i", "--rm",
+ "-v", "/var/run/docker.sock:/var/run/docker.sock",
+ "-v", "/Users/you/.localstack-mcp:/Users/you/.localstack-mcp",
+ "-e", "XDG_CACHE_HOME=/Users/you/.localstack-mcp",
+ "--add-host", "host.docker.internal:host-gateway",
+ "--add-host", "s3.host.docker.internal:host-gateway",
+ "--add-host", "snowflake.localhost.localstack.cloud:host-gateway",
+ "-e", "LOCALSTACK_AUTH_TOKEN",
+ "-e", "LOCALSTACK_HOSTNAME=host.docker.internal",
+ "localstack/localstack-mcp-server:latest"
+ ],
+ "env": { "LOCALSTACK_AUTH_TOKEN": "" }
+ }
+ }
+}
+```
+
+See **[docs/DOCKER.md](./docs/DOCKER.md)** for the run command, MCP client config, IaC project mounts, CDK notes, and troubleshooting.
+
## LocalStack Configuration
| Variable Name | Description | Default Value |
diff --git a/data/sample-cdk/app.js b/data/sample-cdk/app.js
new file mode 100644
index 0000000..449eec8
--- /dev/null
+++ b/data/sample-cdk/app.js
@@ -0,0 +1,26 @@
+const cdk = require("aws-cdk-lib");
+const s3 = require("aws-cdk-lib/aws-s3");
+
+class SampleCdkStack extends cdk.Stack {
+ constructor(scope, id, props) {
+ super(scope, id, props);
+
+ const bucket = new s3.Bucket(this, "SampleBucket", {
+ bucketName: "mcp-cdk-sample-bucket",
+ removalPolicy: cdk.RemovalPolicy.DESTROY,
+ });
+
+ new cdk.CfnOutput(this, "BucketName", {
+ value: bucket.bucketName,
+ });
+ }
+}
+
+const app = new cdk.App();
+new SampleCdkStack(app, "McpSampleCdkStack", {
+ env: {
+ account: process.env.CDK_DEFAULT_ACCOUNT || "000000000000",
+ region: process.env.CDK_DEFAULT_REGION || process.env.AWS_REGION || "us-east-1",
+ },
+ synthesizer: new cdk.BootstraplessSynthesizer(),
+});
diff --git a/data/sample-cdk/cdk.json b/data/sample-cdk/cdk.json
new file mode 100644
index 0000000..b291e31
--- /dev/null
+++ b/data/sample-cdk/cdk.json
@@ -0,0 +1,6 @@
+{
+ "app": "node app.js",
+ "context": {
+ "@aws-cdk/core:newStyleStackSynthesis": true
+ }
+}
diff --git a/data/sample-cdk/package-lock.json b/data/sample-cdk/package-lock.json
new file mode 100644
index 0000000..290456a
--- /dev/null
+++ b/data/sample-cdk/package-lock.json
@@ -0,0 +1,453 @@
+{
+ "name": "localstack-mcp-sample-cdk",
+ "version": "0.1.0",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "localstack-mcp-sample-cdk",
+ "version": "0.1.0",
+ "dependencies": {
+ "aws-cdk-lib": "^2.257.0",
+ "constructs": "^10.5.0"
+ }
+ },
+ "node_modules/@aws-cdk/asset-awscli-v1": {
+ "version": "2.2.273",
+ "resolved": "https://registry.npmjs.org/@aws-cdk/asset-awscli-v1/-/asset-awscli-v1-2.2.273.tgz",
+ "integrity": "sha512-X57HYUtHt9BQrlrzUNcMyRsDUCoakYNnY6qh5lNwRCHPtQoTfXmuISkfLk0AjLkcbS5lw1LLTQFiQhTDXfiTvg==",
+ "license": "Apache-2.0"
+ },
+ "node_modules/@aws-cdk/asset-node-proxy-agent-v6": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/@aws-cdk/asset-node-proxy-agent-v6/-/asset-node-proxy-agent-v6-2.1.2.tgz",
+ "integrity": "sha512-pDiuqH+qY3zM9lhhLjbKJ1tnKOHzQ2V4Wr/3qsxyKeKAkuPMI/BVGvZG1PbrikUw949cGVTfVEt4ETKKYnrj0Q==",
+ "license": "Apache-2.0"
+ },
+ "node_modules/@aws-cdk/cloud-assembly-schema": {
+ "version": "53.28.0",
+ "resolved": "https://registry.npmjs.org/@aws-cdk/cloud-assembly-schema/-/cloud-assembly-schema-53.28.0.tgz",
+ "integrity": "sha512-pZS+9bLGv2tCqcgxfA0WD3XjcqT3yE4ICvKeJEicw6aTdCxBl8FQ/AUsorY/6f2JrMS3kUQgvhXxA30MWcji0A==",
+ "bundleDependencies": [
+ "jsonschema",
+ "semver"
+ ],
+ "license": "Apache-2.0",
+ "peer": true,
+ "dependencies": {
+ "jsonschema": "^1.5.0",
+ "semver": "^7.8.0"
+ },
+ "engines": {
+ "node": ">= 18.0.0"
+ }
+ },
+ "node_modules/@aws-cdk/cloud-assembly-schema/node_modules/jsonschema": {
+ "version": "1.5.0",
+ "inBundle": true,
+ "license": "MIT",
+ "engines": {
+ "node": "*"
+ }
+ },
+ "node_modules/@aws-cdk/cloud-assembly-schema/node_modules/semver": {
+ "version": "7.8.0",
+ "inBundle": true,
+ "license": "ISC",
+ "bin": {
+ "semver": "bin/semver.js"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/aws-cdk-lib": {
+ "version": "2.257.0",
+ "resolved": "https://registry.npmjs.org/aws-cdk-lib/-/aws-cdk-lib-2.257.0.tgz",
+ "integrity": "sha512-GoHfWklrBJcMwLtDlY64pvaT7cD2KyDXC8sik89DR6jHl6nQsBtYTKSJCM+C/k4jgXaecbv8myNX75FySejq0A==",
+ "bundleDependencies": [
+ "@balena/dockerignore",
+ "@aws-cdk/cloud-assembly-api",
+ "case",
+ "fs-extra",
+ "ignore",
+ "jsonschema",
+ "minimatch",
+ "punycode",
+ "semver",
+ "table",
+ "yaml",
+ "mime-types"
+ ],
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@aws-cdk/asset-awscli-v1": "2.2.273",
+ "@aws-cdk/asset-node-proxy-agent-v6": "^2.1.1",
+ "@aws-cdk/cloud-assembly-api": "^2.2.4",
+ "@aws-cdk/cloud-assembly-schema": "^53.25.0",
+ "@balena/dockerignore": "^1.0.2",
+ "case": "1.6.3",
+ "fs-extra": "^11.3.3",
+ "ignore": "^5.3.2",
+ "jsonschema": "^1.5.0",
+ "mime-types": "^2.1.35",
+ "minimatch": "^10.2.3",
+ "punycode": "^2.3.1",
+ "semver": "^7.7.4",
+ "table": "^6.9.0",
+ "yaml": "1.10.3"
+ },
+ "engines": {
+ "node": ">= 20.0.0"
+ },
+ "peerDependencies": {
+ "constructs": "^10.5.0"
+ }
+ },
+ "node_modules/aws-cdk-lib/node_modules/@aws-cdk/cloud-assembly-api": {
+ "version": "2.2.4",
+ "inBundle": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "jsonschema": "^1.5.0",
+ "semver": "^7.8.0"
+ },
+ "engines": {
+ "node": ">= 18.0.0"
+ },
+ "peerDependencies": {
+ "@aws-cdk/cloud-assembly-schema": ">=53.25.0"
+ }
+ },
+ "node_modules/aws-cdk-lib/node_modules/@aws-cdk/cloud-assembly-api/node_modules/semver": {
+ "version": "7.8.0",
+ "inBundle": true,
+ "license": "ISC",
+ "bin": {
+ "semver": "bin/semver.js"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/aws-cdk-lib/node_modules/@balena/dockerignore": {
+ "version": "1.0.2",
+ "inBundle": true,
+ "license": "Apache-2.0"
+ },
+ "node_modules/aws-cdk-lib/node_modules/ajv": {
+ "version": "8.18.0",
+ "inBundle": true,
+ "license": "MIT",
+ "dependencies": {
+ "fast-deep-equal": "^3.1.3",
+ "fast-uri": "^3.0.1",
+ "json-schema-traverse": "^1.0.0",
+ "require-from-string": "^2.0.2"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/epoberezkin"
+ }
+ },
+ "node_modules/aws-cdk-lib/node_modules/ansi-regex": {
+ "version": "5.0.1",
+ "inBundle": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/aws-cdk-lib/node_modules/ansi-styles": {
+ "version": "4.3.0",
+ "inBundle": true,
+ "license": "MIT",
+ "dependencies": {
+ "color-convert": "^2.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/ansi-styles?sponsor=1"
+ }
+ },
+ "node_modules/aws-cdk-lib/node_modules/astral-regex": {
+ "version": "2.0.0",
+ "inBundle": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/aws-cdk-lib/node_modules/balanced-match": {
+ "version": "4.0.4",
+ "inBundle": true,
+ "license": "MIT",
+ "engines": {
+ "node": "18 || 20 || >=22"
+ }
+ },
+ "node_modules/aws-cdk-lib/node_modules/brace-expansion": {
+ "version": "5.0.5",
+ "inBundle": true,
+ "license": "MIT",
+ "dependencies": {
+ "balanced-match": "^4.0.2"
+ },
+ "engines": {
+ "node": "18 || 20 || >=22"
+ }
+ },
+ "node_modules/aws-cdk-lib/node_modules/case": {
+ "version": "1.6.3",
+ "inBundle": true,
+ "license": "(MIT OR GPL-3.0-or-later)",
+ "engines": {
+ "node": ">= 0.8.0"
+ }
+ },
+ "node_modules/aws-cdk-lib/node_modules/color-convert": {
+ "version": "2.0.1",
+ "inBundle": true,
+ "license": "MIT",
+ "dependencies": {
+ "color-name": "~1.1.4"
+ },
+ "engines": {
+ "node": ">=7.0.0"
+ }
+ },
+ "node_modules/aws-cdk-lib/node_modules/color-name": {
+ "version": "1.1.4",
+ "inBundle": true,
+ "license": "MIT"
+ },
+ "node_modules/aws-cdk-lib/node_modules/emoji-regex": {
+ "version": "8.0.0",
+ "inBundle": true,
+ "license": "MIT"
+ },
+ "node_modules/aws-cdk-lib/node_modules/fast-deep-equal": {
+ "version": "3.1.3",
+ "inBundle": true,
+ "license": "MIT"
+ },
+ "node_modules/aws-cdk-lib/node_modules/fast-uri": {
+ "version": "3.1.2",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/fastify"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/fastify"
+ }
+ ],
+ "inBundle": true,
+ "license": "BSD-3-Clause"
+ },
+ "node_modules/aws-cdk-lib/node_modules/fs-extra": {
+ "version": "11.3.3",
+ "inBundle": true,
+ "license": "MIT",
+ "dependencies": {
+ "graceful-fs": "^4.2.0",
+ "jsonfile": "^6.0.1",
+ "universalify": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=14.14"
+ }
+ },
+ "node_modules/aws-cdk-lib/node_modules/graceful-fs": {
+ "version": "4.2.11",
+ "inBundle": true,
+ "license": "ISC"
+ },
+ "node_modules/aws-cdk-lib/node_modules/ignore": {
+ "version": "5.3.2",
+ "inBundle": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 4"
+ }
+ },
+ "node_modules/aws-cdk-lib/node_modules/is-fullwidth-code-point": {
+ "version": "3.0.0",
+ "inBundle": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/aws-cdk-lib/node_modules/json-schema-traverse": {
+ "version": "1.0.0",
+ "inBundle": true,
+ "license": "MIT"
+ },
+ "node_modules/aws-cdk-lib/node_modules/jsonfile": {
+ "version": "6.2.0",
+ "inBundle": true,
+ "license": "MIT",
+ "dependencies": {
+ "universalify": "^2.0.0"
+ },
+ "optionalDependencies": {
+ "graceful-fs": "^4.1.6"
+ }
+ },
+ "node_modules/aws-cdk-lib/node_modules/jsonschema": {
+ "version": "1.5.0",
+ "inBundle": true,
+ "license": "MIT",
+ "engines": {
+ "node": "*"
+ }
+ },
+ "node_modules/aws-cdk-lib/node_modules/lodash.truncate": {
+ "version": "4.4.2",
+ "inBundle": true,
+ "license": "MIT"
+ },
+ "node_modules/aws-cdk-lib/node_modules/mime-db": {
+ "version": "1.52.0",
+ "inBundle": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/aws-cdk-lib/node_modules/mime-types": {
+ "version": "2.1.35",
+ "inBundle": true,
+ "license": "MIT",
+ "dependencies": {
+ "mime-db": "1.52.0"
+ },
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/aws-cdk-lib/node_modules/minimatch": {
+ "version": "10.2.5",
+ "inBundle": true,
+ "license": "BlueOak-1.0.0",
+ "dependencies": {
+ "brace-expansion": "^5.0.5"
+ },
+ "engines": {
+ "node": "18 || 20 || >=22"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
+ },
+ "node_modules/aws-cdk-lib/node_modules/punycode": {
+ "version": "2.3.1",
+ "inBundle": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/aws-cdk-lib/node_modules/require-from-string": {
+ "version": "2.0.2",
+ "inBundle": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/aws-cdk-lib/node_modules/semver": {
+ "version": "7.7.4",
+ "inBundle": true,
+ "license": "ISC",
+ "bin": {
+ "semver": "bin/semver.js"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/aws-cdk-lib/node_modules/slice-ansi": {
+ "version": "4.0.0",
+ "inBundle": true,
+ "license": "MIT",
+ "dependencies": {
+ "ansi-styles": "^4.0.0",
+ "astral-regex": "^2.0.0",
+ "is-fullwidth-code-point": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/slice-ansi?sponsor=1"
+ }
+ },
+ "node_modules/aws-cdk-lib/node_modules/string-width": {
+ "version": "4.2.3",
+ "inBundle": true,
+ "license": "MIT",
+ "dependencies": {
+ "emoji-regex": "^8.0.0",
+ "is-fullwidth-code-point": "^3.0.0",
+ "strip-ansi": "^6.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/aws-cdk-lib/node_modules/strip-ansi": {
+ "version": "6.0.1",
+ "inBundle": true,
+ "license": "MIT",
+ "dependencies": {
+ "ansi-regex": "^5.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/aws-cdk-lib/node_modules/table": {
+ "version": "6.9.0",
+ "inBundle": true,
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "ajv": "^8.0.1",
+ "lodash.truncate": "^4.4.2",
+ "slice-ansi": "^4.0.0",
+ "string-width": "^4.2.3",
+ "strip-ansi": "^6.0.1"
+ },
+ "engines": {
+ "node": ">=10.0.0"
+ }
+ },
+ "node_modules/aws-cdk-lib/node_modules/universalify": {
+ "version": "2.0.1",
+ "inBundle": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 10.0.0"
+ }
+ },
+ "node_modules/aws-cdk-lib/node_modules/yaml": {
+ "version": "1.10.3",
+ "inBundle": true,
+ "license": "ISC",
+ "engines": {
+ "node": ">= 6"
+ }
+ },
+ "node_modules/constructs": {
+ "version": "10.5.0",
+ "resolved": "https://registry.npmjs.org/constructs/-/constructs-10.5.0.tgz",
+ "integrity": "sha512-zWjwqIgk4nAWmQGrPnDWv+M2Yly6m7ROyqmmQVLgJiHnWP762It22uWFaF2Pu/sSx0u8WsoUcvt0PZ4DQIQYwQ==",
+ "license": "Apache-2.0",
+ "peer": true
+ }
+ }
+}
diff --git a/data/sample-cdk/package.json b/data/sample-cdk/package.json
new file mode 100644
index 0000000..44b0ce4
--- /dev/null
+++ b/data/sample-cdk/package.json
@@ -0,0 +1,12 @@
+{
+ "name": "localstack-mcp-sample-cdk",
+ "version": "0.1.0",
+ "private": true,
+ "scripts": {
+ "cdk": "cdk"
+ },
+ "dependencies": {
+ "aws-cdk-lib": "^2.257.0",
+ "constructs": "^10.5.0"
+ }
+}
diff --git a/data/sample-sql/snowflake_test.sql b/data/sample-sql/snowflake_test.sql
index 79caaa8..d0c5806 100644
--- a/data/sample-sql/snowflake_test.sql
+++ b/data/sample-sql/snowflake_test.sql
@@ -1,3 +1,6 @@
+CREATE DATABASE IF NOT EXISTS demo_db;
+CREATE SCHEMA IF NOT EXISTS demo_db.public;
+
DROP TABLE IF EXISTS demo_db.public.customers;
CREATE TABLE demo_db.public.customers (
diff --git a/docs/DOCKER.md b/docs/DOCKER.md
new file mode 100644
index 0000000..d0ff98a
--- /dev/null
+++ b/docs/DOCKER.md
@@ -0,0 +1,156 @@
+# Running the LocalStack MCP Server in Docker
+
+The published image bundles everything the server shells out to — the LocalStack
+CLI, `awslocal`, Terraform + `tflocal`, AWS CDK + `cdklocal`, AWS SAM + `samlocal`,
+the Snowflake `snow` CLI, and the Docker CLI — so the **only dependency on your
+machine is Docker itself**.
+
+The image is multi-arch (`linux/amd64` and `linux/arm64`).
+
+## How it works (Docker-out-of-Docker)
+
+The container talks to your **host Docker daemon** through the bind-mounted
+`/var/run/docker.sock`. When you ask the server to start LocalStack, `localstack
+start` launches a **sibling** `localstack-main` container on the host (not nested
+inside the MCP container). The MCP server and the IaC CLIs reach that sibling over
+the host gateway.
+
+```
+MCP client ── stdio ──► docker run … (MCP server)
+ │ /var/run/docker.sock (mounted)
+ ▼
+ host Docker daemon
+ └─ localstack-main (sibling, publishes :4566 on the host)
+```
+
+Because LocalStack is a sibling container, two things must be configured at run time:
+
+1. **Reachability** — set `LOCALSTACK_HOSTNAME=host.docker.internal` so the server
+ and the IaC CLIs target the sibling's published port instead of the container's
+ own `localhost`.
+2. **Host-resolvable mounts** — `localstack start` asks the **host** daemon to
+ bind-mount its license/state files into `localstack-main`. Those mount *sources*
+ must exist at an **identical path** on the host and inside the MCP container, so
+ point LocalStack's cache at a directory you bind-mount one-to-one (via
+ `XDG_CACHE_HOME`). Without this you get `Mounts denied: … is not shared from the
+ host`.
+
+## Quick start
+
+```bash
+mkdir -p "$HOME/.localstack-mcp"
+
+docker run -i --rm \
+ -v /var/run/docker.sock:/var/run/docker.sock \
+ -v "$HOME/.localstack-mcp:$HOME/.localstack-mcp" \
+ -e XDG_CACHE_HOME="$HOME/.localstack-mcp" \
+ --add-host host.docker.internal:host-gateway \
+ --add-host s3.host.docker.internal:host-gateway \
+ --add-host snowflake.localhost.localstack.cloud:host-gateway \
+ -e LOCALSTACK_AUTH_TOKEN="" \
+ -e LOCALSTACK_HOSTNAME=host.docker.internal \
+ localstack/localstack-mcp-server:latest
+```
+
+| Flag | Why it's needed |
+| --- | --- |
+| `-v /var/run/docker.sock:/var/run/docker.sock` | Lets the bundled LocalStack CLI drive the host Docker daemon (start/stop the sibling, `awslocal` exec). |
+| `-v "$HOME/.localstack-mcp:$HOME/.localstack-mcp"` + `-e XDG_CACHE_HOME=…` | Puts LocalStack's license/machine/volume files on an **identically-pathed** host directory so the host daemon can bind-mount them into `localstack-main`. |
+| `--add-host host.docker.internal:host-gateway` | Resolves `host.docker.internal` on Linux. Harmless on Docker Desktop (Mac/Windows), where it already resolves. |
+| `--add-host s3.host.docker.internal:host-gateway` | Lets CDK's virtual-hosted S3 endpoint resolve when `cdklocal` uses `AWS_ENDPOINT_URL_S3=http://s3.host.docker.internal:4566`. |
+| `--add-host snowflake.localhost.localstack.cloud:host-gateway` | Lets the Snowflake CLI reach the sibling Snowflake emulator through the hostname the emulator expects for routing. |
+| `-e LOCALSTACK_AUTH_TOKEN` | Required by **every** tool in this server. |
+| `-e LOCALSTACK_HOSTNAME=host.docker.internal` | Tells the server + IaC CLIs where the sibling LocalStack lives. |
+
+## MCP client configuration
+
+MCP clients launch the server over stdio. Note that client config files do **not**
+expand `$HOME`/`$PWD` — use absolute paths.
+
+```jsonc
+{
+ "mcpServers": {
+ "localstack-mcp-server": {
+ "command": "docker",
+ "args": [
+ "run", "-i", "--rm",
+ "-v", "/var/run/docker.sock:/var/run/docker.sock",
+ "-v", "/Users/you/.localstack-mcp:/Users/you/.localstack-mcp",
+ "-e", "XDG_CACHE_HOME=/Users/you/.localstack-mcp",
+ "--add-host", "host.docker.internal:host-gateway",
+ "--add-host", "s3.host.docker.internal:host-gateway",
+ "--add-host", "snowflake.localhost.localstack.cloud:host-gateway",
+ "-e", "LOCALSTACK_AUTH_TOKEN",
+ "-e", "LOCALSTACK_HOSTNAME=host.docker.internal",
+ "-v", "/Users/you/projects:/Users/you/projects",
+ "localstack/localstack-mcp-server:latest"
+ ],
+ "env": { "LOCALSTACK_AUTH_TOKEN": "" }
+ }
+ }
+}
+```
+
+## Deploying your IaC (mounting projects)
+
+Deploys run inside the MCP container, so your project directory must be visible
+there. Mount it and pass the in-container path to the `localstack-deployer` tool.
+The simplest convention is to mount it at the same absolute path:
+
+```
+-v "/Users/you/projects/my-infra:/Users/you/projects/my-infra"
+```
+
+Then tell the tool `directory: /Users/you/projects/my-infra`.
+
+Terraform, SAM, and CDK receive the LocalStack endpoint automatically when
+`LOCALSTACK_HOSTNAME=host.docker.internal` is set. CDK asset publishing is forced
+to path-style S3 inside the Docker image, so the single `s3.host.docker.internal`
+alias covers bootstrap asset uploads.
+
+## Known limitations
+
+- **Extra host aliases.** Include the aliases shown in the quick-start command.
+- **First cold start** of LocalStack can take up to ~2 minutes while the runtime
+ initializes; subsequent starts reuse the persisted volume under
+ `$XDG_CACHE_HOME`.
+- **Persistence across MCP restarts.** The sibling `localstack-main` keeps running
+ on the host even if your editor restarts the MCP container — reconnecting finds
+ your stack still up. State persists in `$XDG_CACHE_HOME/localstack/volume`.
+
+## Troubleshooting
+
+| Symptom | Cause / fix |
+| --- | --- |
+| `Mounts denied: … is not shared from the host` | The cache bind mount / `XDG_CACHE_HOME` is missing or not under a Docker-shared root. Use a path under your home directory and mount it one-to-one. |
+| Tools report `LocalStack Not Running` after `start` | Check `LOCALSTACK_HOSTNAME=host.docker.internal` is set and `--add-host` is present (Linux). |
+| `Auth Token Required` | `LOCALSTACK_AUTH_TOKEN` must be passed through (every tool requires it). |
+| `Could not find a running LocalStack container named "localstack-main"` | Set `MAIN_CONTAINER_NAME` if you renamed it. |
+
+## Validating an image yourself
+
+`tests/docker/validate-image.mjs` is a dependency-free MCP stdio client that drives
+the image through real tool calls.
+
+```bash
+LOCALSTACK_AUTH_TOKEN="" \
+HARNESS_TOKEN_REAL=1 \
+node tests/docker/validate-image.mjs -- \
+ docker run -i --rm \
+ -v /var/run/docker.sock:/var/run/docker.sock \
+ -v "$HOME/.localstack-mcp:$HOME/.localstack-mcp" \
+ -e XDG_CACHE_HOME="$HOME/.localstack-mcp" \
+ --add-host host.docker.internal:host-gateway \
+ --add-host s3.host.docker.internal:host-gateway \
+ --add-host snowflake.localhost.localstack.cloud:host-gateway \
+ -e LOCALSTACK_AUTH_TOKEN \
+ -e LOCALSTACK_HOSTNAME=host.docker.internal \
+ -v "$PWD/data:/work/data" \
+ localstack/localstack-mcp-server:latest
+```
+
+Use `HARNESS_SKIP` to skip scenarios, for example:
+
+```bash
+HARNESS_SKIP=docs,cloudpods,ephemeral,replicator
+```
diff --git a/src/core/localstack-env.ts b/src/core/localstack-env.ts
new file mode 100644
index 0000000..b8bd822
--- /dev/null
+++ b/src/core/localstack-env.ts
@@ -0,0 +1,60 @@
+import { LOCALSTACK_PORT } from "./config";
+
+/**
+ * Build the environment for spawned LocalStack IaC CLIs (tflocal, cdklocal, samlocal)
+ * so they target the right LocalStack endpoint when the server runs in a container
+ * and LocalStack is reachable at a non-default host (e.g. host.docker.internal).
+ *
+ * Why this is needed (verified against the wrapper sources):
+ * - tflocal / samlocal honor LOCALSTACK_HOSTNAME + EDGE_PORT (AWS_ENDPOINT_URL wins).
+ * - cdklocal (aws-cdk >= 2.177) IGNORES LOCALSTACK_HOSTNAME and REQUIRES
+ * AWS_ENDPOINT_URL together with AWS_ENDPOINT_URL_S3 (the S3 URL keeps an `s3.`
+ * component so CDK routes S3 traffic). Setting AWS_ENDPOINT_URL without the S3
+ * one makes cdklocal throw.
+ *
+ * This env is injected ONLY into the IaC CLI child processes — never globally and
+ * never into the LocalStack container, because a container-side AWS_ENDPOINT_URL
+ * would redirect LocalStack's own internal service-to-service calls and break them.
+ *
+ * When LOCALSTACK_HOSTNAME is unset / localhost (the default `npx` workflow), the
+ * environment is returned unchanged so the wrappers use their built-in
+ * localhost.localstack.cloud defaults — no behavior change for existing users.
+ */
+export function buildIacCliEnv(extra: Record = {}): NodeJS.ProcessEnv {
+ const base: NodeJS.ProcessEnv = { ...process.env, ...extra };
+
+ const explicitHost = process.env.LOCALSTACK_HOSTNAME?.trim();
+ if (!explicitHost || explicitHost === "localhost" || explicitHost === "127.0.0.1") {
+ return base;
+ }
+
+ const port = String(LOCALSTACK_PORT);
+ const endpoint = `http://${explicitHost}:${port}`;
+ const s3Endpoint = `http://s3.${explicitHost}:${port}`;
+
+ return {
+ ...base,
+ AWS_ENDPOINT_URL: base.AWS_ENDPOINT_URL || endpoint,
+ AWS_ENDPOINT_URL_S3: base.AWS_ENDPOINT_URL_S3 || s3Endpoint,
+ S3_ENDPOINT: base.S3_ENDPOINT || endpoint,
+ AWS_S3_FORCE_PATH_STYLE: base.AWS_S3_FORCE_PATH_STYLE || "1",
+ AWS_ENVAR_ALLOWLIST: [
+ "AWS_ACCESS_KEY_ID",
+ "AWS_SECRET_ACCESS_KEY",
+ "AWS_SESSION_TOKEN",
+ "AWS_DEFAULT_REGION",
+ "AWS_REGION",
+ "AWS_S3_FORCE_PATH_STYLE",
+ "CDK_DEFAULT_ACCOUNT",
+ "CDK_DEFAULT_REGION",
+ ].join(","),
+ LOCALSTACK_HOSTNAME: explicitHost,
+ EDGE_PORT: base.EDGE_PORT || port,
+ AWS_ACCESS_KEY_ID: base.AWS_ACCESS_KEY_ID || "test",
+ AWS_SECRET_ACCESS_KEY: base.AWS_SECRET_ACCESS_KEY || "test",
+ AWS_DEFAULT_REGION: base.AWS_DEFAULT_REGION || "us-east-1",
+ AWS_REGION: base.AWS_REGION || base.AWS_DEFAULT_REGION || "us-east-1",
+ CDK_DEFAULT_ACCOUNT: base.CDK_DEFAULT_ACCOUNT || "000000000000",
+ CDK_DEFAULT_REGION: base.CDK_DEFAULT_REGION || base.AWS_DEFAULT_REGION || "us-east-1",
+ };
+}
diff --git a/src/lib/localstack/localstack.utils.ts b/src/lib/localstack/localstack.utils.ts
index 8d4ea67..c67d1cb 100644
--- a/src/lib/localstack/localstack.utils.ts
+++ b/src/lib/localstack/localstack.utils.ts
@@ -1,4 +1,5 @@
import { spawn } from "child_process";
+import { LOCALSTACK_HOSTNAME, LOCALSTACK_PORT } from "../../core/config";
import { runCommand } from "../../core/command-runner";
import { ResponseBuilder } from "../../core/response-builder";
@@ -95,6 +96,24 @@ export interface RuntimeStatus {
statusOutput?: string;
}
+const SNOWFLAKE_ROUTING_HOST = "snowflake.localhost.localstack.cloud";
+const CLIENT_ONLY_ENV_KEYS = [
+ "HOSTNAME",
+ "LOCALSTACK_HOSTNAME",
+ "AWS_ENDPOINT_URL",
+ "AWS_ENDPOINT_URL_S3",
+ "S3_ENDPOINT",
+ "AWS_S3_FORCE_PATH_STYLE",
+];
+
+function getLocalStackEndpointHost() {
+ return process.env.LOCALSTACK_HOSTNAME?.trim() || LOCALSTACK_HOSTNAME;
+}
+
+function getLocalStackEndpointPort() {
+ return String(process.env.LOCALSTACK_PORT || LOCALSTACK_PORT);
+}
+
/**
* Get LocalStack status information
* @returns Promise with status details including running state and raw output
@@ -125,15 +144,19 @@ export async function getLocalStackStatus(): Promise {
*/
export async function getSnowflakeEmulatorStatus(): Promise {
try {
+ const host = getLocalStackEndpointHost();
+ const port = getLocalStackEndpointPort();
const { stdout, stderr, error, exitCode } = await runCommand("curl", [
"-sS",
"-X",
"POST",
"-H",
"Content-Type: application/json",
+ "-H",
+ `Host: ${SNOWFLAKE_ROUTING_HOST}:${port}`,
"-d",
"{}",
- "snowflake.localhost.localstack.cloud:4566/session",
+ `http://${host}:${port}/session`,
]);
const output = (stdout || "").trim();
@@ -190,7 +213,11 @@ export async function startRuntime({
return ResponseBuilder.markdown(alreadyRunningMessage);
}
- const environment = { ...process.env, ...(envVars || {}) } as Record;
+ const environment = { ...process.env } as Record;
+ for (const key of CLIENT_ONLY_ENV_KEYS) {
+ delete environment[key];
+ }
+ Object.assign(environment, envVars || {});
if (process.env.LOCALSTACK_AUTH_TOKEN) {
environment.LOCALSTACK_AUTH_TOKEN = process.env.LOCALSTACK_AUTH_TOKEN;
}
@@ -216,8 +243,14 @@ export async function startRuntime({
child.on("close", (code) => {
if (earlyExit) return;
- if (poll) clearInterval(poll);
+ // A non-zero exit is a real failure: stop polling and report it.
+ // A zero exit is expected in non-interactive environments (e.g. inside a
+ // container, where `localstack start` launches the runtime and returns
+ // instead of staying attached to stream logs). In that case we must keep
+ // polling so readiness is still detected and the promise resolves — clearing
+ // the interval here would leave the start call hanging forever.
if (code !== 0) {
+ if (poll) clearInterval(poll);
resolve(
ResponseBuilder.markdown(
`❌ ${processLabel} process exited unexpectedly with code ${code}.\n\nStderr:\n${stderr}`
diff --git a/src/tools/localstack-deployer.ts b/src/tools/localstack-deployer.ts
index 21739ab..e712b09 100644
--- a/src/tools/localstack-deployer.ts
+++ b/src/tools/localstack-deployer.ts
@@ -21,6 +21,7 @@ import { type DeploymentEvent } from "../lib/deployment/deployment-utils";
import { formatDeploymentReport } from "../lib/deployment/deployment-reporter";
import { ResponseBuilder } from "../core/response-builder";
import { withToolAnalytics } from "../core/analytics";
+import { buildIacCliEnv } from "../core/localstack-env";
// Define the schema for tool parameters
export const schema = {
@@ -380,7 +381,7 @@ async function executeTerraformCommands(
if (action === "deploy") {
events.push({ type: "header", title: "📦 Initializing Terraform", content: "" });
- const initRes = await runCommand(baseCommand, ["init"], { cwd: directory });
+ const initRes = await runCommand(baseCommand, ["init"], { cwd: directory, env: buildIacCliEnv() });
events.push({ type: "output", content: stripAnsiCodes(initRes.stdout) });
if (initRes.stderr) events.push({ type: "warning", content: stripAnsiCodes(initRes.stderr) });
if (initRes.error) {
@@ -394,7 +395,7 @@ async function executeTerraformCommands(
events.push({ type: "header", title: "🔨 Applying Terraform Configuration", content: "" });
const applyArgs = ["apply", "-auto-approve", ...varArgs];
- const applyRes = await runCommand(baseCommand, applyArgs, { cwd: directory });
+ const applyRes = await runCommand(baseCommand, applyArgs, { cwd: directory, env: buildIacCliEnv() });
events.push({ type: "output", content: stripAnsiCodes(applyRes.stdout) });
if (applyRes.stderr) events.push({ type: "warning", content: stripAnsiCodes(applyRes.stderr) });
if (applyRes.error) {
@@ -406,7 +407,7 @@ async function executeTerraformCommands(
return events;
}
- const outputRes = await runCommand(baseCommand, ["output", "-json"], { cwd: directory });
+ const outputRes = await runCommand(baseCommand, ["output", "-json"], { cwd: directory, env: buildIacCliEnv() });
if (outputRes.stdout.trim()) {
const parsed = parseTerraformOutputs(outputRes.stdout);
events.push({ type: "output", content: parsed });
@@ -415,7 +416,7 @@ async function executeTerraformCommands(
} else {
events.push({ type: "header", title: "💥 Destroying Terraform Resources", content: "" });
const destroyArgs = ["destroy", "-auto-approve", ...varArgs];
- const destroyRes = await runCommand(baseCommand, destroyArgs, { cwd: directory });
+ const destroyRes = await runCommand(baseCommand, destroyArgs, { cwd: directory, env: buildIacCliEnv() });
events.push({ type: "output", content: stripAnsiCodes(destroyRes.stdout) });
if (destroyRes.stderr)
events.push({ type: "warning", content: stripAnsiCodes(destroyRes.stderr) });
@@ -500,7 +501,7 @@ async function executeSamCommands(
buildArgs.push("--template-file", resolvedTemplatePath);
}
events.push({ type: "command", content: `${baseCommand} ${buildArgs.join(" ")}` });
- const buildRes = await runCommand(baseCommand, buildArgs, { cwd: directory });
+ const buildRes = await runCommand(baseCommand, buildArgs, { cwd: directory, env: buildIacCliEnv() });
events.push({ type: "output", content: stripAnsiCodes(buildRes.stdout) });
if (buildRes.stderr) events.push({ type: "warning", content: stripAnsiCodes(buildRes.stderr) });
if (buildRes.error) {
@@ -543,7 +544,7 @@ async function executeSamCommands(
events.push({ type: "command", content: `${baseCommand} ${deployArgs.join(" ")}` });
const deployRes = await runCommand(baseCommand, deployArgs, {
cwd: directory,
- env: { ...process.env, CI: "true" },
+ env: buildIacCliEnv({ CI: "true" }),
});
events.push({ type: "output", content: stripAnsiCodes(deployRes.stdout) });
if (deployRes.stderr) events.push({ type: "warning", content: stripAnsiCodes(deployRes.stderr) });
@@ -560,7 +561,7 @@ async function executeSamCommands(
events.push({ type: "header", title: "💥 Deleting SAM Application", content: "" });
const deleteArgs = ["delete", "--no-prompts", "--region", resolvedRegion, "--stack-name", resolvedStackName];
events.push({ type: "command", content: `${baseCommand} ${deleteArgs.join(" ")}` });
- const deleteRes = await runCommand(baseCommand, deleteArgs, { cwd: directory });
+ const deleteRes = await runCommand(baseCommand, deleteArgs, { cwd: directory, env: buildIacCliEnv() });
events.push({ type: "output", content: stripAnsiCodes(deleteRes.stdout) });
if (deleteRes.stderr) events.push({ type: "warning", content: stripAnsiCodes(deleteRes.stderr) });
if (deleteRes.error) {
@@ -595,7 +596,7 @@ async function executeCdkCommands(
events.push({ type: "header", title: "🥾 Bootstrapping CDK for LocalStack", content: "" });
const bootstrapRes = await runCommand(baseCommand, ["bootstrap"], {
cwd: directory,
- env: { ...process.env, CI: "true" },
+ env: buildIacCliEnv({ CI: "true" }),
});
events.push({ type: "output", content: stripAnsiCodes(bootstrapRes.stdout) });
if (bootstrapRes.stderr)
@@ -613,7 +614,7 @@ async function executeCdkCommands(
const deployRes = await runCommand(
baseCommand,
["deploy", "--require-approval", "never", "--all", ...contextArgs],
- { cwd: directory, env: { ...process.env, CI: "true" } }
+ { cwd: directory, env: buildIacCliEnv({ CI: "true" }) }
);
const cleanDeployOutput = stripAnsiCodes(deployRes.stdout);
events.push({ type: "output", content: cleanDeployOutput });
@@ -639,7 +640,7 @@ async function executeCdkCommands(
const destroyRes = await runCommand(
baseCommand,
["destroy", "--force", "--all", ...contextArgs],
- { cwd: directory, env: { ...process.env, CI: "true" } }
+ { cwd: directory, env: buildIacCliEnv({ CI: "true" }) }
);
events.push({ type: "output", content: stripAnsiCodes(destroyRes.stdout) });
if (destroyRes.stderr)
diff --git a/src/tools/localstack-docs.ts b/src/tools/localstack-docs.ts
index 483314a..9098dbc 100644
--- a/src/tools/localstack-docs.ts
+++ b/src/tools/localstack-docs.ts
@@ -85,10 +85,8 @@ function formatMarkdownResults(query: string, results: CrawlChatDocsResult[]): s
function getLabelFromUrlPath(urlString: string): string {
try {
const url = new URL(urlString);
- const lastSegment = url.pathname
- .split("/")
- .filter(Boolean)
- .at(-1);
+ const pathSegments = url.pathname.split("/").filter(Boolean);
+ const lastSegment = pathSegments[pathSegments.length - 1];
if (!lastSegment) {
return url.hostname;
diff --git a/src/tools/localstack-management.ts b/src/tools/localstack-management.ts
index 07c615c..ac63ce7 100644
--- a/src/tools/localstack-management.ts
+++ b/src/tools/localstack-management.ts
@@ -60,7 +60,7 @@ export default async function localstackManagement({
case "stop":
return await handleStop();
case "restart":
- return await handleRestart();
+ return await handleRestart({ envVars, service });
case "status":
return await handleStatus({ service });
default:
@@ -138,32 +138,16 @@ async function handleStop() {
}
// Handle restart action
-async function handleRestart() {
- const cmd = await runCommand("localstack", ["restart"], { timeout: 30000 });
- let result = "🔄 LocalStack restart command executed!\n\n";
- if (cmd.stdout.trim()) result += `Output:\n${cmd.stdout}\n`;
- if (cmd.stderr.trim()) result += `Messages:\n${cmd.stderr}\n`;
-
+async function handleRestart({
+ envVars,
+ service,
+}: {
+ envVars?: Record;
+ service: "aws" | "snowflake";
+}) {
+ await runCommand("localstack", ["stop"], { timeout: 60000 });
await new Promise((resolve) => setTimeout(resolve, 2000));
- const statusResult = await getLocalStackStatus();
- if (statusResult.statusOutput) {
- result += `\nStatus after restart:\n${statusResult.statusOutput}`;
- if (statusResult.isRunning) {
- result += "\n\n✅ LocalStack has been restarted successfully and is now running with a fresh state.";
- } else {
- result +=
- "\n\n⚠️ LocalStack restart completed but may still be starting up. Check status again in a few moments.";
- }
- } else {
- result +=
- "\n\n⚠️ Restart completed but unable to verify status. LocalStack may still be starting up.";
- }
-
- if (cmd.error) {
- result = `❌ Failed to restart LocalStack: ${cmd.error.message}\n\nThis could happen if:\n- LocalStack is not currently installed properly\n- There was an error executing the restart command\n- The restart process timed out (LocalStack can take time to restart)\n- Permission issues\n\nYou can try stopping and starting LocalStack manually using separate actions if the restart action continues to fail.`;
- }
-
- return ResponseBuilder.markdown(result);
+ return await handleStart({ envVars, service });
}
// Handle status action
diff --git a/src/tools/localstack-snowflake-client.ts b/src/tools/localstack-snowflake-client.ts
index 9dfc1b2..d31e2fb 100644
--- a/src/tools/localstack-snowflake-client.ts
+++ b/src/tools/localstack-snowflake-client.ts
@@ -1,12 +1,22 @@
import { z } from "zod";
import { type ToolMetadata, type InferSchema } from "xmcp";
import { runCommand } from "../core/command-runner";
+import { LOCALSTACK_PORT } from "../core/config";
import { runPreflights, requireSnowflakeCli, requireProFeature } from "../core/preflight";
import { ResponseBuilder } from "../core/response-builder";
import { ProFeature } from "../lib/localstack/license-checker";
import { withToolAnalytics } from "../core/analytics";
const SNOWFLAKE_CONNECTION_NAME = "localstack";
+const SNOWFLAKE_ROUTING_HOST = "snowflake.localhost.localstack.cloud";
+
+function getSnowflakeHost() {
+ return SNOWFLAKE_ROUTING_HOST;
+}
+
+function getSnowflakePort() {
+ return String(process.env.LOCALSTACK_PORT || LOCALSTACK_PORT);
+}
export const schema = {
action: z.enum(["execute", "check-connection"]).describe("Action to perform"),
@@ -70,9 +80,9 @@ async function requireSnowflakeConnectionProfile() {
"--schema",
"test",
"--port",
- "4566",
+ getSnowflakePort(),
"--host",
- "snowflake.localhost.localstack.cloud",
+ getSnowflakeHost(),
"--no-interactive",
],
{ env: { ...process.env } }
diff --git a/tests/docker/validate-image.mjs b/tests/docker/validate-image.mjs
new file mode 100644
index 0000000..bae04d3
--- /dev/null
+++ b/tests/docker/validate-image.mjs
@@ -0,0 +1,549 @@
+#!/usr/bin/env node
+/**
+ * Self-contained MCP stdio client that drives the LocalStack MCP Server (typically
+ * the Docker image) through a sequence of real tool calls and reports pass/fail.
+ *
+ * No external deps: speaks newline-delimited JSON-RPC 2.0 over the child's stdio,
+ * which is exactly what the MCP stdio transport uses.
+ *
+ * Usage:
+ * node tests/docker/validate-image.mjs -- [args...]
+ *
+ * Example (Docker image):
+ * node tests/docker/validate-image.mjs -- \
+ * docker run -i --rm \
+ * -v /var/run/docker.sock:/var/run/docker.sock \
+ * -v "$HOME/.localstack-mcp:$HOME/.localstack-mcp" \
+ * -e XDG_CACHE_HOME="$HOME/.localstack-mcp" \
+ * --add-host host.docker.internal:host-gateway \
+ * --add-host s3.host.docker.internal:host-gateway \
+ * --add-host snowflake.localhost.localstack.cloud:host-gateway \
+ * -e LOCALSTACK_AUTH_TOKEN -e LOCALSTACK_HOSTNAME=host.docker.internal \
+ * -v "$PWD/data:/work/data" \
+ * localstack/localstack-mcp-server:dev
+ *
+ * The cache bind mount + XDG_CACHE_HOME are required for the management tool's
+ * `start` action under Docker-out-of-Docker: `localstack start` asks the HOST
+ * daemon to bind-mount its license/machine/volume files, whose source paths must
+ * exist at an identical path on the host (see docs/DOCKER.md).
+ *
+ * Env knobs:
+ * HARNESS_DEPLOY_DIR In-container path to the terraform sample (default /work/data/sample-terraform)
+ * HARNESS_CDK_DIR In-container path to the CDK sample (default /work/data/sample-cdk)
+ * HARNESS_TOKEN_REAL "1" if LOCALSTACK_AUTH_TOKEN is a real/valid token (affects Pro-tool expectations)
+ * HARNESS_SKIP Comma-separated scenario keys to skip (e.g. "deploy,extensions")
+ * HARNESS_NO_CLEANUP "1" to leave LocalStack running afterwards
+ * HARNESS_RUN_REMOTE "1" to create remote resources (Cloud Pods, ephemeral instances)
+ * HARNESS_RUN_EPHEMERAL "1" to create/delete a cloud-hosted ephemeral instance
+ */
+
+import { spawn } from "node:child_process";
+
+const argv = process.argv.slice(2);
+const sep = argv.indexOf("--");
+if (sep === -1 || sep === argv.length - 1) {
+ console.error("Usage: node validate-image.mjs -- [args...]");
+ process.exit(2);
+}
+const serverCmd = argv[sep + 1];
+const serverArgs = argv.slice(sep + 2);
+
+const DEPLOY_DIR = process.env.HARNESS_DEPLOY_DIR || "/work/data/sample-terraform";
+const CDK_DIR = process.env.HARNESS_CDK_DIR || "/work/data/sample-cdk";
+const SQL_FILE = process.env.HARNESS_SQL_FILE || "/work/data/sample-sql/snowflake_test.sql";
+const TOKEN_REAL = process.env.HARNESS_TOKEN_REAL === "1";
+const SKIP = new Set((process.env.HARNESS_SKIP || "").split(",").map((s) => s.trim()).filter(Boolean));
+const NO_CLEANUP = process.env.HARNESS_NO_CLEANUP === "1";
+const RUN_REMOTE = process.env.HARNESS_RUN_REMOTE === "1";
+const RUN_EPHEMERAL = RUN_REMOTE || process.env.HARNESS_RUN_EPHEMERAL === "1";
+const RUN_REPLICATOR_START = process.env.HARNESS_RUN_REPLICATOR_START === "1";
+const RUN_ID = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
+
+const EXPECTED_TOOLS = [
+ "localstack-management", "localstack-deployer", "localstack-logs-analysis",
+ "localstack-iam-policy-analyzer", "localstack-chaos-injector", "localstack-cloud-pods",
+ "localstack-state-management", "localstack-extensions", "localstack-snowflake-client",
+ "localstack-ephemeral-instances", "localstack-aws-client", "localstack-aws-replicator",
+ "localstack-docs", "localstack-app-inspector",
+];
+
+// ---- JSON-RPC over stdio ----------------------------------------------------
+const child = spawn(serverCmd, serverArgs, { stdio: ["pipe", "pipe", "pipe"] });
+let buf = "";
+let nextId = 1;
+const pending = new Map();
+const serverLog = [];
+
+child.stdout.on("data", (chunk) => {
+ buf += chunk.toString();
+ let nl;
+ while ((nl = buf.indexOf("\n")) !== -1) {
+ const line = buf.slice(0, nl).trim();
+ buf = buf.slice(nl + 1);
+ if (!line) continue;
+ let msg;
+ try { msg = JSON.parse(line); } catch { serverLog.push(`[stdout-nonjson] ${line}`); continue; }
+ if (msg.id !== undefined && pending.has(msg.id)) {
+ const { resolve, reject } = pending.get(msg.id);
+ pending.delete(msg.id);
+ if (msg.error) reject(new Error(JSON.stringify(msg.error)));
+ else resolve(msg.result);
+ }
+ }
+});
+child.stderr.on("data", (c) => serverLog.push(`[stderr] ${c.toString().trimEnd()}`));
+child.on("exit", (code, sig) => {
+ for (const { reject } of pending.values()) reject(new Error(`server exited (code=${code} sig=${sig})`));
+ pending.clear();
+});
+
+function rpc(method, params, timeoutMs = 300000) {
+ const id = nextId++;
+ const payload = JSON.stringify({ jsonrpc: "2.0", id, method, params }) + "\n";
+ return new Promise((resolve, reject) => {
+ const timer = setTimeout(() => {
+ pending.delete(id);
+ reject(new Error(`timeout after ${timeoutMs}ms waiting for ${method}`));
+ }, timeoutMs);
+ pending.set(id, {
+ resolve: (r) => { clearTimeout(timer); resolve(r); },
+ reject: (e) => { clearTimeout(timer); reject(e); },
+ });
+ child.stdin.write(payload);
+ });
+}
+function notify(method, params) {
+ child.stdin.write(JSON.stringify({ jsonrpc: "2.0", method, params }) + "\n");
+}
+async function callTool(name, args, timeoutMs) {
+ const res = await rpc("tools/call", { name, arguments: args }, timeoutMs);
+ const text = (res?.content || []).map((c) => c.text || "").join("\n");
+ return { text, isError: res?.isError === true || text.trimStart().startsWith("❌") };
+}
+async function getPrompt(name, args, timeoutMs = 60000) {
+ return await rpc("prompts/get", { name, arguments: args }, timeoutMs);
+}
+const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
+// Call a tool, retrying until `ok(result)` (default: not an error) or attempts run out.
+async function callToolUntil(name, args, { attempts = 6, delayMs = 5000, timeoutMs = 60000, ok } = {}) {
+ let last;
+ for (let i = 0; i < attempts; i++) {
+ try { last = await callTool(name, args, timeoutMs); }
+ catch (e) { last = { text: String(e.message), isError: true }; }
+ if (ok ? ok(last) : !last.isError) return last;
+ if (i < attempts - 1) await sleep(delayMs);
+ }
+ return last;
+}
+
+// ---- scenario runner --------------------------------------------------------
+const results = [];
+function record(key, name, ok, detail, note) {
+ results.push({ key, name, ok, detail, note });
+ const tag = ok === true ? "✅ PASS" : ok === "warn" ? "⚠️ WARN" : "❌ FAIL";
+ console.log(`\n${tag} [${key}] ${name}`);
+ if (detail) console.log(" " + detail.replace(/\n/g, "\n ").slice(0, 1200));
+ if (note) console.log(" ↳ " + note);
+}
+const snip = (t, n = 400) => (t || "").replace(/\s+/g, " ").trim().slice(0, n);
+const hasAwsCreds = () => Boolean(
+ process.env.AWS_REPLICATOR_SOURCE_AWS_ACCESS_KEY_ID ||
+ process.env.AWS_ACCESS_KEY_ID
+) && Boolean(
+ process.env.AWS_REPLICATOR_SOURCE_AWS_SECRET_ACCESS_KEY ||
+ process.env.AWS_SECRET_ACCESS_KEY
+) && Boolean(
+ process.env.AWS_REPLICATOR_SOURCE_REGION_NAME ||
+ process.env.AWS_DEFAULT_REGION ||
+ process.env.AWS_REGION
+);
+
+function gracefulProGate(result) {
+ return result.isError && /(Authentication|Auth Token|Feature Not Available|license|not seem to include|requires a LocalStack license)/i.test(result.text);
+}
+
+function recordToolResult(key, name, result, predicate = (r) => !r.isError, note) {
+ if (TOKEN_REAL) {
+ record(key, name, predicate(result), snip(result.text, 600), note);
+ return;
+ }
+ const graceful = gracefulProGate(result);
+ record(
+ key,
+ `${name} (dummy token)`,
+ graceful ? "warn" : false,
+ snip(result.text, 400),
+ graceful ? "graceful auth/Pro-gate failure (expected without a real token)" : note
+ );
+}
+
+function firstMarkdownTableValue(text) {
+ for (const line of (text || "").split("\n")) {
+ const trimmed = line.trim();
+ if (!trimmed.startsWith("|") || /^[-|:\s]+$/.test(trimmed) || /Trace ID|Span ID|Event ID/.test(trimmed)) {
+ continue;
+ }
+ const cells = trimmed.split("|").map((cell) => cell.trim()).filter(Boolean);
+ if (cells[0] && cells[0] !== "-") return cells[0].replace(/^`|`$/g, "");
+ }
+ return undefined;
+}
+
+async function main() {
+ // 1. Handshake
+ const init = await rpc("initialize", {
+ protocolVersion: "2024-11-05",
+ capabilities: {},
+ clientInfo: { name: "ls-mcp-docker-validator", version: "1.0.0" },
+ }, 60000);
+ notify("notifications/initialized", {});
+ console.log(`Connected. Server: ${init?.serverInfo?.name ?? "?"} ${init?.serverInfo?.version ?? ""}`);
+
+ // 2. tools/list
+ const toolsRes = await rpc("tools/list", {}, 60000);
+ const toolNames = (toolsRes?.tools || []).map((t) => t.name);
+ const missing = EXPECTED_TOOLS.filter((t) => !toolNames.includes(t));
+ record("tools", "tools/list exposes all 14 tools", missing.length === 0,
+ `found ${toolNames.length} tools`, missing.length ? `MISSING: ${missing.join(", ")}` : undefined);
+
+ // 2b. prompts/get
+ if (!SKIP.has("prompt")) {
+ try {
+ const prompts = await rpc("prompts/list", {}, 60000);
+ const hasPrompt = (prompts?.prompts || []).some((prompt) => prompt.name === "infrastructure-tester");
+ const prompt = await getPrompt("infrastructure-tester", { iac_path: "/work/data/sample-terraform" });
+ const text = (prompt?.messages || []).map((msg) => msg?.content?.text || "").join("\n");
+ record(
+ "prompt",
+ "infrastructure-tester prompt is exposed and renders",
+ hasPrompt && /# Infrastructure Tester \(LocalStack\)/.test(text),
+ snip(text, 400),
+ hasPrompt ? undefined : "prompt missing from prompts/list"
+ );
+ } catch (e) { record("prompt", "infrastructure-tester prompt", false, String(e.message)); }
+ }
+
+ // 3. docs (token-only; calls an external API, so retry once for transient blips)
+ if (!SKIP.has("docs")) {
+ try {
+ const r = await callToolUntil(
+ "localstack-docs",
+ { query: "how to start localstack and configure auth token", limit: 2 },
+ { attempts: 2, delayMs: 3000, timeoutMs: 60000, ok: (x) => !x.isError && /LocalStack Docs/i.test(x.text) }
+ );
+ record("docs", "localstack-docs returns snippets", !r.isError && /LocalStack Docs/i.test(r.text), snip(r.text));
+ } catch (e) { record("docs", "localstack-docs", false, String(e.message)); }
+ }
+
+ // 4. management status (pre-start) — validates CLI + docker socket reachability
+ if (!SKIP.has("status")) {
+ try {
+ const r = await callTool("localstack-management", { action: "status" }, 60000);
+ record("status", "localstack-management status (pre-start)", !r.isError, snip(r.text));
+ } catch (e) { record("status", "management status", false, String(e.message)); }
+ }
+
+ // 5. management start
+ if (!SKIP.has("start")) {
+ try {
+ const r = await callTool("localstack-management", { action: "start" }, 240000);
+ const ok = !r.isError && /(started successfully|already running)/i.test(r.text);
+ record("start", "localstack-management start", ok, snip(r.text, 500));
+ } catch (e) { record("start", "management start", false, String(e.message)); }
+ }
+
+ // 5b. Readiness gate — after a cold start the container reports "running" before
+ // every service accepts connections. A well-behaved client waits for readiness;
+ // poll a trivial awslocal call until it succeeds before exercising services.
+ if (!SKIP.has("start") && (!SKIP.has("aws") || !SKIP.has("deploy"))) {
+ const ready = await callToolUntil(
+ "localstack-aws-client",
+ { command: "sts get-caller-identity" },
+ { attempts: 24, delayMs: 5000, timeoutMs: 30000 }
+ );
+ console.log(`\n[readiness] LocalStack services ${ready.isError ? "NOT ready after wait" : "ready"}`);
+ }
+
+ // 6. aws-client — validates docker exec of awslocal inside the LS container
+ if (!SKIP.has("aws")) {
+ try {
+ const mb = await callTool("localstack-aws-client", { command: "s3 mb s3://harness-test-bucket" }, 60000);
+ const ls = await callTool("localstack-aws-client", { command: "s3 ls" }, 60000);
+ const ok = !mb.isError && !ls.isError && /harness-test-bucket/.test(ls.text);
+ record("aws", "localstack-aws-client (awslocal s3 mb + ls)", ok, `mb: ${snip(mb.text, 120)} | ls: ${snip(ls.text, 200)}`);
+ } catch (e) { record("aws", "aws-client", false, String(e.message)); }
+ }
+
+ // 6b. logs-analysis — validates docker log access to the sibling LocalStack container.
+ if (!SKIP.has("logs")) {
+ try {
+ await callTool("localstack-aws-client", { command: "s3api head-bucket --bucket definitely-missing-harness-bucket" }, 60000).catch(() => {});
+ const summary = await callTool("localstack-logs-analysis", { analysisType: "summary", lines: 1000 }, 60000);
+ const errors = await callTool("localstack-logs-analysis", { analysisType: "errors", lines: 1000, service: "s3" }, 60000);
+ const requests = await callTool("localstack-logs-analysis", { analysisType: "requests", lines: 1000, service: "s3", operation: "CreateBucket" }, 60000);
+ const raw = await callTool("localstack-logs-analysis", { analysisType: "logs", lines: 1000, filter: "harness-test-bucket" }, 60000);
+ const ok = [summary, errors, requests, raw].every((r) => !r.isError) && /LocalStack Summary|Summary/i.test(summary.text);
+ record("logs", "localstack-logs-analysis summary/errors/requests/logs", ok,
+ `summary: ${snip(summary.text, 180)} | errors: ${snip(errors.text, 120)} | requests: ${snip(requests.text, 120)} | raw: ${snip(raw.text, 120)}`);
+ } catch (e) { record("logs", "logs-analysis", false, String(e.message)); }
+ }
+
+ // 6c. state-management — export local state to a mounted path, reset, import, inspect.
+ if (!SKIP.has("state")) {
+ const bucket = `harness-state-${RUN_ID}`;
+ const statePath = `/work/data/harness-state-${RUN_ID}.zip`;
+ try {
+ await callTool("localstack-aws-client", { command: `s3 mb s3://${bucket}` }, 60000);
+ const exported = await callTool("localstack-state-management", { action: "export", file_path: statePath, services: ["s3"] }, 120000);
+ const inspected = await callTool("localstack-state-management", { action: "inspect", services: "s3" }, 60000);
+ const reset = await callTool("localstack-state-management", { action: "reset", services: ["s3"] }, 120000);
+ const afterReset = await callTool("localstack-aws-client", { command: "s3 ls" }, 60000);
+ const imported = await callTool("localstack-state-management", { action: "import", file_path: statePath }, 120000);
+ const afterImport = await callTool("localstack-aws-client", { command: "s3 ls" }, 60000);
+ const ok = !exported.isError && !inspected.isError && !reset.isError && !imported.isError && /State Exported/i.test(exported.text) && afterImport.text.includes(bucket);
+ recordToolResult("state", "localstack-state-management export/inspect/reset/import", { text: `export: ${exported.text}\ninspect: ${inspected.text}\nreset: ${reset.text}\nimport: ${imported.text}\nafter reset: ${afterReset.text}\nafter import: ${afterImport.text}`, isError: !ok }, () => ok);
+ } catch (e) { record("state", "state-management", false, String(e.message)); }
+ }
+
+ // 6d. cloud-pods — remote/cloud-backed snapshot. Opt in because it creates account resources.
+ if (!SKIP.has("cloudpods")) {
+ const podName = `mcp-harness-${RUN_ID}`.replace(/[^A-Za-z0-9._-]/g, "-").slice(0, 80);
+ const bucket = `harness-pod-${RUN_ID}`;
+ if (!RUN_REMOTE) {
+ record("cloudpods", "localstack-cloud-pods save/load/delete", "warn", "skipped remote Cloud Pod create/load/delete", "set HARNESS_RUN_REMOTE=1 to exercise remote Cloud Pods");
+ } else {
+ try {
+ await callTool("localstack-cloud-pods", { action: "delete", pod_name: podName }, 120000).catch(() => {});
+ await callTool("localstack-aws-client", { command: `s3 mb s3://${bucket}` }, 60000);
+ const save = await callTool("localstack-cloud-pods", { action: "save", pod_name: podName }, 300000);
+ const reset = await callTool("localstack-state-management", { action: "reset", services: ["s3"] }, 120000);
+ const load = await callTool("localstack-cloud-pods", { action: "load", pod_name: podName }, 300000);
+ const ls = await callTool("localstack-aws-client", { command: "s3 ls" }, 60000);
+ const del = await callTool("localstack-cloud-pods", { action: "delete", pod_name: podName }, 120000);
+ const ok = !save.isError && !reset.isError && !load.isError && !del.isError && ls.text.includes(bucket);
+ recordToolResult("cloudpods", "localstack-cloud-pods save/reset/load/delete", { text: `save: ${save.text}\nload: ${load.text}\ndelete: ${del.text}\nls: ${ls.text}`, isError: !ok }, () => ok);
+ } catch (e) {
+ try { await callTool("localstack-cloud-pods", { action: "delete", pod_name: podName }, 120000); } catch {}
+ record("cloudpods", "cloud-pods", false, String(e.message));
+ }
+ }
+ }
+
+ // 6e. app-inspector — enable, generate traffic, list traces and drill into spans/events when present.
+ if (!SKIP.has("appinspector")) {
+ try {
+ const enable = await callTool("localstack-app-inspector", { action: "set-status", status: "enabled" }, 60000);
+ await callTool("localstack-aws-client", { command: `s3 mb s3://harness-ai-${RUN_ID}` }, 60000);
+ await sleep(2000);
+ const traces = await callToolUntil(
+ "localstack-app-inspector",
+ { action: "list-traces", limit: 10 },
+ { attempts: 6, delayMs: 3000, timeoutMs: 60000, ok: (r) => !r.isError && !/No traces found/i.test(r.text) }
+ );
+ const traceId = firstMarkdownTableValue(traces.text);
+ let trace = { text: "", isError: false };
+ let spans = { text: "", isError: false };
+ let events = { text: "", isError: false };
+ let iamEvents = { text: "", isError: false };
+ if (traceId) {
+ trace = await callTool("localstack-app-inspector", { action: "get-trace", trace_id: traceId }, 60000);
+ spans = await callTool("localstack-app-inspector", { action: "list-spans", trace_id: traceId, limit: 10 }, 60000);
+ const spanId = firstMarkdownTableValue(spans.text);
+ if (spanId) {
+ events = await callTool("localstack-app-inspector", { action: "list-events", trace_id: traceId, span_id: spanId, limit: 10 }, 60000);
+ iamEvents = await callTool("localstack-app-inspector", { action: "list-iam-events", trace_id: traceId, span_id: spanId, limit: 10 }, 60000);
+ }
+ }
+ const ok = !enable.isError && !traces.isError && Boolean(traceId) && !trace.isError && !spans.isError && !events.isError && !iamEvents.isError;
+ recordToolResult("appinspector", "localstack-app-inspector enable/list/get/spans/events", { text: `enable: ${enable.text}\ntraces: ${traces.text}\ntrace: ${trace.text}\nspans: ${spans.text}\nevents: ${events.text}\niam: ${iamEvents.text}`, isError: !ok }, () => ok);
+ } catch (e) { record("appinspector", "app-inspector", false, String(e.message)); }
+ }
+
+ // 6f. chaos-injector — add a deterministic S3 ListBuckets fault, observe it, then clear faults/latency.
+ if (!SKIP.has("chaos")) {
+ const rule = {
+ service: "s3",
+ region: "us-east-1",
+ operation: "ListBuckets",
+ probability: 1,
+ error: { statusCode: 503, code: "ServiceUnavailable" },
+ };
+ try {
+ const add = await callTool("localstack-chaos-injector", { action: "add-fault-rule", rules: [rule] }, 60000);
+ const faults = await callTool("localstack-chaos-injector", { action: "get-faults" }, 60000);
+ const affected = await callTool("localstack-aws-client", { command: "s3 ls" }, 60000);
+ const latency = await callTool("localstack-chaos-injector", { action: "inject-latency", latency_ms: 25 }, 60000);
+ const getLatency = await callTool("localstack-chaos-injector", { action: "get-latency" }, 60000);
+ const clearLatency = await callTool("localstack-chaos-injector", { action: "clear-latency" }, 60000);
+ const clear = await callTool("localstack-chaos-injector", { action: "clear-all-faults" }, 60000);
+ const ok = !add.isError && !faults.isError && affected.isError && !latency.isError && !getLatency.isError && !clearLatency.isError && !clear.isError;
+ recordToolResult("chaos", "localstack-chaos-injector add/get/effect/clear", { text: `add: ${add.text}\nfaults: ${faults.text}\naffected: ${affected.text}\nlatency: ${latency.text}\nget latency: ${getLatency.text}\nclear latency: ${clearLatency.text}\nclear: ${clear.text}`, isError: !ok }, () => ok);
+ } catch (e) {
+ try { await callTool("localstack-chaos-injector", { action: "clear-all-faults" }, 60000); } catch {}
+ try { await callTool("localstack-chaos-injector", { action: "clear-latency" }, 60000); } catch {}
+ record("chaos", "chaos-injector", false, String(e.message));
+ }
+ }
+
+ // 6g. IAM policy analyzer — mode transitions plus log analysis, then restore disabled.
+ if (!SKIP.has("iam")) {
+ try {
+ const status = await callTool("localstack-iam-policy-analyzer", { action: "get-status" }, 60000);
+ const soft = await callTool("localstack-iam-policy-analyzer", { action: "set-mode", mode: "SOFT_MODE" }, 60000);
+ await callTool("localstack-aws-client", { command: "s3 ls" }, 60000);
+ const analyze = await callTool("localstack-iam-policy-analyzer", { action: "analyze-policies" }, 60000);
+ const disabled = await callTool("localstack-iam-policy-analyzer", { action: "set-mode", mode: "DISABLED" }, 60000);
+ const ok = !status.isError && !soft.isError && !analyze.isError && !disabled.isError;
+ recordToolResult("iam", "localstack-iam-policy-analyzer status/set/analyze/restore", { text: `status: ${status.text}\nsoft: ${soft.text}\nanalyze: ${analyze.text}\ndisabled: ${disabled.text}`, isError: !ok }, () => ok);
+ } catch (e) {
+ try { await callTool("localstack-iam-policy-analyzer", { action: "set-mode", mode: "DISABLED" }, 60000); } catch {}
+ record("iam", "iam-policy-analyzer", false, String(e.message));
+ }
+ }
+
+ // 6h. aws-replicator — list endpoints always; start a job only when source AWS creds are explicitly available.
+ if (!SKIP.has("replicator")) {
+ try {
+ const resources = await callTool("localstack-aws-replicator", { action: "list-resources" }, 120000);
+ const jobs = await callTool("localstack-aws-replicator", { action: "list" }, 120000);
+ let start = { text: "start skipped: no HARNESS_RUN_REPLICATOR_START=1 or source AWS credentials", isError: false };
+ if (RUN_REPLICATOR_START && hasAwsCreds()) {
+ start = await callTool("localstack-aws-replicator", {
+ action: "start",
+ replication_type: "SINGLE_RESOURCE",
+ resource_type: process.env.HARNESS_REPLICATOR_RESOURCE_TYPE || "AWS::SSM::Parameter",
+ resource_identifier: process.env.HARNESS_REPLICATOR_RESOURCE_IDENTIFIER || "/localstack-mcp-harness",
+ }, 300000);
+ }
+ const ok = !resources.isError && !jobs.isError && !start.isError;
+ const note = RUN_REPLICATOR_START && hasAwsCreds() ? undefined : "replication job start skipped unless HARNESS_RUN_REPLICATOR_START=1 and source AWS creds are set";
+ recordToolResult("replicator", "localstack-aws-replicator list-resources/list/start-if-configured", { text: `resources: ${resources.text}\njobs: ${jobs.text}\nstart: ${start.text}`, isError: !ok }, () => ok, note);
+ } catch (e) { record("replicator", "aws-replicator", false, String(e.message)); }
+ }
+
+ // 6i. ephemeral instances — list always; create/logs/delete only with explicit opt-in.
+ if (!SKIP.has("ephemeral")) {
+ const instanceName = `mcp-harness-${RUN_ID}`.replace(/[^A-Za-z0-9._-]/g, "-").slice(0, 60);
+ try {
+ const list = await callTool("localstack-ephemeral-instances", { action: "list" }, 120000);
+ if (!RUN_EPHEMERAL) {
+ recordToolResult("ephemeral", "localstack-ephemeral-instances list", list, (r) => !r.isError, "create/logs/delete skipped; set HARNESS_RUN_EPHEMERAL=1 to provision a short-lived cloud instance");
+ } else {
+ const create = await callTool("localstack-ephemeral-instances", { action: "create", name: instanceName, lifetime: 10 }, 240000);
+ if (create.isError && /compute\.resource_exhausted|quota|limit/i.test(create.text)) {
+ record(
+ "ephemeral",
+ "localstack-ephemeral-instances list/create",
+ "warn",
+ `list: ${snip(list.text, 300)}\ncreate: ${snip(create.text, 500)}`,
+ "platform quota exhausted; create/logs/delete could not be completed"
+ );
+ } else {
+ const logs = await callTool("localstack-ephemeral-instances", { action: "logs", name: instanceName }, 180000);
+ const del = await callTool("localstack-ephemeral-instances", { action: "delete", name: instanceName }, 120000);
+ const ok = !list.isError && !create.isError && !logs.isError && !del.isError;
+ recordToolResult("ephemeral", "localstack-ephemeral-instances list/create/logs/delete", { text: `list: ${list.text}\ncreate: ${create.text}\nlogs: ${logs.text}\ndelete: ${del.text}`, isError: !ok }, () => ok);
+ }
+ }
+ } catch (e) {
+ if (RUN_EPHEMERAL) {
+ try { await callTool("localstack-ephemeral-instances", { action: "delete", name: instanceName }, 120000); } catch {}
+ }
+ record("ephemeral", "ephemeral-instances", false, String(e.message));
+ }
+ }
+
+ // 7. deployer terraform
+ if (!SKIP.has("deploy")) {
+ try {
+ const r = await callTool("localstack-deployer", { action: "deploy", projectType: "terraform", directory: DEPLOY_DIR }, 300000);
+ const ok = !r.isError && /(completed successfully|Apply complete|bucket_name|Terraform Outputs)/i.test(r.text);
+ record("deploy", "localstack-deployer terraform deploy (tflocal -> LS)", ok, snip(r.text, 600));
+ } catch (e) { record("deploy", "deployer terraform", false, String(e.message)); }
+ }
+
+ // 7b. deployer CDK — validates cdklocal endpoint injection and path-style S3 asset uploads.
+ if (!SKIP.has("deploy-cdk")) {
+ try {
+ const r = await callTool("localstack-deployer", { action: "deploy", projectType: "cdk", directory: CDK_DIR }, 300000);
+ const ls = await callTool("localstack-aws-client", { command: "s3 ls" }, 60000);
+ const ok = !r.isError && !ls.isError && /CDK stack deployed successfully/i.test(r.text) && ls.text.includes("mcp-cdk-sample-bucket") && !/Error during `cdklocal/i.test(r.text);
+ record("deploy-cdk", "localstack-deployer CDK deploy (cdklocal -> LS)", ok, `deploy: ${snip(r.text, 500)} | s3 ls: ${snip(ls.text, 160)}`);
+ } catch (e) { record("deploy-cdk", "deployer CDK", false, String(e.message)); }
+ }
+
+ // 8. extensions — Pro-gated (needs valid token + marketplace API)
+ if (!SKIP.has("extensions")) {
+ try {
+ const r = await callTool("localstack-extensions", { action: "available" }, 60000);
+ recordToolResult("extensions", "localstack-extensions available (Pro)", r, (x) => !x.isError && /Marketplace|extensions available/i.test(x.text));
+ } catch (e) { record("extensions", "extensions", false, String(e.message)); }
+ }
+
+ // 9. cleanup and remaining management lifecycle coverage
+ if (!NO_CLEANUP && !SKIP.has("deploy")) {
+ try { await callTool("localstack-deployer", { action: "destroy", projectType: "terraform", directory: DEPLOY_DIR }, 180000); } catch {}
+ }
+ if (!NO_CLEANUP && !SKIP.has("deploy-cdk")) {
+ try { await callTool("localstack-deployer", { action: "destroy", projectType: "cdk", directory: CDK_DIR }, 180000); } catch {}
+ }
+
+ if (!SKIP.has("restart")) {
+ try {
+ const r = await callTool("localstack-management", { action: "restart" }, 120000);
+ const ready = await callToolUntil(
+ "localstack-aws-client",
+ { command: "sts get-caller-identity" },
+ { attempts: 12, delayMs: 5000, timeoutMs: 30000 }
+ );
+ record("restart", "localstack-management restart", !r.isError && !ready.isError, `restart: ${snip(r.text, 300)} | readiness: ${snip(ready.text, 160)}`);
+ } catch (e) { record("restart", "management restart", false, String(e.message)); }
+ }
+
+ if (!SKIP.has("stop")) {
+ try {
+ const r = await callTool("localstack-management", { action: "stop" }, 60000);
+ record("stop", "localstack-management stop", !r.isError && /(stopped|stop command executed)/i.test(r.text), snip(r.text, 300));
+ } catch (e) { record("stop", "management stop", false, String(e.message)); }
+ }
+
+ // 10. Snowflake stack — starts a separate runtime flavor after the AWS stack is stopped.
+ if (!SKIP.has("snowflake")) {
+ try {
+ const start = await callTool("localstack-management", { action: "start", service: "snowflake" }, 240000);
+ const check = await callTool("localstack-snowflake-client", { action: "check-connection" }, 120000);
+ const exec = await callTool("localstack-snowflake-client", { action: "execute", file_path: SQL_FILE }, 180000);
+ const ok = !start.isError && !check.isError && !exec.isError;
+ recordToolResult("snowflake", "localstack-snowflake-client check-connection/execute file", { text: `start: ${start.text}\ncheck: ${check.text}\nexecute: ${exec.text}`, isError: !ok }, () => ok);
+ } catch (e) { record("snowflake", "snowflake-client", false, String(e.message)); }
+ finally {
+ if (!NO_CLEANUP) {
+ try { await callTool("localstack-management", { action: "stop" }, 60000); } catch {}
+ }
+ }
+ }
+}
+
+main()
+ .then(() => finish())
+ .catch((e) => { console.error("\nHARNESS ERROR:", e.message); finish(1); });
+
+function finish(forceCode) {
+ try { child.stdin.end(); } catch {}
+ try { child.kill("SIGTERM"); } catch {}
+ const hard = results.filter((r) => r.ok === false);
+ const warn = results.filter((r) => r.ok === "warn");
+ const pass = results.filter((r) => r.ok === true);
+ console.log("\n" + "=".repeat(64));
+ console.log(`SUMMARY: ${pass.length} passed, ${warn.length} warn, ${hard.length} failed`);
+ for (const r of results) {
+ const tag = r.ok === true ? "PASS" : r.ok === "warn" ? "WARN" : "FAIL";
+ console.log(` [${tag}] ${r.key} — ${r.name}`);
+ }
+ if (hard.length || forceCode) {
+ console.log("\n--- recent server stderr (last 25 lines) ---");
+ console.log(serverLog.slice(-25).join("\n"));
+ }
+ console.log("=".repeat(64));
+ process.exit(forceCode || (hard.length ? 1 : 0));
+}