diff --git a/Dockerfile b/Dockerfile index 155f533..a49381e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -77,6 +77,7 @@ 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" \ @@ -84,4 +85,4 @@ LABEL org.opencontainers.image.title="LocalStack MCP Server" \ 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"] diff --git a/README.md b/README.md index 99721d5..4b22ac0 100644 --- a/README.md +++ b/README.md @@ -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": "" } @@ -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` diff --git a/docs/DOCKER.md b/docs/DOCKER.md index 1fd3bc3..0bff983 100644 --- a/docs/DOCKER.md +++ b/docs/DOCKER.md @@ -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 diff --git a/manifest.json b/manifest.json index aafd4ed..75afc29 100644 --- a/manifest.json +++ b/manifest.json @@ -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": {} } }, diff --git a/package.json b/package.json index bd6497c..5878b32 100644 --- a/package.json +++ b/package.json @@ -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", @@ -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" ], diff --git a/playwright.config.mjs b/playwright.config.mjs index 2830678..f1123b7 100644 --- a/playwright.config.mjs +++ b/playwright.config.mjs @@ -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", diff --git a/src/cli/index.ts b/src/cli/index.ts index 26cb106..f789b8d 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -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( @@ -55,6 +57,7 @@ async function main(): Promise { `Unknown command "${command}" — starting the MCP server. Did you mean "init"? See --help for setup commands.` ); } + exitWhenClientDisconnects(); require("./stdio.js"); } } diff --git a/src/cli/lifecycle.test.ts b/src/cli/lifecycle.test.ts new file mode 100644 index 0000000..51bdb73 --- /dev/null +++ b/src/cli/lifecycle.test.ts @@ -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(); + }); +}); diff --git a/src/cli/lifecycle.ts b/src/cli/lifecycle.ts new file mode 100644 index 0000000..f192d0c --- /dev/null +++ b/src/cli/lifecycle.ts @@ -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); +} diff --git a/tests/mcp/direct.spec.mjs b/tests/mcp/direct.spec.mjs index c7e7dcf..4fff497 100644 --- a/tests/mcp/direct.spec.mjs +++ b/tests/mcp/direct.spec.mjs @@ -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"; @@ -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"); + } + } +}); diff --git a/yarn.lock b/yarn.lock index e9bf8d1..7433752 100644 --- a/yarn.lock +++ b/yarn.lock @@ -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"