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