From 940eeec9c05b7ab54d148f836342e7d101a7bfd3 Mon Sep 17 00:00:00 2001 From: Jeffrey Aven Date: Fri, 12 Jun 2026 19:37:07 +1000 Subject: [PATCH] Fix server death under Claude Desktop: cwd-independent approot and audit Claude Desktop launches extensions with cwd '/', read-only on macOS. stackql's audit sink defaults to a file in the cwd with strict failure mode, so the server exited immediately after initialize ('Server disconnected'). The default approot (/.stackql) would fail the same way on first provider pull. - manifest args now pass --approot ${HOME}/.stackql (plain arg - on Windows ${HOME} expands with backslashes, invalid inside JSON) and --mcp.config '{"server": {"audit": {"disabled": true}}}' - smoke-test.py now launches the server with the manifest's own mcp_config.args (substituting ${__dirname}/${HOME} to temp dirs), so manifest regressions fail the gate instead of surfacing in Desktop - docs/install.md manual-config example gets the same flags with explanation (no variable substitution exists in that file) Found by testing the published darwin bundle in Claude Desktop on macOS; reproduced root cause against the binary directly. Co-Authored-By: Claude Fable 5 --- CLAUDE.md | 8 +++++++- docs/install.md | 15 ++++++++++++--- manifest/manifest.template.json | 9 ++++++++- scripts/smoke-test.py | 28 +++++++++++++++++++++++----- 4 files changed, 50 insertions(+), 10 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 67e6e14..d800366 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -206,7 +206,13 @@ The darwin target reads a notarised `.pkg`, not a bare binary, because: ### Manifest template -[manifest/manifest.template.json](manifest/manifest.template.json) is tokenised with `__VERSION__` and `__BINARY_NAME__`. The runtime invocation is `${__dirname}/server/` with `args: ["mcp", "--mcp.server.type=stdio"]`. The `--mcp.server.type=stdio` flag is required: without it the MCP server starts but does not emit JSON-RPC on stdout (the default transport differs). If you ever need to pass more flags (registry path, auth context, etc.), update `args` here - clients launch the bundled binary with precisely those arguments. +[manifest/manifest.template.json](manifest/manifest.template.json) is tokenised with `__VERSION__` and `__BINARY_NAME__`. The runtime invocation is `${__dirname}/server/` with args `mcp --mcp.server.type=stdio --approot ${HOME}/.stackql --mcp.config {"server": {"audit": {"disabled": true}}}`. Every arg is load-bearing: + +- `--mcp.server.type=stdio` is required: without it the MCP server starts but does not emit JSON-RPC on stdout. +- `--approot ${HOME}/.stackql`: Claude Desktop launches extensions with cwd `/` (read-only on macOS) and stackql's default approot is `/.stackql`, so provider pulls die without it. `${HOME}` is an MCPB substitution variable; it is passed as a plain arg (not inside JSON) because on Windows it expands with backslashes, which are invalid JSON string escapes. +- `--mcp.config` with `audit.disabled`: the audit sink defaults to a file in the cwd and kills the server when unwritable (`failure_mode` defaults to `strict`). The JSON is static - no substitution inside it, for the same backslash reason. The `--mcp.config` parser is JSON-only in practice; YAML flow style silently falls back to defaults. + +`scripts/smoke-test.py` launches the server with the manifest's own `mcp_config.args` (substituting `${__dirname}` and `${HOME}` against temp dirs), so manifest arg regressions fail the smoke test rather than surfacing in Claude Desktop. If you need to pass more flags, update `args` in the template - clients launch the bundled binary with precisely those arguments. ## Releasing diff --git a/docs/install.md b/docs/install.md index 68f8528..e66259b 100644 --- a/docs/install.md +++ b/docs/install.md @@ -37,20 +37,29 @@ If you'd rather wire the existing `stackql` binary on your machine directly (no - Windows: `%APPDATA%\Claude\claude_desktop_config.json` - Linux: `~/.config/Claude/claude_desktop_config.json` -Add the `stackql` server entry. Adjust `command` to the absolute path of your `stackql` binary if it is not on `PATH`: +Add the `stackql` server entry. Adjust `command` to the absolute path of your `stackql` binary if it is not on `PATH`, and replace `/Users/you` with your actual home directory (no variable substitution happens in this file): ```json { "mcpServers": { "stackql": { "command": "stackql", - "args": ["mcp", "--mcp.server.type=stdio"] + "args": [ + "mcp", + "--mcp.server.type=stdio", + "--approot", "/Users/you/.stackql", + "--mcp.config", "{\"server\": {\"audit\": {\"disabled\": true}}}" + ] } } } ``` -The `--mcp.server.type=stdio` flag is required - without it the server starts but does not produce JSON-RPC on stdout. +All three extra arguments matter: + +- `--mcp.server.type=stdio` is required - without it the server starts but does not produce JSON-RPC on stdout. +- `--approot` must point somewhere writable. Claude Desktop launches MCP servers with cwd `/` (read-only on macOS), and stackql's default approot is `/.stackql`, so without this flag provider downloads fail. +- The `--mcp.config` audit setting is required for the same reason: the audit sink defaults to a file in the cwd and the server exits if it cannot open it (`failure_mode` defaults to `strict`). Alternatively set `{"server": {"audit": {"file": {"path": "/Users/you/.stackql/stackql-mcp-audit.log"}}}}` to keep auditing with an explicit writable path. ### With cloud provider credentials diff --git a/manifest/manifest.template.json b/manifest/manifest.template.json index a3760ff..4a77198 100644 --- a/manifest/manifest.template.json +++ b/manifest/manifest.template.json @@ -17,7 +17,14 @@ "entry_point": "server/__BINARY_NAME__", "mcp_config": { "command": "${__dirname}/server/__BINARY_NAME__", - "args": ["mcp", "--mcp.server.type=stdio"] + "args": [ + "mcp", + "--mcp.server.type=stdio", + "--approot", + "${HOME}/.stackql", + "--mcp.config", + "{\"server\": {\"audit\": {\"disabled\": true}}}" + ] } } } diff --git a/scripts/smoke-test.py b/scripts/smoke-test.py index 9f2ad21..d640330 100644 --- a/scripts/smoke-test.py +++ b/scripts/smoke-test.py @@ -38,8 +38,8 @@ def fail(msg: str) -> "Never": # type: ignore[name-defined] sys.exit(1) -def extract_bundle(bundle: Path, dest: Path) -> Path: - """Unzip the .mcpb and return the path to the server binary.""" +def extract_bundle(bundle: Path, dest: Path) -> tuple[Path, dict]: + """Unzip the .mcpb and return the server binary path and the manifest.""" log(f"extracting {bundle.name}") with zipfile.ZipFile(bundle) as zf: zf.extractall(dest) @@ -51,7 +51,22 @@ def extract_bundle(bundle: Path, dest: Path) -> Path: if os.name != "nt": binary.chmod(0o755) log(f"entry_point: {entry} (version {manifest.get('version')})") - return binary + return binary, manifest + + +def manifest_args(manifest: dict, extract_dir: Path, home_dir: Path) -> list[str]: + """Resolve the manifest's mcp_config args the way an MCPB client would. + + Substituting ${HOME} with a temp dir keeps the test hermetic and proves + the server runs without writing to its cwd (Claude Desktop launches + extensions with cwd '/', which is read-only on macOS). + """ + args = manifest["server"]["mcp_config"].get("args", []) + resolved = [ + a.replace("${__dirname}", str(extract_dir)).replace("${HOME}", str(home_dir)) + for a in args + ] + return resolved class JsonRpcClient: @@ -107,9 +122,12 @@ def run(bundle_path: Path) -> None: with tempfile.TemporaryDirectory(prefix="mcpb-smoke-") as tmp: tmp_path = Path(tmp) - binary = extract_bundle(bundle_path, tmp_path) + binary, manifest = extract_bundle(bundle_path, tmp_path) - cmd = [str(binary), "mcp", "--mcp.server.type=stdio", f"--auth={GITHUB_AUTH}"] + home_dir = tmp_path / "home" + home_dir.mkdir() + args = manifest_args(manifest, tmp_path, home_dir) + cmd = [str(binary), *args, f"--auth={GITHUB_AUTH}"] log(f"spawning: {' '.join(cmd)}") # Binary pipes on purpose: text=True would translate \n to \r\n on # Windows stdin, and the server exits silently on the stray \r.