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
8 changes: 7 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<binary>` 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/<binary>` 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 `<cwd>/.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

Expand Down
15 changes: 12 additions & 3 deletions docs/install.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<cwd>/.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

Expand Down
9 changes: 8 additions & 1 deletion manifest/manifest.template.json
Original file line number Diff line number Diff line change
Expand Up @@ -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}}}"
]
}
}
}
28 changes: 23 additions & 5 deletions scripts/smoke-test.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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:
Expand Down Expand Up @@ -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.
Expand Down
Loading