Skip to content

Commit f22ef00

Browse files
fix(cli): exit the server when the MCP client closes stdin (#68)
* fix(cli): exit the server when the MCP client closes stdin * fix(security): refresh fast-uri lockfile entry to 3.1.5 * fix(cli): route stdio entrypoints through lifecycle wrapper --------- Co-authored-by: George Tsiolis <tsiolis.g@gmail.com>
1 parent 09f18be commit f22ef00

11 files changed

Lines changed: 120 additions & 18 deletions

File tree

‎Dockerfile‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -77,11 +77,12 @@ RUN set -eux; \
7777
command -v samlocal; \
7878
cdklocal --version; \
7979
snow --version; \
80+
node dist/cli.js version; \
8081
node -e "require('dockerode'); console.log('dockerode ok')"
8182

8283
LABEL org.opencontainers.image.title="LocalStack MCP Server" \
8384
org.opencontainers.image.description="Self-contained MCP server for managing LocalStack" \
8485
org.opencontainers.image.source="https://github.com/localstack/localstack-mcp-server" \
8586
org.opencontainers.image.licenses="Apache-2.0"
8687

87-
ENTRYPOINT ["node", "dist/stdio.js"]
88+
ENTRYPOINT ["node", "dist/cli.js"]

‎README.md‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -156,7 +156,7 @@ If you installed from source, change `command` and `args` to point to your local
156156
"mcpServers": {
157157
"localstack": {
158158
"command": "node",
159-
"args": ["/path/to/your/localstack-mcp-server/dist/stdio.js"],
159+
"args": ["/path/to/your/localstack-mcp-server/dist/cli.js"],
160160
"env": {
161161
"LOCALSTACK_AUTH_TOKEN": "<YOUR_TOKEN>"
162162
}
@@ -269,7 +269,7 @@ This repository includes [MCP Server Tester](https://github.com/gleanwork/mcp-se
269269

270270
Notes:
271271

272-
- MCP tests target the local STDIO server command `node dist/stdio.js` by default.
272+
- MCP tests target the lifecycle-aware local server command `node dist/cli.js` by default.
273273
- `LOCALSTACK_AUTH_TOKEN` is required for all MCP tool usage and test suites.
274274
- You can override the target command with:
275275
- `MCP_TEST_COMMAND`

‎docs/DOCKER.md‎

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -128,13 +128,14 @@ alias covers bootstrap asset uploads.
128128

129129
## Troubleshooting
130130

131-
| Symptom | Cause / fix |
132-
| ----------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- |
133-
| Tools report `LocalStack Not Running` after `start` | Check `LOCALSTACK_HOSTNAME=host.docker.internal` is set and `--add-host` is present (Linux). |
134-
| `Auth Token Required` | `LOCALSTACK_AUTH_TOKEN` must be passed through (every tool requires it). |
135-
| `Docker Not Available` / daemon unreachable | Ensure `/var/run/docker.sock` is mounted (or pass `DOCKER_HOST` for a non-default daemon). |
136-
| `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. |
137-
| 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. |
131+
| Symptom | Cause / fix |
132+
| ----------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
133+
| Tools report `LocalStack Not Running` after `start` | Check `LOCALSTACK_HOSTNAME=host.docker.internal` is set and `--add-host` is present (Linux). |
134+
| `Auth Token Required` | `LOCALSTACK_AUTH_TOKEN` must be passed through (every tool requires it). |
135+
| `Docker Not Available` / daemon unreachable | Ensure `/var/run/docker.sock` is mounted (or pass `DOCKER_HOST` for a non-default daemon). |
136+
| `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. |
137+
| 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. |
138+
| 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`. |
138139

139140
## Validating an image yourself
140141

‎manifest.json‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,10 +15,10 @@
1515
"icon": "icon.png",
1616
"server": {
1717
"type": "node",
18-
"entry_point": "./dist/stdio.js",
18+
"entry_point": "./dist/cli.js",
1919
"mcp_config": {
2020
"command": "node",
21-
"args": ["${__dirname}/./dist/stdio.js"],
21+
"args": ["${__dirname}/./dist/cli.js"],
2222
"env": {}
2323
}
2424
},

‎package.json‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
"build": "xmcp build && yarn build:cli",
1111
"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",
1212
"dev": "xmcp dev",
13-
"start": "node dist/stdio.js",
13+
"start": "node dist/cli.js",
1414
"prepack": "yarn build",
1515
"format": "prettier --write .",
1616
"test": "jest",
@@ -46,7 +46,7 @@
4646
"@hono/node-server": "^2.0.5",
4747
"ip-address": "^10.1.1"
4848
},
49-
"main": "./dist/stdio.js",
49+
"main": "./dist/cli.js",
5050
"files": [
5151
"dist"
5252
],

‎playwright.config.mjs‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { defineConfig } from "@playwright/test";
33
const mcpCommand = process.env.MCP_TEST_COMMAND || "node";
44
const mcpArgs = process.env.MCP_TEST_ARGS
55
? process.env.MCP_TEST_ARGS.split(" ").filter(Boolean)
6-
: ["dist/stdio.js"];
6+
: ["dist/cli.js"];
77

88
export default defineConfig({
99
testDir: "./tests/mcp",

‎src/cli/index.ts‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@
66
import * as fs from "fs";
77
import * as path from "path";
88

9+
import { exitWhenClientDisconnects } from "./lifecycle";
10+
911
function getVersion(): string {
1012
try {
1113
const packageJson = JSON.parse(
@@ -55,6 +57,7 @@ async function main(): Promise<void> {
5557
`Unknown command "${command}" — starting the MCP server. Did you mean "init"? See --help for setup commands.`
5658
);
5759
}
60+
exitWhenClientDisconnects();
5861
require("./stdio.js");
5962
}
6063
}

‎src/cli/lifecycle.test.ts‎

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import { EventEmitter } from "events";
2+
3+
import { exitWhenClientDisconnects } from "./lifecycle";
4+
5+
describe("exitWhenClientDisconnects", () => {
6+
let exitSpy: jest.SpyInstance;
7+
8+
beforeEach(() => {
9+
jest.useFakeTimers();
10+
exitSpy = jest.spyOn(process, "exit").mockImplementation((() => undefined) as never);
11+
});
12+
13+
afterEach(() => {
14+
exitSpy.mockRestore();
15+
jest.useRealTimers();
16+
});
17+
18+
it("exits with code 0 after stdin ends", () => {
19+
const stdin = new EventEmitter();
20+
exitWhenClientDisconnects(stdin);
21+
22+
stdin.emit("end");
23+
24+
expect(exitSpy).not.toHaveBeenCalled();
25+
jest.runAllTimers();
26+
expect(exitSpy).toHaveBeenCalledWith(0);
27+
});
28+
29+
it("exits once when both end and close fire", () => {
30+
const stdin = new EventEmitter();
31+
exitWhenClientDisconnects(stdin);
32+
33+
stdin.emit("end");
34+
stdin.emit("close");
35+
jest.runAllTimers();
36+
37+
expect(exitSpy).toHaveBeenCalledTimes(1);
38+
});
39+
40+
it("stays alive while the client is connected", () => {
41+
const stdin = new EventEmitter();
42+
exitWhenClientDisconnects(stdin);
43+
44+
jest.runAllTimers();
45+
46+
expect(exitSpy).not.toHaveBeenCalled();
47+
});
48+
});

‎src/cli/lifecycle.ts‎

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
/**
2+
* MCP clients signal shutdown over stdio by closing the server's stdin.
3+
* The bundled transport only listens for "data", so without this watcher
4+
* the process outlives its client: `docker run --rm` never removes the
5+
* container and npx-launched processes linger on the host.
6+
*/
7+
8+
// Must exceed the PostHog flushInterval in core/analytics.ts (1000 ms) so
9+
// telemetry from the final tool call is sent before the process exits.
10+
const TELEMETRY_FLUSH_GRACE_MS = 1250;
11+
12+
export function exitWhenClientDisconnects(stdin: NodeJS.EventEmitter = process.stdin): void {
13+
let exitScheduled = false;
14+
15+
const scheduleExit = () => {
16+
if (exitScheduled) return;
17+
exitScheduled = true;
18+
setTimeout(() => process.exit(0), TELEMETRY_FLUSH_GRACE_MS);
19+
};
20+
21+
stdin.once("end", scheduleExit);
22+
stdin.once("close", scheduleExit);
23+
}

‎tests/mcp/direct.spec.mjs‎

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { expect, test } from "@gleanwork/mcp-server-tester/fixtures/mcp";
22
import { execFileSync, spawn } from "node:child_process";
3+
import { once } from "node:events";
34
import { mkdirSync, mkdtempSync, readFileSync } from "node:fs";
45
import { tmpdir } from "node:os";
56
import { join } from "node:path";
@@ -141,3 +142,28 @@ test("wizard: no-arg dist/cli.js still serves MCP over stdio", async () => {
141142
child.kill();
142143
}
143144
});
145+
146+
test("dist/cli.js exits when the client closes stdin", async () => {
147+
const child = spawn("node", ["-e", 'setInterval(() => {}, 1000); require("./dist/cli.js")'], {
148+
stdio: ["pipe", "pipe", "pipe"],
149+
});
150+
151+
try {
152+
const exited = once(child, "exit");
153+
child.stdin.end();
154+
155+
const [code, signal] = await Promise.race([
156+
exited,
157+
new Promise((_, reject) =>
158+
setTimeout(() => reject(new Error("server did not exit after stdin closed")), 5000)
159+
),
160+
]);
161+
162+
expect(code).toBe(0);
163+
expect(signal).toBeNull();
164+
} finally {
165+
if (child.exitCode === null && child.signalCode === null) {
166+
child.kill("SIGKILL");
167+
}
168+
}
169+
});

0 commit comments

Comments
 (0)