diff --git a/README.md b/README.md index e0e397a..f6cb41c 100644 --- a/README.md +++ b/README.md @@ -77,14 +77,14 @@ This server provides your AI with dedicated tools for managing your LocalStack e | [`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 | | [`localstack-app-inspector`](./src/tools/localstack-app-inspector.ts) | Inspects LocalStack application traces, spans, events, and IAM evaluations | - Enable or disable App Inspector for the running LocalStack instance
- List and inspect traces to understand AWS service-to-service flows
- Drill into spans, events, payload metadata, and IAM policy evaluation events
- Filter by service, region, operation, resource, ARN, status, and time range
- Requires a valid LocalStack Auth Token and the App Inspector feature in the connected LocalStack license | | [`localstack-docs`](./src/tools/localstack-docs.ts) | Searches LocalStack documentation through CrawlChat | - Queries LocalStack docs through a public CrawlChat collection
- Returns focused snippets with source links only
- Helps answer coverage, configuration, and setup questions without requiring LocalStack runtime | -| [`localstack-snowflake-client`](./src/tools/localstack-snowflake-client.ts) | Runs SQL against the LocalStack Snowflake emulator through the `snow` CLI | - Execute SELECT, DDL (CREATE/DROP), DML (INSERT/UPDATE/DELETE), and SHOW/DESCRIBE statements from a query string or a `.sql` file
- Check the Snowflake connection before running queries
- Set optional database, schema, warehouse, and role context per query
- Requires the Snowflake CLI (`snow`) and a valid LocalStack Auth Token | +| [`localstack-snowflake-client`](./src/tools/localstack-snowflake-client.ts) | Runs SQL against the LocalStack Snowflake emulator through the `snow` CLI | - Execute SELECT, DDL (CREATE/DROP), DML (INSERT/UPDATE/DELETE), and SHOW/DESCRIBE statements from a query string or a `.sql` file
- Check the Snowflake connection before running queries
- Set optional database, schema, warehouse, and role context per query
- Requires the Snowflake CLI (`snow`) and a valid LocalStack Auth Token | ## Prompts Prompts are user-selected workflow templates exposed by MCP clients as slash commands or quick actions. They frame multi-step LocalStack tasks so the assistant follows the same phases, evidence requirements, and reporting format every time. -| Prompt Name | Description | Arguments | -| :---------------------- | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | :------------------------------------------------------------------------------------------------------------ | +| Prompt Name | Description | Arguments | +| :---------------------- | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | :----------------------------------------------------------------------------------------------------------- | | `infrastructure-tester` | Deploys an IaC project to LocalStack, validates declared resources with live AWS probes and App Inspector evidence, then writes and runs deterministic integration tests. | `iac_path` (required), `iac_type`, `test_language`, `test_framework`, `mode`, `services_focus`, `user_focus` | ## Installation @@ -100,7 +100,7 @@ npx -y @localstack/localstack-mcp-server init The wizard: - lets you choose how to run the server (`npx` on your machine, or the self-contained Docker image), -- checks the prerequisites (Node.js, LocalStack CLI, Docker) and tells you how to fix anything missing, +- checks the prerequisites (Node.js, a LocalStack lifecycle CLI, Docker) and tells you how to fix anything missing, - picks up your `LOCALSTACK_AUTH_TOKEN` from the environment, or asks for it, - lets you pass extra LocalStack config (e.g. `DEBUG=1,PERSISTENCE=1`), - detects your installed MCP clients (Cursor, Claude Code, Claude Desktop, VS Code, Codex, OpenCode, Amazon Q CLI) and writes the right configuration for each one you select. @@ -121,7 +121,7 @@ Run `npx -y @localstack/localstack-mcp-server init --help` for all options. ### Prerequisites -- [LocalStack CLI](https://docs.localstack.cloud/getting-started/installation/#localstack-cli) and Docker installed in your system path +- A LocalStack lifecycle CLI (`localstack` or `lstk`) and Docker installed in your system path if you want the MCP server to start or restart LocalStack - [`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**) @@ -199,15 +199,15 @@ See **[docs/DOCKER.md](./docs/DOCKER.md)** for the run command, MCP client confi ## LocalStack configuration -| Variable Name | Description | Default Value | -| -------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ----------------- | -| `LOCALSTACK_AUTH_TOKEN` (**required**) | The LocalStack Auth Token to use for the MCP server | None | -| `MAIN_CONTAINER_NAME` | The name of the LocalStack container to use for the MCP server | `localstack-main` | -| `MCP_ANALYTICS_DISABLED` | Disable MCP analytics when set to `1` | `0` | -| `APP_INSPECTOR` | Set to `1` in the LocalStack container environment to enable App Inspector by default across restarts. The MCP tool can also toggle App Inspector at runtime with `set-status`. | `0` | -| `AWS_ACCESS_KEY_ID` (**required for AWS Replicator tool**) | Source AWS access key used by AWS Replicator to read external AWS resources | None | -| `AWS_SECRET_ACCESS_KEY` (**required for AWS Replicator tool**) | Source AWS secret access key used by AWS Replicator to read external AWS resources | None | -| `AWS_DEFAULT_REGION` (**required for AWS Replicator tool**) | Source AWS region used by AWS Replicator | None | +| Variable Name | Description | Default Value | +| -------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------- | +| `LOCALSTACK_AUTH_TOKEN` (**required**) | The LocalStack Auth Token to use for the MCP server | None | +| `MAIN_CONTAINER_NAME` | The explicit LocalStack container name to use for Docker-based tools. When unset, the server auto-detects standard LocalStack containers such as `localstack-main` and `localstack-aws`, then LocalStack Docker images. | Auto-detect | +| `MCP_ANALYTICS_DISABLED` | Disable MCP analytics when set to `1` | `0` | +| `APP_INSPECTOR` | Set to `1` in the LocalStack container environment to enable App Inspector by default across restarts. The MCP tool can also toggle App Inspector at runtime with `set-status`. | `0` | +| `AWS_ACCESS_KEY_ID` (**required for AWS Replicator tool**) | Source AWS access key used by AWS Replicator to read external AWS resources | None | +| `AWS_SECRET_ACCESS_KEY` (**required for AWS Replicator tool**) | Source AWS secret access key used by AWS Replicator to read external AWS resources | None | +| `AWS_DEFAULT_REGION` (**required for AWS Replicator tool**) | Source AWS region used by AWS Replicator | None | For AWS Replicator-specific source credentials, you can use the `AWS_REPLICATOR_SOURCE_` prefixed variants instead of the unprefixed variants. Do not mix the prefixed and unprefixed source credential groups; when any `AWS_REPLICATOR_SOURCE_` variable is set, the Replicator tool reads the source configuration only from that group. diff --git a/docs/DOCKER.md b/docs/DOCKER.md index d0ff98a..352184e 100644 --- a/docs/DOCKER.md +++ b/docs/DOCKER.md @@ -10,10 +10,11 @@ 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. +`/var/run/docker.sock`. When you ask the server to start LocalStack, the bundled +`localstack start` launches a **sibling** `localstack-main` container on the host +(not nested inside the MCP container). Stop/restart operations then act on the +detected sibling container through the Docker API. The MCP server and the IaC CLIs +reach that sibling over the host gateway. ``` MCP client ── stdio ──► docker run … (MCP server) @@ -52,15 +53,15 @@ docker run -i --rm \ 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). | +| Flag | Why it's needed | +| -------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `-v /var/run/docker.sock:/var/run/docker.sock` | Lets the bundled LocalStack CLI start the sibling container and lets Docker-based tools stop/restart it and run `awslocal` inside it. | | `-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. | +| `--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 @@ -120,12 +121,12 @@ alias covers bootstrap asset uploads. ## 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. | +| 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). | +| `LocalStack container not found` or `Could not find a running LocalStack container named "localstack-main"` | Set `MAIN_CONTAINER_NAME` if you use a custom LocalStack container name. | ## Validating an image yourself diff --git a/src/cli/init.ts b/src/cli/init.ts index 5339ca9..5e2ff5a 100644 --- a/src/cli/init.ts +++ b/src/cli/init.ts @@ -85,7 +85,7 @@ async function resolveMethod( { value: "npx", label: "npx (Node on this machine)", - hint: "uses your local Node 20+, LocalStack CLI, and Docker", + hint: "uses your local Node 20+, localstack or lstk, and Docker", }, { value: "docker", diff --git a/src/lib/docker/docker.client.test.ts b/src/lib/docker/docker.client.test.ts index 0ae7e27..ca099bf 100644 --- a/src/lib/docker/docker.client.test.ts +++ b/src/lib/docker/docker.client.test.ts @@ -4,18 +4,31 @@ import { PassThrough } from "stream"; jest.mock("dockerode", () => { const listContainers = jest.fn(); const execInspect = jest.fn(); + const containerInspect = jest.fn(); const start = jest.fn((opts: any, cb: any) => { const stream = new PassThrough(); setImmediate(() => cb(null, stream)); return undefined as unknown as NodeJS.ReadableStream; }); const exec = jest.fn(async () => ({ start, inspect: execInspect })); - const getContainer = jest.fn(() => ({ exec })); + const stop = jest.fn(); + const remove = jest.fn(); + const getContainer = jest.fn(() => ({ exec, stop, remove, inspect: containerInspect })); const __state = { demuxTarget: "stdout" as "stdout" | "stderr" }; class DockerMock { - static __mocks = { listContainers, getContainer, exec, start, execInspect, __state }; + static __mocks = { + listContainers, + getContainer, + exec, + start, + execInspect, + containerInspect, + stop, + remove, + __state, + }; modem: any; constructor() { this.modem = { @@ -52,13 +65,22 @@ describe("DockerApiClient", () => { mocks.exec.mockReset(); mocks.start.mockReset(); mocks.execInspect.mockReset(); + mocks.containerInspect.mockReset(); + mocks.stop.mockReset(); + mocks.remove.mockReset(); // Restore default implementations after reset - mocks.getContainer.mockImplementation(() => ({ exec: mocks.exec })); + mocks.getContainer.mockImplementation(() => ({ + exec: mocks.exec, + stop: mocks.stop, + remove: mocks.remove, + inspect: mocks.containerInspect, + })); mocks.exec.mockImplementation(async () => ({ start: mocks.start, inspect: mocks.execInspect })); mocks.__state.demuxTarget = "stdout"; delete process.env.MAIN_CONTAINER_NAME; delete process.env.LOCALSTACK_MAIN_CONTAINER_NAME; + delete process.env.LOCALSTACK_PORT; }); test("findLocalStackContainer throws when none found", async () => { @@ -73,12 +95,38 @@ describe("DockerApiClient", () => { test("findLocalStackContainer returns id when found", async () => { const mocks = getDockerMocks(); - mocks.listContainers.mockResolvedValueOnce([{ Id: "abc123", Names: ["/localstack-main"] }]); + mocks.listContainers.mockResolvedValueOnce([ + { Id: "abc123", Names: ["/localstack-main"], Image: "localstack/localstack:latest" }, + ]); const client = new DockerApiClient(); await expect(client.findLocalStackContainer()).resolves.toBe("abc123"); }); + test("findLocalStackContainer matches known runtime names for non-standard LocalStack product images", async () => { + const mocks = getDockerMocks(); + mocks.listContainers.mockResolvedValueOnce([ + { Id: "snow123", Names: ["/localstack-main"], Image: "localstack/snowflake:latest" }, + ]); + + const client = new DockerApiClient(); + await expect(client.findLocalStackContainer()).resolves.toBe("snow123"); + }); + + test("findLocalStackContainer matches lstk's known runtime name for Azure emulator images", async () => { + const mocks = getDockerMocks(); + mocks.listContainers.mockResolvedValueOnce([ + { + Id: "azure123", + Names: ["/localstack-aws"], + Image: "localstack/localstack-azure-alpha:latest", + }, + ]); + + const client = new DockerApiClient(); + await expect(client.findLocalStackContainer()).resolves.toBe("azure123"); + }); + test("findLocalStackContainer matches MAIN_CONTAINER_NAME when configured", async () => { process.env.MAIN_CONTAINER_NAME = "my-custom-localstack"; @@ -103,7 +151,22 @@ describe("DockerApiClient", () => { await expect(client.findLocalStackContainer()).resolves.toBe("lstk123"); }); - test("findLocalStackContainer prefers the container publishing the configured gateway port over image metadata", async () => { + test("findLocalStackContainer detects mirrored LocalStack images by gateway port", async () => { + const mocks = getDockerMocks(); + mocks.listContainers.mockResolvedValueOnce([ + { + Id: "mirror123", + Names: ["/gateway-runtime"], + Image: "registry.example.com/localstack/localstack-pro:latest", + Ports: [{ PrivatePort: 4566, PublicPort: 4566, Type: "tcp" }], + }, + ]); + + const client = new DockerApiClient(); + await expect(client.findLocalStackContainer()).resolves.toBe("mirror123"); + }); + + test("findLocalStackContainer prefers the LocalStack image publishing the configured gateway port over other LocalStack images", async () => { const mocks = getDockerMocks(); mocks.listContainers.mockResolvedValueOnce([ { @@ -114,7 +177,7 @@ describe("DockerApiClient", () => { { Id: "port123", Names: ["/gateway-runtime"], - Image: "example/custom-runtime:latest", + Image: "localstack/localstack:latest", Ports: [{ PrivatePort: 4566, PublicPort: 4566, Type: "tcp" }], }, ]); @@ -123,6 +186,71 @@ describe("DockerApiClient", () => { await expect(client.findLocalStackContainer()).resolves.toBe("port123"); }); + test("findLocalStackContainer does not match a non-LocalStack container by port alone", async () => { + const mocks = getDockerMocks(); + mocks.listContainers.mockResolvedValueOnce([ + { + Id: "not-localstack", + Names: ["/plain-node"], + Image: "node:22-bookworm-slim", + Ports: [{ PrivatePort: 4566, PublicPort: 4566, Type: "tcp" }], + }, + ]); + + const client = new DockerApiClient(); + await expect(client.findLocalStackContainer()).rejects.toThrow( + /Could not find a running LocalStack container/i + ); + }); + + test("findLocalStackContainer does not match non-runtime LocalStack namespace images", async () => { + const mocks = getDockerMocks(); + mocks.listContainers.mockResolvedValueOnce([ + { + Id: "mcp-dev", + Names: ["/localstack-mcp-dev"], + Image: "localstack/localstack-mcp-server:latest", + Ports: [{ PrivatePort: 4566, PublicPort: 4566, Type: "tcp" }], + }, + ]); + + const client = new DockerApiClient(); + await expect(client.findLocalStackContainer()).rejects.toThrow( + /Could not find a running LocalStack container/i + ); + }); + + test("findLocalStackContainer honors an explicit LOCALSTACK_PORT before falling back to image-only matches", async () => { + process.env.LOCALSTACK_PORT = "4567"; + const mocks = getDockerMocks(); + mocks.listContainers.mockResolvedValueOnce([ + { + Id: "default-runtime", + Names: ["/gateway-runtime"], + Image: "localstack/localstack-pro:latest", + Ports: [{ PrivatePort: 4566, PublicPort: 4566, Type: "tcp" }], + }, + ]); + + const client = new DockerApiClient(); + await expect(client.findLocalStackContainer()).rejects.toThrow( + /none publishes the configured gateway port 4567/i + ); + }); + + test("findLocalStackContainer rejects ambiguous LocalStack image matches without a port or explicit name", async () => { + const mocks = getDockerMocks(); + mocks.listContainers.mockResolvedValueOnce([ + { Id: "ls1", Names: ["/localstack-sidecar-1"], Image: "localstack/localstack:latest" }, + { Id: "ls2", Names: ["/localstack-sidecar-2"], Image: "localstack/localstack-pro:latest" }, + ]); + + const client = new DockerApiClient(); + await expect(client.findLocalStackContainer()).rejects.toThrow( + /Found multiple running LocalStack containers/i + ); + }); + test("findLocalStackContainer honors explicit MAIN_CONTAINER_NAME over metadata fallback", async () => { process.env.MAIN_CONTAINER_NAME = "my-custom-localstack"; @@ -151,9 +279,67 @@ describe("DockerApiClient", () => { ); }); + test("stopContainer stops the container via the Docker API", async () => { + const mocks = getDockerMocks(); + mocks.stop.mockResolvedValueOnce(undefined); + mocks.remove.mockResolvedValueOnce(undefined); + + const client = new DockerApiClient(); + await client.stopContainer("abc123", 5); + + expect(mocks.getContainer).toHaveBeenCalledWith("abc123"); + expect(mocks.stop).toHaveBeenCalledWith({ t: 5 }); + expect(mocks.remove).toHaveBeenCalledWith(); + }); + + test("stopContainer ignores remove errors when --rm already removed the container", async () => { + const mocks = getDockerMocks(); + mocks.stop.mockResolvedValueOnce(undefined); + mocks.remove.mockRejectedValueOnce( + Object.assign(new Error("No such container"), { statusCode: 404 }) + ); + + const client = new DockerApiClient(); + await expect(client.stopContainer("abc123", 5)).resolves.toBeUndefined(); + }); + + test("stopContainer ignores remove conflicts when Docker auto-removal is already in progress", async () => { + const mocks = getDockerMocks(); + mocks.stop.mockResolvedValueOnce(undefined); + mocks.remove.mockRejectedValueOnce( + Object.assign(new Error("removal of container abc123 is already in progress"), { + statusCode: 409, + }) + ); + + const client = new DockerApiClient(); + await expect(client.stopContainer("abc123", 5)).resolves.toBeUndefined(); + }); + + test("inspectContainer returns normalized container metadata", async () => { + const mocks = getDockerMocks(); + mocks.containerInspect.mockResolvedValueOnce({ + Name: "/localstack-aws", + Config: { + Image: "localstack/localstack-pro:latest", + Env: ["MAIN_CONTAINER_NAME=localstack-aws"], + }, + }); + + const client = new DockerApiClient(); + await expect(client.inspectContainer("abc123")).resolves.toEqual({ + id: "abc123", + name: "localstack-aws", + image: "localstack/localstack-pro:latest", + env: ["MAIN_CONTAINER_NAME=localstack-aws"], + }); + }); + test("executeInContainer returns stdout on success", async () => { const mocks = getDockerMocks(); - mocks.listContainers.mockResolvedValueOnce([{ Id: "abc123", Names: ["/localstack-main"] }]); + mocks.listContainers.mockResolvedValueOnce([ + { Id: "abc123", Names: ["/localstack-main"], Image: "localstack/localstack:latest" }, + ]); // prepare exec.inspect to return 0 mocks.execInspect.mockResolvedValueOnce({ ExitCode: 0 }); @@ -184,7 +370,9 @@ describe("DockerApiClient", () => { test("executeInContainer returns stderr on failure", async () => { const mocks = getDockerMocks(); - mocks.listContainers.mockResolvedValueOnce([{ Id: "abc123", Names: ["/localstack-main"] }]); + mocks.listContainers.mockResolvedValueOnce([ + { Id: "abc123", Names: ["/localstack-main"], Image: "localstack/localstack:latest" }, + ]); mocks.execInspect.mockResolvedValueOnce({ ExitCode: 2 }); const client = new DockerApiClient(); diff --git a/src/lib/docker/docker.client.ts b/src/lib/docker/docker.client.ts index 5d957f9..4394181 100644 --- a/src/lib/docker/docker.client.ts +++ b/src/lib/docker/docker.client.ts @@ -7,6 +7,27 @@ export interface ContainerExecResult { exitCode: number; } +export interface ContainerMetadata { + id: string; + name?: string; + image?: string; + env?: string[]; +} + +export class LocalStackContainerNotFoundError extends Error { + constructor(message: string) { + super(message); + this.name = "LocalStackContainerNotFoundError"; + } +} + +export function isLocalStackContainerNotFoundError(error: unknown): boolean { + return ( + error instanceof LocalStackContainerNotFoundError || + (error instanceof Error && error.name === "LocalStackContainerNotFoundError") + ); +} + export class DockerApiClient { private docker: any; @@ -38,7 +59,48 @@ export class DockerApiClient { } private hasLocalStackImage(container: { Image?: string }): boolean { - return /^localstack\/localstack(?:-pro)?(?::|@|$)/.test(container.Image || ""); + return /^(?:[^/]+\/)?localstack\/(?:localstack(?:-pro)?|snowflake|localstack-azure-alpha)(?::|@|$)/.test( + container.Image || "" + ); + } + + private async withDockerRequestTimeout( + operation: Promise, + timeoutMs: number, + description: string + ): Promise { + let timer: NodeJS.Timeout; + const timeout = new Promise((_, reject) => { + timer = setTimeout( + () => reject(new Error(`${description} timed out after ${timeoutMs}ms`)), + timeoutMs + ); + }); + try { + return await Promise.race([operation, timeout]); + } finally { + clearTimeout(timer!); + } + } + + private isContainerAlreadyGone(error: unknown): boolean { + if (!error || typeof error !== "object") return false; + const dockerError = error as { statusCode?: number; reason?: string; message?: string }; + const text = `${dockerError.reason || ""} ${dockerError.message || ""}`.toLowerCase(); + return ( + dockerError.statusCode === 404 || + text.includes("no such container") || + ((dockerError.statusCode === 409 || text.includes("removal of container")) && + text.includes("already in progress")) + ); + } + + private findByKnownLocalStackName( + containers: T[] + ): T | undefined { + return ["localstack-main", "localstack-aws"] + .map((name) => containers.find((c) => this.matchesConfiguredContainerName(c, name))) + .find(Boolean); } async findLocalStackContainer(): Promise { @@ -58,25 +120,84 @@ export class DockerApiClient { ).trim(); const configuredName = explicitName || "localstack-main"; - const byConfiguredName = (running || []).find((c) => - this.matchesConfiguredContainerName(c, configuredName) - ); - if (byConfiguredName) return byConfiguredName.Id as string; + if (explicitName) { + const byConfiguredName = (running || []).find((c) => + this.matchesConfiguredContainerName(c, configuredName) + ); + if (byConfiguredName) return byConfiguredName.Id as string; + } if (!explicitName) { - const byGatewayPort = (running || []).find((c) => this.publishesConfiguredGatewayPort(c)); + const byKnownName = this.findByKnownLocalStackName(running || []); + if (byKnownName) return byKnownName.Id as string; + + const localstackImages = (running || []).filter((c) => this.hasLocalStackImage(c)); + const byGatewayPort = localstackImages.find((c) => this.publishesConfiguredGatewayPort(c)); if (byGatewayPort) return byGatewayPort.Id as string; - const byLocalStackImage = (running || []).find((c) => this.hasLocalStackImage(c)); - if (byLocalStackImage) return byLocalStackImage.Id as string; + const explicitPort = Boolean(process.env.LOCALSTACK_PORT?.trim()); + if (explicitPort && localstackImages.length > 0) { + throw new LocalStackContainerNotFoundError( + `Found running LocalStack containers, but none publishes the configured gateway port ${process.env.LOCALSTACK_PORT}. ` + + `Set MAIN_CONTAINER_NAME to the container name to use.` + ); + } + + if (localstackImages.length === 1) return localstackImages[0].Id as string; + if (localstackImages.length > 1) { + throw new LocalStackContainerNotFoundError( + `Found multiple running LocalStack containers but none publishes the configured gateway port ${process.env.LOCALSTACK_PORT || LOCALSTACK_PORT}. ` + + `Set MAIN_CONTAINER_NAME to the container name to use.` + ); + } } - throw new Error( + throw new LocalStackContainerNotFoundError( `Could not find a running LocalStack container named "${configuredName}". ` + `Set MAIN_CONTAINER_NAME to your container name if it is custom.` ); } + async inspectContainer(containerId: string): Promise { + const container = this.docker.getContainer(containerId); + const inspect = await container.inspect(); + return { + id: containerId, + name: this.normalizeContainerName(inspect?.Name), + image: inspect?.Config?.Image, + env: inspect?.Config?.Env, + }; + } + + /** + * Stop a container via the Docker API (graceful SIGTERM, then SIGKILL after the + * timeout). Provenance-agnostic — works regardless of which CLI started it (or none) + * and needs no host-side `localstack`/`lstk` binary. Remove after stop so `lstk` + * containers, which are not started with `--rm`, do not linger. + */ + async stopContainer( + containerId: string, + timeoutSeconds = 10, + requestTimeoutMs = 60000 + ): Promise { + const container = this.docker.getContainer(containerId); + await this.withDockerRequestTimeout( + container.stop({ t: timeoutSeconds }), + requestTimeoutMs, + "Docker container stop" + ); + + try { + await this.withDockerRequestTimeout( + container.remove(), + requestTimeoutMs, + "Docker container remove" + ); + } catch (error) { + if (!this.isContainerAlreadyGone(error)) throw error; + } + } + async executeInContainer( containerId: string, command: string[], diff --git a/src/lib/localstack/localstack.utils.test.ts b/src/lib/localstack/localstack.utils.test.ts index abdaa0e..ccbc913 100644 --- a/src/lib/localstack/localstack.utils.test.ts +++ b/src/lib/localstack/localstack.utils.test.ts @@ -1,10 +1,16 @@ import { + checkLocalStackCli, + detectLifecycleCli, getGatewayHealth, getLocalStackStatus, getSnowflakeEmulatorStatus, + startRuntime, } from "./localstack.utils"; import { runCommand } from "../../core/command-runner"; import { httpClient } from "../../core/http-client"; +import { spawn } from "child_process"; +import { EventEmitter } from "events"; +import { PassThrough } from "stream"; jest.mock("../../core/command-runner", () => ({ runCommand: jest.fn(), @@ -15,8 +21,21 @@ jest.mock("../../core/http-client", () => ({ HttpError: class HttpError extends Error {}, })); +jest.mock("child_process", () => ({ + spawn: jest.fn(), +})); + const mockedRunCommand = runCommand as jest.MockedFunction; const mockedRequest = httpClient.request as jest.MockedFunction; +const mockedSpawn = spawn as jest.MockedFunction; + +function mockChildProcess() { + const child = new EventEmitter() as any; + child.stdout = new PassThrough(); + child.stderr = new PassThrough(); + mockedSpawn.mockReturnValueOnce(child); + return child; +} const cliUnavailable = () => mockedRunCommand.mockResolvedValue({ @@ -32,6 +51,7 @@ describe("localstack.utils", () => { beforeEach(() => { mockedRunCommand.mockReset(); mockedRequest.mockReset(); + mockedSpawn.mockReset(); }); describe("getGatewayHealth", () => { @@ -65,6 +85,14 @@ describe("localstack.utils", () => { expect(health.reachable).toBe(false); expect(health.ready).toBe(false); }); + + test("does not treat arbitrary text responses as a LocalStack gateway", async () => { + mockedRequest.mockResolvedValueOnce("not localstack" as any); + + const health = await getGatewayHealth(); + expect(health.reachable).toBe(false); + expect(health.ready).toBe(false); + }); }); describe("getLocalStackStatus", () => { @@ -144,6 +172,65 @@ describe("localstack.utils", () => { }); }); + describe("detectLifecycleCli", () => { + const onlyAvailable = (bin: string) => + mockedRunCommand.mockImplementation(async (cmd: string) => + cmd === bin + ? ({ stdout: `${bin} 1.0.0`, stderr: "", exitCode: 0 } as never) + : ({ stdout: "", stderr: "", exitCode: null, error: new Error("ENOENT") } as never) + ); + + test("prefers the localstack CLI when available", async () => { + onlyAvailable("localstack"); + expect(await detectLifecycleCli()).toBe("localstack"); + }); + + test("falls back to lstk when only lstk is present", async () => { + onlyAvailable("lstk"); + expect(await detectLifecycleCli()).toBe("lstk"); + }); + + test("uses the requested CLI preference order", async () => { + mockedRunCommand.mockResolvedValue({ + stdout: "ok", + stderr: "", + exitCode: 0, + } as never); + + expect(await detectLifecycleCli(["lstk", "localstack"])).toBe("lstk"); + expect(mockedRunCommand).toHaveBeenCalledWith( + "lstk", + ["--version"], + expect.objectContaining({ timeout: 5000 }) + ); + }); + + test("returns null when neither CLI is installed", async () => { + mockedRunCommand.mockResolvedValue({ + stdout: "", + stderr: "", + exitCode: null, + error: new Error("ENOENT"), + } as never); + expect(await detectLifecycleCli()).toBeNull(); + }); + }); + + describe("checkLocalStackCli", () => { + test("reports unavailable when runCommand resolves with ENOENT", async () => { + mockedRunCommand.mockResolvedValue({ + stdout: "", + stderr: "", + exitCode: null, + error: new Error("spawn localstack ENOENT"), + } as never); + + const result = await checkLocalStackCli(); + expect(result.isAvailable).toBe(false); + expect(result.errorMessage).toMatch(/not installed|not available/i); + }); + }); + describe("getSnowflakeEmulatorStatus", () => { test("marks emulator healthy on success payload", async () => { mockedRunCommand.mockResolvedValueOnce({ @@ -170,4 +257,63 @@ describe("localstack.utils", () => { expect(result.isReady).toBe(false); }); }); + + describe("startRuntime", () => { + test("reports lstk zero-exit failures immediately with stdout when readiness is absent", async () => { + const child = mockChildProcess(); + const getStatus = jest + .fn() + .mockResolvedValueOnce({ isRunning: false, isReady: false }) + .mockResolvedValueOnce({ isRunning: false, isReady: false }); + + const resultPromise = startRuntime({ + cli: "lstk", + startArgs: ["start", "--non-interactive"], + getStatus, + processLabel: "LocalStack", + alreadyRunningMessage: "already running", + successTitle: "started", + statusHeading: "Status", + timeoutMessage: "timed out", + }); + + await Promise.resolve(); + child.stdout.write("Error: Port 4566 already in use\n"); + child.emit("close", 0); + + const text = (await resultPromise).content[0].text; + expect(text).toMatch(/exited before it became ready/i); + expect(text).toContain("Port 4566 already in use"); + }); + + test("treats lstk zero-exit as success when readiness is already available", async () => { + const child = mockChildProcess(); + const getStatus = jest + .fn() + .mockResolvedValueOnce({ isRunning: false, isReady: false }) + .mockResolvedValueOnce({ + isRunning: true, + isReady: true, + statusOutput: "LocalStack gateway is reachable", + }); + + const resultPromise = startRuntime({ + cli: "lstk", + startArgs: ["start", "--non-interactive"], + getStatus, + processLabel: "LocalStack", + alreadyRunningMessage: "already running", + successTitle: "started", + statusHeading: "Status", + timeoutMessage: "timed out", + }); + + await Promise.resolve(); + child.emit("close", 0); + + const text = (await resultPromise).content[0].text; + expect(text).toContain("started"); + expect(text).toContain("LocalStack gateway is reachable"); + }); + }); }); diff --git a/src/lib/localstack/localstack.utils.ts b/src/lib/localstack/localstack.utils.ts index 31df821..e73fee5 100644 --- a/src/lib/localstack/localstack.utils.ts +++ b/src/lib/localstack/localstack.utils.ts @@ -16,35 +16,85 @@ export interface SnowflakeCliCheckResult { errorMessage?: string; } +const CLI_CHECK_TIMEOUT = 5000; +const SNOWFLAKE_CLI_CHECK_TIMEOUT = 30000; + +function cliSpawnOptions(timeoutMs: number = CLI_CHECK_TIMEOUT) { + return { + timeout: timeoutMs, + shell: process.platform === "win32", + }; +} + +function localStackCliUnavailable(details?: string): LocalStackCliCheckResult { + const suffix = details?.trim() ? `\n\nDetails: ${details.trim()}` : ""; + return { + isAvailable: false, + errorMessage: `❌ LocalStack CLI is not installed or not available in PATH. + +Please install LocalStack by following the official documentation: +https://docs.localstack.cloud/aws/getting-started/installation/ + +Installation options: +- Using pip: pip install localstack +- Using Docker: Use the LocalStack Docker image +- Using Homebrew (macOS): brew install localstack/tap/localstack-cli + +After installation, make sure the 'localstack' command is available in your PATH.${suffix}`, + }; +} + /** * Check if LocalStack CLI is installed and available in the system PATH * @returns Promise with availability status, version (if available), and error message (if not available) */ export async function checkLocalStackCli(): Promise { try { - await runCommand("localstack", ["--help"]); - const { stdout: version } = await runCommand("localstack", ["--version"]); + const help = await runCommand("localstack", ["--help"], cliSpawnOptions()); + if (help.error || help.exitCode !== 0) { + return localStackCliUnavailable(help.error?.message || help.stderr); + } + + const versionResult = await runCommand("localstack", ["--version"], cliSpawnOptions()); + if (versionResult.error || versionResult.exitCode !== 0) { + return localStackCliUnavailable(versionResult.error?.message || versionResult.stderr); + } return { isAvailable: true, - version: version.trim(), + version: versionResult.stdout.trim(), }; } catch (error) { - return { - isAvailable: false, - errorMessage: `❌ LocalStack CLI is not installed or not available in PATH. + return localStackCliUnavailable(error instanceof Error ? error.message : String(error)); + } +} -Please install LocalStack by following the official documentation: -https://docs.localstack.cloud/aws/getting-started/installation/ +export type LifecycleCli = "localstack" | "lstk"; -Installation options: -- Using pip: pip install localstack -- Using Docker: Use the LocalStack Docker image -- Using Homebrew (macOS): brew install localstack/tap/localstack-cli +/** + * Whether a CLI is usable. `runCommand` resolves (rather than throws) on a missing + * binary, so we inspect the actual exit code / error — this returns false when the + * binary isn't on PATH. + */ +async function cliAvailable(bin: string): Promise { + const { error, exitCode } = await runCommand(bin, ["--version"], cliSpawnOptions()); + return !error && exitCode === 0; +} -After installation, make sure the 'localstack' command is available in your PATH.`, - }; +/** + * Pick a CLI capable of starting LocalStack. Prefers the Python `localstack` CLI for + * backward compatibility, falling back to `lstk` (the newer Go CLI, which also forwards + * `LOCALSTACK_*` env). Returns null when neither is installed: a running container can + * still be detected and driven via the gateway + Docker API, but *creating* one needs a + * CLI. + */ +export async function detectLifecycleCli( + preferredOrder: LifecycleCli[] = ["localstack", "lstk"] +): Promise { + for (const cli of preferredOrder) { + if (await cliAvailable(cli)) return cli; } + return null; } /** @@ -53,14 +103,21 @@ After installation, make sure the 'localstack' command is available in your PATH */ export async function checkSnowflakeCli(): Promise { try { - await runCommand("snow", ["--help"]); - const { stdout: version } = await runCommand("snow", ["--version"]); + const { stdout, error, exitCode, stderr } = await runCommand( + "snow", + ["--version"], + cliSpawnOptions(SNOWFLAKE_CLI_CHECK_TIMEOUT) + ); + if (error || exitCode !== 0) { + throw error || new Error(stderr || `snow --version exited with code ${exitCode}`); + } return { isAvailable: true, - version: version.trim(), + version: stdout.trim(), }; } catch (error) { + const details = error instanceof Error ? error.message : String(error); return { isAvailable: false, errorMessage: `❌ Snowflake CLI (snow) is not installed or not available in PATH. @@ -72,7 +129,9 @@ Installation options: - Using pip: pip install snowflake-cli-labs - Using Homebrew (macOS): brew install snowflake-cli -After installation, make sure the 'snow' command is available in your PATH.`, +After installation, make sure the 'snow' command is available in your PATH.${ + details.trim() ? `\n\nDetails: ${details.trim()}` : "" + }`, }; } } @@ -150,7 +209,11 @@ export async function getGatewayHealth(): Promise { version?: string; }>("/_localstack/health", { method: "GET", timeout: GATEWAY_HEALTH_TIMEOUT }); - const services = data && typeof data === "object" && data.services ? data.services : undefined; + if (!data || typeof data !== "object" || Array.isArray(data)) { + return { reachable: false, ready: false }; + } + + const services = data.services ? data.services : undefined; const ready = Object.values(services || {}).some((state) => READY_SERVICE_STATES.has(state)); @@ -303,6 +366,7 @@ export async function getSnowflakeEmulatorStatus(): Promise Promise; processLabel: string; @@ -322,7 +388,7 @@ export async function startRuntime({ timeoutMessage: string; envVars?: Record; onReady?: () => Promise | null>; -}) { +}): Promise> { const statusCheck = await getStatus(); if (statusCheck.isReady || statusCheck.isRunning) { return ResponseBuilder.markdown(alreadyRunningMessage); @@ -336,29 +402,77 @@ export async function startRuntime({ if (process.env.LOCALSTACK_AUTH_TOKEN) { environment.LOCALSTACK_AUTH_TOKEN = process.env.LOCALSTACK_AUTH_TOKEN; } + // Force UTF-8 for the spawned Python `localstack` CLI so its emoji output doesn't + // throw UnicodeEncodeError under the Windows cp1252 code page. + if (cli === "localstack") { + if (!environment.PYTHONIOENCODING) environment.PYTHONIOENCODING = "utf-8"; + if (!environment.PYTHONUTF8) environment.PYTHONUTF8 = "1"; + } return new Promise((resolve) => { - const child = spawn("localstack", startArgs, { + const child = spawn(cli, startArgs, { env: environment, - stdio: ["ignore", "ignore", "pipe"], + stdio: ["ignore", "pipe", "pipe"], + shell: process.platform === "win32", }); + let stdout = ""; let stderr = ""; - child.stderr.on("data", (data) => { + child.stdout?.on("data", (data) => { + stdout += data.toString(); + }); + child.stderr?.on("data", (data) => { stderr += data.toString(); }); let earlyExit = false; let poll: NodeJS.Timeout; + let resolved = false; + const finish = (response: ReturnType) => { + if (resolved) return; + resolved = true; + if (poll) clearInterval(poll); + resolve(response); + }; + const failureDetails = () => { + const details: string[] = []; + const trimmedStdout = stdout.trim(); + const trimmedStderr = stderr.trim(); + if (trimmedStdout) details.push(`Stdout:\n${trimmedStdout}`); + if (trimmedStderr) details.push(`Stderr:\n${trimmedStderr}`); + return details.length ? `\n\n${details.join("\n\n")}` : ""; + }; + const successResponse = (status: RuntimeStatus) => { + let resultMessage = `${successTitle}\n\n`; + if (envVars) + resultMessage += `✅ Custom environment variables passed to lifecycle CLI: ${Object.keys(envVars).join(", ")}\n`; + if (status.statusOutput) resultMessage += `\n**${statusHeading}:**\n${status.statusOutput}`; + return ResponseBuilder.markdown(resultMessage); + }; + const finishIfReady = async (): Promise => { + const status = await getStatus(); + if (!(status.isReady || status.isRunning)) return false; + + if (onReady) { + const preflight = await onReady(); + if (preflight) { + finish(preflight); + return true; + } + } + + finish(successResponse(status)); + return true; + }; + child.on("error", (err) => { earlyExit = true; - if (poll) clearInterval(poll); - resolve( + finish( ResponseBuilder.markdown(`❌ Failed to start ${processLabel} process: ${err.message}`) ); }); - child.on("close", (code) => { + child.on("close", async (code) => { if (earlyExit) return; // 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 @@ -367,10 +481,31 @@ export async function startRuntime({ // 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( + finish( + ResponseBuilder.markdown( + `❌ ${processLabel} process exited unexpectedly with code ${code}.${failureDetails()}` + ) + ); + return; + } + + if (cli === "lstk") { + try { + if (await finishIfReady()) return; + } catch (error) { + finish( + ResponseBuilder.markdown( + `❌ ${processLabel} process exited before it became ready. Failed to verify status: ${ + error instanceof Error ? error.message : String(error) + }${failureDetails()}` + ) + ); + return; + } + + finish( ResponseBuilder.markdown( - `❌ ${processLabel} process exited unexpectedly with code ${code}.\n\nStderr:\n${stderr}` + `❌ ${processLabel} process exited before it became ready.${failureDetails()}` ) ); } @@ -382,26 +517,22 @@ export async function startRuntime({ poll = setInterval(async () => { timeWaited += pollInterval; - const status = await getStatus(); - if (status.isReady || status.isRunning) { - if (onReady) { - const preflight = await onReady(); - if (preflight) { - clearInterval(poll); - resolve(preflight); - return; - } - } + try { + if (await finishIfReady()) return; + } catch (error) { + finish( + ResponseBuilder.markdown( + `❌ Failed to check ${processLabel} status: ${ + error instanceof Error ? error.message : String(error) + }${failureDetails()}` + ) + ); + return; + } - clearInterval(poll); - let resultMessage = `${successTitle}\n\n`; - if (envVars) - resultMessage += `✅ Custom environment variables applied: ${Object.keys(envVars).join(", ")}\n`; - if (status.statusOutput) resultMessage += `\n**${statusHeading}:**\n${status.statusOutput}`; - resolve(ResponseBuilder.markdown(resultMessage)); - } else if (timeWaited >= maxWaitTime) { - clearInterval(poll); - resolve(ResponseBuilder.markdown(timeoutMessage)); + if (timeWaited >= maxWaitTime) { + const details = failureDetails(); + finish(ResponseBuilder.markdown(details ? `${timeoutMessage}${details}` : timeoutMessage)); } }, pollInterval); }); diff --git a/src/lib/logs/log-retriever.test.ts b/src/lib/logs/log-retriever.test.ts new file mode 100644 index 0000000..afa5156 --- /dev/null +++ b/src/lib/logs/log-retriever.test.ts @@ -0,0 +1,28 @@ +import { runCommand } from "../../core/command-runner"; +import { LocalStackLogRetriever } from "./log-retriever"; + +jest.mock("../../core/command-runner", () => ({ + runCommand: jest.fn(), +})); + +const mockedRunCommand = runCommand as jest.MockedFunction; + +describe("LocalStackLogRetriever", () => { + beforeEach(() => { + mockedRunCommand.mockReset(); + }); + + test("reports command failures instead of parsing empty output as clean logs", async () => { + mockedRunCommand.mockResolvedValueOnce({ + stdout: "", + stderr: "", + exitCode: null, + error: new Error("spawn localstack ENOENT"), + } as never); + + const result = await new LocalStackLogRetriever().retrieveLogs(10); + expect(result.success).toBe(false); + expect(result.errorMessage).toMatch(/Failed to retrieve logs/i); + expect(result.errorMessage).toMatch(/ENOENT/i); + }); +}); diff --git a/src/lib/logs/log-retriever.ts b/src/lib/logs/log-retriever.ts index 14e0dca..bcfe9b4 100644 --- a/src/lib/logs/log-retriever.ts +++ b/src/lib/logs/log-retriever.ts @@ -39,6 +39,20 @@ export class LocalStackLogRetriever { timeout: 30000, }); + if (cmd.error || cmd.exitCode !== 0) { + const details = [cmd.stderr, cmd.stdout, cmd.error?.message] + .filter((part) => part && part.trim()) + .join("\n"); + return { + success: false, + logs: [], + totalLines: 0, + errorMessage: details + ? `Failed to retrieve logs: ${details}` + : "Failed to retrieve logs from the LocalStack CLI.", + }; + } + if (!cmd.stdout && cmd.stderr) { return { success: false, diff --git a/src/lib/wizard/prereqs.ts b/src/lib/wizard/prereqs.ts index 525197b..2c257f7 100644 --- a/src/lib/wizard/prereqs.ts +++ b/src/lib/wizard/prereqs.ts @@ -49,16 +49,21 @@ export async function checkPrereqs(method: InstallMethod): Promise localstackOk || lstkOk) + : Promise.resolve(false); if (method === "npx") { results.push(checkNodeVersion()); results.push({ - name: "LocalStack CLI", - ok: await localstackPromise, + name: "LocalStack lifecycle CLI", + ok: await lifecycleCliPromise, fatal: false, - hint: "install it with `brew install localstack/tap/localstack-cli` or `pip install localstack` — needed by the lifecycle tools", + hint: "install either `localstack` (`brew install localstack/tap/localstack-cli` or `pip install localstack`) or `lstk` — needed when the MCP server starts or restarts LocalStack", }); } diff --git a/src/tools/localstack-management.ts b/src/tools/localstack-management.ts index 7edc18c..3ba1079 100644 --- a/src/tools/localstack-management.ts +++ b/src/tools/localstack-management.ts @@ -1,22 +1,27 @@ import { z } from "zod"; import { type ToolMetadata, type InferSchema } from "xmcp"; import { + detectLifecycleCli, + type LifecycleCli, getLocalStackStatus, getSnowflakeEmulatorStatus, startRuntime, } from "../lib/localstack/localstack.utils"; -import { runCommand } from "../core/command-runner"; import { - runPreflights, - requireLocalStackCli, - requireProFeature, - requireAuthToken, -} from "../core/preflight"; + DockerApiClient, + isLocalStackContainerNotFoundError, + type ContainerMetadata, +} from "../lib/docker/docker.client"; +import { runPreflights, requireProFeature, requireAuthToken } from "../core/preflight"; import { ResponseBuilder } from "../core/response-builder"; import { ProFeature } from "../lib/localstack/license-checker"; import { withToolAnalytics } from "../core/analytics"; type ToolResponse = ReturnType; +const AWS_ALREADY_RUNNING_MESSAGE = + "⚠️ LocalStack is already running. Use 'restart' if you want to apply new configuration."; +const SNOWFLAKE_ALREADY_RUNNING_MESSAGE = + "⚠️ Snowflake emulator is already running. Use 'restart' if you want to apply new configuration."; export const schema = { action: z @@ -51,8 +56,10 @@ export default async function localstackManagement({ envVars, }: InferSchema) { return withToolAnalytics("localstack-management", { action, service, envVars }, async () => { + // No CLI preflight: stop/restart/status drive LocalStack via the Docker API + + // gateway, and start detects whichever lifecycle CLI is present (localstack or + // lstk) itself — so an lstk-only host is no longer blocked here. const checks: Array> = [requireAuthToken()]; - if (action !== "status") checks.push(requireLocalStackCli()); if (service === "snowflake") { // `start` can run when no LocalStack runtime is currently up; validate feature after startup. @@ -84,20 +91,31 @@ export default async function localstackManagement({ async function handleStart({ envVars, service, + cli, }: { envVars?: Record; service: "aws" | "snowflake"; + cli?: LifecycleCli; }) { if (service === "snowflake") { return await handleSnowflakeStart({ envVars }); } + const status = await getLocalStackStatus({ includeCliStatus: false }); + if (status.isReady || status.isRunning) { + return ResponseBuilder.markdown(AWS_ALREADY_RUNNING_MESSAGE); + } + + const lifecycleCli = cli || (await detectLifecycleCli()); + if (!lifecycleCli) return noLifecycleCliFoundResponse("Starting"); + return await startRuntime({ - startArgs: ["start"], + cli: lifecycleCli, + // lstk would otherwise prompt; force non-interactive when spawned headless. + startArgs: lifecycleCli === "lstk" ? ["start", "--non-interactive"] : ["start"], getStatus: () => getLocalStackStatus({ includeCliStatus: false }), processLabel: "LocalStack", - alreadyRunningMessage: - "⚠️ LocalStack is already running. Use 'restart' if you want to apply new configuration.", + alreadyRunningMessage: AWS_ALREADY_RUNNING_MESSAGE, successTitle: "🚀 LocalStack started successfully!", statusHeading: "Status", timeoutMessage: @@ -107,12 +125,25 @@ async function handleStart({ } async function handleSnowflakeStart({ envVars }: { envVars?: Record }) { + const status = await getSnowflakeEmulatorStatus(); + if (status.isReady || status.isRunning) { + return ResponseBuilder.markdown(SNOWFLAKE_ALREADY_RUNNING_MESSAGE); + } + + // The Snowflake stack is localstack-only (`--stack snowflake` has no lstk equivalent). + if ((await detectLifecycleCli(["localstack"])) !== "localstack") { + return ResponseBuilder.error( + "localstack CLI required", + "Starting the Snowflake stack requires the Python `localstack` CLI (the `--stack snowflake` flag is localstack-only). Install it with `pip install localstack`." + ); + } + return await startRuntime({ + cli: "localstack", startArgs: ["start", "--stack", "snowflake"], getStatus: getSnowflakeEmulatorStatus, processLabel: "Snowflake emulator", - alreadyRunningMessage: - "⚠️ Snowflake emulator is already running. Use 'restart' if you want to apply new configuration.", + alreadyRunningMessage: SNOWFLAKE_ALREADY_RUNNING_MESSAGE, successTitle: "🚀 Snowflake emulator started successfully!", statusHeading: "Health check", timeoutMessage: @@ -122,29 +153,46 @@ async function handleSnowflakeStart({ envVars }: { envVars?: Record; service: "aws" | "snowflake"; }) { - await runCommand("localstack", ["stop"], { timeout: 60000 }); + const dockerClient = new DockerApiClient(); + let containerId: string; + try { + containerId = await dockerClient.findLocalStackContainer(); + } catch (error) { + if (!isLocalStackContainerNotFoundError(error)) { + return ResponseBuilder.error( + "Docker lookup failed", + `Could not inspect Docker containers before restart: ${ + error instanceof Error ? error.message : String(error) + }` + ); + } + const status = await getLocalStackStatus({ includeCliStatus: false }); + if (status.isRunning) { + return ResponseBuilder.error( + "LocalStack container not found", + "The LocalStack gateway is reachable, but no matching Docker container could be identified for restart. " + + "Set MAIN_CONTAINER_NAME to the LocalStack container name, or restart the runtime outside the MCP server." + ); + } + return await handleStart({ envVars, service }); + } + + const cli = await detectCliForRestart(dockerClient, containerId, service); + if (!cli) { + return service === "snowflake" + ? ResponseBuilder.error( + "localstack CLI required", + "Restarting the Snowflake stack requires the Python `localstack` CLI on PATH." + ) + : noLifecycleCliFoundResponse("Restarting"); + } + + try { + await dockerClient.stopContainer(containerId); + } catch (error) { + return ResponseBuilder.error( + "Failed to stop LocalStack", + `Restart aborted because the running LocalStack container could not be stopped: ${ + error instanceof Error ? error.message : String(error) + }` + ); + } + await new Promise((resolve) => setTimeout(resolve, 2000)); - return await handleStart({ envVars, service }); + return await handleStart({ envVars, service, cli }); +} + +function noLifecycleCliFoundResponse(action: "Starting" | "Restarting") { + return ResponseBuilder.error( + "No LocalStack CLI found", + `${action} LocalStack needs the \`localstack\` or \`lstk\` CLI on PATH, but neither was found. ` + + "Install one (`pip install localstack`, or the `lstk` CLI), or start LocalStack yourself (e.g. `lstk start`) — " + + "the other tools drive it via the Docker API and gateway." + ); +} + +async function detectCliForRestart( + dockerClient: DockerApiClient, + containerId: string, + service: "aws" | "snowflake" +): Promise { + if (service === "snowflake") return await detectLifecycleCli(["localstack"]); + + let metadata: ContainerMetadata | undefined; + try { + metadata = await dockerClient.inspectContainer(containerId); + } catch { + metadata = undefined; + } + + return await detectLifecycleCli(preferredCliOrder(metadata)); +} + +function preferredCliOrder(metadata?: ContainerMetadata): LifecycleCli[] { + const name = metadata?.name || ""; + const mainContainerName = (metadata?.env || []) + .find((entry) => entry.startsWith("MAIN_CONTAINER_NAME=")) + ?.slice("MAIN_CONTAINER_NAME=".length); + + if (name === "localstack-aws" || mainContainerName === "localstack-aws") { + return ["lstk", "localstack"]; + } + + return ["localstack", "lstk"]; } // Handle status action