Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -77,11 +77,12 @@ RUN set -eux; \
command -v samlocal; \
cdklocal --version; \
snow --version; \
node dist/cli.js 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" \
org.opencontainers.image.source="https://github.com/localstack/localstack-mcp-server" \
org.opencontainers.image.licenses="Apache-2.0"

ENTRYPOINT ["node", "dist/stdio.js"]
ENTRYPOINT ["node", "dist/cli.js"]
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -156,7 +156,7 @@ If you installed from source, change `command` and `args` to point to your local
"mcpServers": {
"localstack": {
"command": "node",
"args": ["/path/to/your/localstack-mcp-server/dist/stdio.js"],
"args": ["/path/to/your/localstack-mcp-server/dist/cli.js"],
"env": {
"LOCALSTACK_AUTH_TOKEN": "<YOUR_TOKEN>"
}
Expand Down Expand Up @@ -269,7 +269,7 @@ This repository includes [MCP Server Tester](https://github.com/gleanwork/mcp-se

Notes:

- MCP tests target the local STDIO server command `node dist/stdio.js` by default.
- MCP tests target the lifecycle-aware local server command `node dist/cli.js` by default.
- `LOCALSTACK_AUTH_TOKEN` is required for all MCP tool usage and test suites.
- You can override the target command with:
- `MCP_TEST_COMMAND`
Expand Down
15 changes: 8 additions & 7 deletions docs/DOCKER.md
Original file line number Diff line number Diff line change
Expand Up @@ -128,13 +128,14 @@ alias covers bootstrap asset uploads.

## Troubleshooting

| Symptom | Cause / fix |
| ----------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- |
| 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). |
| `Docker Not Available` / daemon unreachable | Ensure `/var/run/docker.sock` is mounted (or pass `DOCKER_HOST` for a non-default daemon). |
| `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. |
| State disappeared after upgrading the image | Old configs stored state under `$XDG_CACHE_HOME/localstack/volume` — keep that env var, or point `LOCALSTACK_VOLUME_DIR` at the old directory. |
| Symptom | Cause / fix |
| ----------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| 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). |
| `Docker Not Available` / daemon unreachable | Ensure `/var/run/docker.sock` is mounted (or pass `DOCKER_HOST` for a non-default daemon). |
| `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. |
| State disappeared after upgrading the image | Old configs stored state under `$XDG_CACHE_HOME/localstack/volume` — keep that env var, or point `LOCALSTACK_VOLUME_DIR` at the old directory. |
| MCP server containers pile up over time | Older images did not exit when the client disconnected. Pull the latest image, then remove strays with `docker ps -aq --filter ancestor=localstack/localstack-mcp-server \| xargs docker rm -f`. |

## Validating an image yourself

Expand Down
4 changes: 2 additions & 2 deletions manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,10 @@
"icon": "icon.png",
"server": {
"type": "node",
"entry_point": "./dist/stdio.js",
"entry_point": "./dist/cli.js",
"mcp_config": {
"command": "node",
"args": ["${__dirname}/./dist/stdio.js"],
"args": ["${__dirname}/./dist/cli.js"],
"env": {}
}
},
Expand Down
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
"build": "xmcp build && yarn build:cli",
"build:cli": "esbuild src/cli/index.ts --bundle --platform=node --format=cjs --target=node22 --tsconfig=tsconfig.cli.json --alias:jsonc-parser=jsonc-parser/lib/esm/main.js --outfile=dist/cli.js --external:./stdio.js \"--banner:js=#!/usr/bin/env node\" --log-level=warning",
"dev": "xmcp dev",
"start": "node dist/stdio.js",
"start": "node dist/cli.js",
"prepack": "yarn build",
"format": "prettier --write .",
"test": "jest",
Expand Down Expand Up @@ -46,7 +46,7 @@
"@hono/node-server": "^2.0.5",
"ip-address": "^10.1.1"
},
"main": "./dist/stdio.js",
"main": "./dist/cli.js",
"files": [
"dist"
],
Expand Down
2 changes: 1 addition & 1 deletion playwright.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { defineConfig } from "@playwright/test";
const mcpCommand = process.env.MCP_TEST_COMMAND || "node";
const mcpArgs = process.env.MCP_TEST_ARGS
? process.env.MCP_TEST_ARGS.split(" ").filter(Boolean)
: ["dist/stdio.js"];
: ["dist/cli.js"];

export default defineConfig({
testDir: "./tests/mcp",
Expand Down
3 changes: 3 additions & 0 deletions src/cli/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@
import * as fs from "fs";
import * as path from "path";

import { exitWhenClientDisconnects } from "./lifecycle";

function getVersion(): string {
try {
const packageJson = JSON.parse(
Expand Down Expand Up @@ -55,6 +57,7 @@ async function main(): Promise<void> {
`Unknown command "${command}" — starting the MCP server. Did you mean "init"? See --help for setup commands.`
);
}
exitWhenClientDisconnects();
Comment thread
HarshCasper marked this conversation as resolved.
require("./stdio.js");
}
}
Expand Down
48 changes: 48 additions & 0 deletions src/cli/lifecycle.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { EventEmitter } from "events";

import { exitWhenClientDisconnects } from "./lifecycle";

describe("exitWhenClientDisconnects", () => {
let exitSpy: jest.SpyInstance;

beforeEach(() => {
jest.useFakeTimers();
exitSpy = jest.spyOn(process, "exit").mockImplementation((() => undefined) as never);
});

afterEach(() => {
exitSpy.mockRestore();
jest.useRealTimers();
});

it("exits with code 0 after stdin ends", () => {
const stdin = new EventEmitter();
exitWhenClientDisconnects(stdin);

stdin.emit("end");

expect(exitSpy).not.toHaveBeenCalled();
jest.runAllTimers();
expect(exitSpy).toHaveBeenCalledWith(0);
});

it("exits once when both end and close fire", () => {
const stdin = new EventEmitter();
exitWhenClientDisconnects(stdin);

stdin.emit("end");
stdin.emit("close");
jest.runAllTimers();

expect(exitSpy).toHaveBeenCalledTimes(1);
});

it("stays alive while the client is connected", () => {
const stdin = new EventEmitter();
exitWhenClientDisconnects(stdin);

jest.runAllTimers();

expect(exitSpy).not.toHaveBeenCalled();
});
});
23 changes: 23 additions & 0 deletions src/cli/lifecycle.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
/**
* MCP clients signal shutdown over stdio by closing the server's stdin.
* The bundled transport only listens for "data", so without this watcher
* the process outlives its client: `docker run --rm` never removes the
* container and npx-launched processes linger on the host.
*/

// Must exceed the PostHog flushInterval in core/analytics.ts (1000 ms) so
// telemetry from the final tool call is sent before the process exits.
const TELEMETRY_FLUSH_GRACE_MS = 1250;

export function exitWhenClientDisconnects(stdin: NodeJS.EventEmitter = process.stdin): void {
let exitScheduled = false;

const scheduleExit = () => {
if (exitScheduled) return;
exitScheduled = true;
setTimeout(() => process.exit(0), TELEMETRY_FLUSH_GRACE_MS);
};

stdin.once("end", scheduleExit);
stdin.once("close", scheduleExit);
}
26 changes: 26 additions & 0 deletions tests/mcp/direct.spec.mjs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { expect, test } from "@gleanwork/mcp-server-tester/fixtures/mcp";
import { execFileSync, spawn } from "node:child_process";
import { once } from "node:events";
import { mkdirSync, mkdtempSync, readFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
Expand Down Expand Up @@ -141,3 +142,28 @@ test("wizard: no-arg dist/cli.js still serves MCP over stdio", async () => {
child.kill();
}
});

test("dist/cli.js exits when the client closes stdin", async () => {
const child = spawn("node", ["-e", 'setInterval(() => {}, 1000); require("./dist/cli.js")'], {
stdio: ["pipe", "pipe", "pipe"],
});

try {
const exited = once(child, "exit");
child.stdin.end();

const [code, signal] = await Promise.race([
exited,
new Promise((_, reject) =>
setTimeout(() => reject(new Error("server did not exit after stdin closed")), 5000)
),
]);

expect(code).toBe(0);
expect(signal).toBeNull();
} finally {
if (child.exitCode === null && child.signalCode === null) {
child.kill("SIGKILL");
}
}
});
6 changes: 3 additions & 3 deletions yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -2616,9 +2616,9 @@ fast-string-width@^3.0.2:
fast-string-truncated-width "^3.0.2"

fast-uri@^3.0.1:
version "3.1.4"
resolved "https://registry.yarnpkg.com/fast-uri/-/fast-uri-3.1.4.tgz#3b3daf9ce68f41f956df0b505132c0cfce9ec7af"
integrity sha512-8JnbkQ4juDyvYs4mgFGQqg4yCYtFDtUtmp2QIQq11ZZe5CFQ5wcqm1rqDgAh/QdMySuBnPzMUiJUNZG5N/AiQw==
version "3.1.5"
resolved "https://registry.yarnpkg.com/fast-uri/-/fast-uri-3.1.5.tgz#610f37419a030270430cecd68d74e3d4d96725d0"
integrity sha512-gHwA1O9LDIcKunMKhObS/HimwtehO1nPUECKAu5TpKgaO19fcWEl4bliWe1jWxVFvIXztJjjQ4L8XQ1EU9f7Jw==

fast-wrap-ansi@^0.2.0:
version "0.2.2"
Expand Down
Loading