From 4c17a29ce5c3fcead62588977b7f4c73d52a4812 Mon Sep 17 00:00:00 2001 From: Jeffrey Aven Date: Sat, 13 Jun 2026 08:17:48 +1000 Subject: [PATCH 1/3] Add OCI image and npm wrapper install vectors Two new distribution channels alongside the .mcpb bundles, both gated by the same smoke test (now supporting --docker and --cmd '' modes in addition to bundle mode): OCI (docker.io/stackql/stackql-mcp): - oci/Dockerfile: debian-slim, non-root, binary from the release zips staged by 'make oci-stage' (COPY --chmod=0755 - host-independent), io.modelcontextprotocol.server.name label for registry validation, CMD with the cwd-safe approot/audit args - make oci (local amd64 build) / make oci-push (multi-arch push) - build.yml: build + in-container smoke at PR time - publish.yml: multi-arch push, soft-skipped without DOCKERHUB_* secrets npm (@stackql/mcp-server): - npx-able launcher: downloads the platform's published .mcpb on first run, verifies sha256 against pins baked into the package, caches under ~/.stackql/mcp-server-bin/, spawns with cwd-safe args; extra args pass through; diagnostics on stderr only - scripts/render-npm-manifest.sh: pins from PUBLISHED .sha256 files (post-publish step, same ordering rule as server-json) - make npm-manifest / make npm-pack; npm publish stays manual (2FA) - build.yml: wrapper smoke via STACKQL_MCP_BUNDLE override (published pins do not exist at PR time for a new version) - publish.yml: renders real pins post-upload, attaches tarball artifact registry/server.template.json gains oci and npm package entries (schema validated against the live registry). mcpName in package.json and the image label satisfy the registry's namespace ownership checks. Verified locally: docker build + smoke (amd64 container, full MCP exercise), npm wrapper end-to-end against published 0.10.500 assets (download, sha verify, extract, cache, spawn, full MCP exercise, cache reuse on second run), npm pack contents (4 files, 3.4kB). Co-Authored-By: Claude Fable 5 --- .dockerignore | 4 + .github/workflows/build.yml | 54 +++++++++++ .github/workflows/publish.yml | 44 +++++++++ .gitignore | 4 + CLAUDE.md | 19 ++++ Makefile | 52 ++++++++++ README.md | 15 ++- docs/install.md | 46 ++++++++- npm/README.md | 67 +++++++++++++ npm/bin/stackql-mcp.js | 128 ++++++++++++++++++++++++ npm/package.json | 36 +++++++ oci/Dockerfile | 43 +++++++++ registry/server.template.json | 14 +++ scripts/render-npm-manifest.sh | 78 +++++++++++++++ scripts/smoke-test.py | 172 +++++++++++++++++++-------------- 15 files changed, 701 insertions(+), 75 deletions(-) create mode 100644 .dockerignore create mode 100644 npm/README.md create mode 100644 npm/bin/stackql-mcp.js create mode 100644 npm/package.json create mode 100644 oci/Dockerfile create mode 100755 scripts/render-npm-manifest.sh diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..b78d886 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,4 @@ +# Only the staged binaries are needed in the build context - without this the +# context drags in bin/ and dist/ (hundreds of MB of zips and bundles). +* +!.oci-stage diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index dfd82a6..0d2bd68 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -97,3 +97,57 @@ jobs: - name: Smoke test (deterministic MCP handshake) run: python scripts/smoke-test.py dist/stackql-mcp-windows-x64.mcpb + + # OCI image: built from the release zips (which exist as soon as the + # upstream release does, so this runs at PR time), smoke-tested in-container. + # Pushing happens in publish.yml, gated on Docker Hub secrets. + oci: + name: oci (build + smoke) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + + - name: Build image + run: make oci VERSION=${{ inputs.version }} + + - name: Smoke test (deterministic MCP handshake) + run: python3 scripts/smoke-test.py --docker stackql/stackql-mcp:${{ inputs.version }} + + # npm wrapper: the real platforms.json pins the sha256 of the PUBLISHED + # .mcpb assets, which do not exist yet at PR time for a new version (this + # repo publishes them at tag time). CI therefore tests the wrapper's + # extract/cache/spawn logic against a locally built bundle via the + # STACKQL_MCP_BUNDLE override, with placeholder pins. The download+verify + # path is exercised by 'make npm-manifest && npm pack' after publish. + npm-wrapper: + name: npm wrapper (smoke) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + + - name: Build local bundle for the wrapper to consume + run: make one TARGET=linux-x64 VERSION=${{ inputs.version }} + + - name: Render placeholder manifest + run: | + zeros="0000000000000000000000000000000000000000000000000000000000000000" + cat > npm/platforms.json <` (manifest-driven args), `--docker `, and `--cmd ""`. Registry namespace validation is baked in: the npm package.json carries `mcpName: io.github.stackql/stackql-mcp` and the Dockerfile carries the `io.modelcontextprotocol.server.name` label - the Official MCP Registry checks both before accepting the oci/npm package entries in server.json. + Upload everything in `dist/` to the matching `stackql/stackql` release (requires `gh auth login` with `contents:write` on `stackql/stackql`; idempotent via `--clobber`): ```bash diff --git a/Makefile b/Makefile index fc5b524..16700c6 100644 --- a/Makefile +++ b/Makefile @@ -48,9 +48,13 @@ ASSETS := stackql_linux_amd64.zip \ stackql_windows_amd64.zip \ stackql_darwin_multiarch.pkg +OCI_IMAGE ?= stackql/stackql-mcp +OCI_STAGE := .oci-stage + .PHONY: all check-version check-target download package one signed sign publish \ server-json registry-publish clean clean-bin \ linux-x64 linux-arm64 windows-x64 darwin-universal \ + oci-stage oci oci-push npm-manifest npm-pack \ list help all: download package @@ -169,6 +173,54 @@ registry-publish: check-version server-json } cd registry && mcp-publisher publish +# --- OCI image --------------------------------------------------------------- +# Extract the linux binaries from the release zips into the docker build +# context. Downloads the zips first if they are not already in bin/. +oci-stage: check-version + @command -v unzip >/dev/null 2>&1 || { echo "error: unzip required" >&2; exit 2; } + @mkdir -p $(BIN_DIR) + @for asset in stackql_linux_amd64.zip stackql_linux_arm64.zip; do \ + if [ ! -f "$(BIN_DIR)/$$asset" ]; then \ + echo "downloading $$asset"; \ + curl -fsSL --retry 3 -o "$(BIN_DIR)/$$asset" "$(RELEASE_BASE)/v$(VERSION)/$$asset" || { \ + rm -f "$(BIN_DIR)/$$asset"; exit 1; }; \ + fi; \ + done + @rm -rf $(OCI_STAGE) + @mkdir -p $(OCI_STAGE)/linux/amd64 $(OCI_STAGE)/linux/arm64 + @unzip -o -q $(BIN_DIR)/stackql_linux_amd64.zip stackql -d $(OCI_STAGE)/linux/amd64 + @unzip -o -q $(BIN_DIR)/stackql_linux_arm64.zip stackql -d $(OCI_STAGE)/linux/arm64 + @echo "staged linux binaries under $(OCI_STAGE)/" + +# Local single-arch (amd64) build for testing. Smoke test with: +# python scripts/smoke-test.py --docker $(OCI_IMAGE):$(VERSION) +oci: oci-stage + docker buildx build --platform linux/amd64 --load \ + --build-arg VERSION=$(VERSION) \ + -f oci/Dockerfile -t $(OCI_IMAGE):$(VERSION) . + +# Multi-arch build + push. Requires 'docker login' with push rights on +# docker.io/$(OCI_IMAGE). +oci-push: oci-stage + docker buildx build --platform linux/amd64,linux/arm64 --push \ + --build-arg VERSION=$(VERSION) \ + -f oci/Dockerfile \ + -t docker.io/$(OCI_IMAGE):$(VERSION) \ + -t docker.io/$(OCI_IMAGE):latest . + +# --- npm wrapper --------------------------------------------------------------- +# Render npm/platforms.json (bundle URLs + sha256 pins) and stamp the package +# version. Run AFTER the .mcpb assets are published - it fetches the canonical +# .sha256 files from the GitHub release, same rule as 'make server-json'. +npm-manifest: check-version + bash scripts/render-npm-manifest.sh --version $(VERSION) + +# Build the publishable tarball. Publishing stays manual (npm 2FA): +# cd npm && npm publish --access public +npm-pack: npm-manifest + cd npm && npm pack + @echo "tarball ready under npm/ - publish manually with: cd npm && npm publish --access public" + # Upload everything that landed in dist/ to the matching tag on stackql/stackql. # Idempotent via --clobber, so running this from two machines (one with the # darwin bundle, one with the rest) is safe in either order. diff --git a/README.md b/README.md index 0090f74..1ea910e 100644 --- a/README.md +++ b/README.md @@ -45,9 +45,12 @@ stackql-mcpb-packaging/ .github/workflows/publish.yml # v* tag: verify, build, test, publish manifest/manifest.template.json # MCPB manifest, tokenised (__VERSION__, __BINARY_NAME__) registry/server.template.json # Official MCP Registry server.json, tokenised SHAs + VERSION + oci/Dockerfile # stackql/stackql-mcp image (multi-arch via TARGETARCH) + npm/ # @stackql/mcp-server npx wrapper package scripts/package.sh # build bundles from bin/ -> dist/ scripts/clean.sh # wipe dist/ scripts/render-server-json.sh # pin SHAs into registry/server.json + scripts/render-npm-manifest.sh # pin bundle SHAs into npm/platforms.json scripts/sign.sh # envelope-sign dist/*.mcpb + regen .sha256 scripts/append-signature.py # frame an externally-produced CMS signature scripts/smoke-test.py # deterministic MCP smoke test (stdlib only) @@ -112,7 +115,12 @@ One-time setup: add a repo secret `STACKQL_RELEASE_TOKEN` - a fine-grained PAT ( Optional envelope signing: if the repo secrets `MCPB_SIGNING_CERT` and `MCPB_SIGNING_KEY` (PEM contents, plus optional `MCPB_SIGNING_INTERMEDIATES`) are set, the publish job runs `make sign` to `mcpb sign` every bundle and regenerate its `.sha256` before upload. Without the secrets the step prints a notice and skips, and unsigned bundles ship as before. Note `mcpb verify` in the current CLI is broken upstream (node-forge cannot verify PKCS#7, so every signed bundle reports as unsigned); `make sign` treats it as advisory and asserts the appended signature block instead. -Steps 3 and 4 of the local runbook below (MCP Registry publish and aggregator listings) are still manual after a CI publish. +Two more vectors ship from the same pipeline: + +- **OCI image** (`docker.io/stackql/stackql-mcp`): built and smoke-tested in PR CI; pushed multi-arch (amd64 + arm64) by the publish workflow when the `DOCKERHUB_USERNAME`/`DOCKERHUB_TOKEN` secrets are set (soft-skips otherwise). The image carries the `io.modelcontextprotocol.server.name` label the MCP Registry requires for oci package validation. +- **npm wrapper** (`@stackql/mcp-server`): an npx-able launcher that downloads the platform's published `.mcpb` (sha256-verified against pins baked into the package) and spawns the binary. PR CI tests the wrapper against a locally built bundle; the publish workflow renders the real pins from the published assets and uploads the tarball as a run artifact. Publishing to npmjs is deliberately manual (2FA): download the artifact or run `make npm-pack VERSION=X.Y.Z` locally, then `cd npm && npm publish --access public`. The package carries the `mcpName` field the MCP Registry requires for npm package validation. + +Steps 3 and 4 of the local runbook below (MCP Registry publish and aggregator listings) are still manual after a CI publish. After the OCI image and npm package exist for a version, the registry `server.json` (which now includes oci and npm package entries) can be published - the registry validates the npm `mcpName` and the image label at publish time, so those artifacts must exist first. ## Release runbook (local fallback) @@ -322,6 +330,11 @@ make signed VERSION=X.Y.Z build with MCPB_SELF_SIGN=true (testing only) make sign envelope-sign dist/*.mcpb in place and regenerate .sha256 (MCPB_SELF_SIGN=true or MCPB_SIGN_CERT + MCPB_SIGN_KEY; no-ops with a notice when unset) +make oci VERSION=X.Y.Z build the stackql/stackql-mcp image locally (amd64) +make oci-push VERSION=X.Y.Z multi-arch image build + push (needs docker login) +make npm-manifest VERSION=X.Y.Z render npm/platforms.json from PUBLISHED .sha256s +make npm-pack VERSION=X.Y.Z build the @stackql/mcp-server tarball (publish is + manual: cd npm && npm publish --access public) make publish VERSION=X.Y.Z upload dist/* to the stackql/stackql release make server-json VERSION=X.Y.Z render registry/server.json (pins 4 SHAs) make registry-publish VERSION=X.Y.Z render + publish to the Official MCP Registry diff --git a/docs/install.md b/docs/install.md index e66259b..c6bc58d 100644 --- a/docs/install.md +++ b/docs/install.md @@ -1,6 +1,6 @@ # Installing the StackQL MCP server -There are three ways to get the StackQL MCP server into a client (Claude Desktop, Cursor, VS Code, Cline, etc.), in order of how much trust and how little effort each takes. +There are five ways to get the StackQL MCP server into a client (Claude Desktop, Cursor, VS Code, Cline, etc.). The first three are ordered by how much trust and how little effort each takes; npx and Docker suit npx-shaped client directories and containerised environments respectively. ## 1. From a marketplace / directory (recommended once listings are live) @@ -82,6 +82,50 @@ The server picks up provider credentials through stackql's normal `--auth` flag. See https://stackql.io/docs for the full provider auth catalogue. +## 4. npx (any stdio MCP client, no install) + +The `@stackql/mcp-server` package downloads the signed binary on first run +(sha256-verified against pins baked into the package) and caches it under +`~/.stackql/mcp-server-bin/`: + +```json +{ + "mcpServers": { + "stackql": { + "command": "npx", + "args": ["-y", "@stackql/mcp-server"] + } + } +} +``` + +The launcher sets `--approot` and disables the audit sink automatically (the +cwd-safety flags from section 3), so no extra arguments are needed. Pass +`--auth=...` and other stackql flags as additional args. + +## 5. Docker + +```bash +docker run -i --rm stackql/stackql-mcp +``` + +Runs the MCP server on stdio as a non-root user; amd64 and arm64. As an MCP +client entry: + +```json +{ + "mcpServers": { + "stackql": { + "command": "docker", + "args": ["run", "-i", "--rm", "stackql/stackql-mcp"] + } + } +} +``` + +Add `-e` flags before the image name to pass credential environment variables +referenced by your `--auth` config. + ## Trust model What you get with a fresh StackQL `.mcpb` install: diff --git a/npm/README.md b/npm/README.md new file mode 100644 index 0000000..aadcabc --- /dev/null +++ b/npm/README.md @@ -0,0 +1,67 @@ +# @stackql/mcp-server + +npx-able launcher for the [StackQL](https://stackql.io) MCP server - a +SQL-native query and provisioning engine for cloud and SaaS infrastructure +(AWS, Azure, Google, GitHub, Databricks, and 40+ other providers), served over +the Model Context Protocol. + +On first run, the launcher downloads the signed `stackql` binary for your +platform from the matching GitHub release (sha256-verified against pins baked +into this package), caches it under `~/.stackql/mcp-server-bin/`, and starts +it as an MCP stdio server. Subsequent runs start instantly from the cache. + +## Usage + +With any MCP client that supports stdio servers: + +```json +{ + "mcpServers": { + "stackql": { + "command": "npx", + "args": ["-y", "@stackql/mcp-server"] + } + } +} +``` + +Provider credentials are passed through with stackql's standard `--auth` flag: + +```json +{ + "mcpServers": { + "stackql": { + "command": "npx", + "args": [ + "-y", + "@stackql/mcp-server", + "--auth={\"github\":{\"type\":\"null_auth\"}}" + ] + } + } +} +``` + +Any extra arguments are passed to `stackql` after the standard MCP server +arguments. The launcher sets `--approot` to `~/.stackql` and disables the +audit file sink by default; pass your own `--approot` or `--mcp.config` to +override (later flags win). + +## Environment overrides + +- `STACKQL_MCP_BIN` - path to an existing `stackql` binary; skips the download. +- `STACKQL_MCP_BUNDLE` - path to a local `.mcpb` bundle to extract the binary + from (testing; skips download and sha verification). + +## Other installation vectors + +- Claude Desktop one-click bundles (`.mcpb`): + https://github.com/stackql/stackql/releases/latest +- Docker: `docker run -i --rm stackql/stackql-mcp` +- Native installers and package managers: https://stackql.io/docs/installing-stackql + +## Links + +- Docs: https://stackql.io/docs +- MCP Registry: `io.github.stackql/stackql-mcp` +- Source: https://github.com/stackql/stackql diff --git a/npm/bin/stackql-mcp.js b/npm/bin/stackql-mcp.js new file mode 100644 index 0000000..a6ae97e --- /dev/null +++ b/npm/bin/stackql-mcp.js @@ -0,0 +1,128 @@ +#!/usr/bin/env node +/* + * stackql-mcp - npx-able launcher for the StackQL MCP server. + * + * On first run, downloads the platform's signed .mcpb bundle from the GitHub + * release pinned in platforms.json, verifies its sha256, extracts the stackql + * binary into ~/.stackql/mcp-server-bin//, then spawns it as an MCP + * stdio server. Subsequent runs use the cached binary. + * + * Extra arguments are passed through to stackql after the standard MCP args, + * e.g.: npx -y @stackql/mcp-server --auth='{"github":{"type":"null_auth"}}' + * + * Env overrides: + * STACKQL_MCP_BIN path to an existing stackql binary (skips download) + * STACKQL_MCP_BUNDLE path to a local .mcpb to extract from (CI/testing; + * skips download and sha verification) + * + * All diagnostics go to stderr - stdout belongs to the MCP protocol. + */ +"use strict"; + +const { spawn } = require("child_process"); +const crypto = require("crypto"); +const fs = require("fs"); +const os = require("os"); +const path = require("path"); + +const manifest = require("../platforms.json"); + +function platformKey() { + const { platform, arch } = process; + if (platform === "linux" && arch === "x64") return "linux-x64"; + if (platform === "linux" && arch === "arm64") return "linux-arm64"; + if (platform === "win32" && arch === "x64") return "windows-x64"; + if (platform === "darwin") return "darwin-universal"; // universal binary covers x64 + arm64 + return null; +} + +async function download(url) { + const res = await fetch(url, { redirect: "follow" }); + if (!res.ok) { + throw new Error(`download failed: HTTP ${res.status} for ${url}`); + } + return Buffer.from(await res.arrayBuffer()); +} + +function extractBinary(bundleBuf, entryName, destPath) { + const AdmZip = require("adm-zip"); + const zip = new AdmZip(bundleBuf); + const entry = zip.getEntry(entryName); + if (!entry) { + throw new Error(`${entryName} not found in bundle`); + } + fs.mkdirSync(path.dirname(destPath), { recursive: true }); + // write-then-rename so a concurrent first run cannot see a half-written binary + const tmp = `${destPath}.tmp-${process.pid}`; + fs.writeFileSync(tmp, entry.getData(), { mode: 0o755 }); + fs.renameSync(tmp, destPath); +} + +async function ensureBinary() { + if (process.env.STACKQL_MCP_BIN) { + return process.env.STACKQL_MCP_BIN; + } + + const key = platformKey(); + if (!key) { + throw new Error(`unsupported platform: ${process.platform}/${process.arch}`); + } + const info = manifest.platforms[key]; + const binName = key === "windows-x64" ? "stackql.exe" : "stackql"; + const binPath = path.join( + os.homedir(), ".stackql", "mcp-server-bin", manifest.version, key, binName + ); + if (fs.existsSync(binPath)) { + return binPath; + } + + let bundleBuf; + if (process.env.STACKQL_MCP_BUNDLE) { + bundleBuf = fs.readFileSync(process.env.STACKQL_MCP_BUNDLE); + } else { + const url = `${manifest.baseUrl}/${info.bundle}`; + console.error(`stackql-mcp: downloading ${info.bundle} (first run only) ...`); + bundleBuf = await download(url); + const digest = crypto.createHash("sha256").update(bundleBuf).digest("hex"); + if (digest !== info.sha256) { + throw new Error( + `sha256 mismatch for ${info.bundle}\n expected ${info.sha256}\n got ${digest}` + ); + } + } + extractBinary(bundleBuf, `server/${binName}`, binPath); + console.error(`stackql-mcp: installed ${binPath}`); + return binPath; +} + +async function main() { + const bin = await ensureBinary(); + // approot and the audit sink must not depend on the cwd: MCP clients may + // launch this with cwd '/' (read-only on macOS). Later duplicate flags win, + // so user-passed overrides still take effect. + const args = [ + "mcp", + "--mcp.server.type=stdio", + "--approot", path.join(os.homedir(), ".stackql"), + "--mcp.config", JSON.stringify({ server: { audit: { disabled: true } } }), + ...process.argv.slice(2), + ]; + const child = spawn(bin, args, { stdio: "inherit", windowsHide: true }); + for (const sig of ["SIGINT", "SIGTERM"]) { + process.on(sig, () => { + try { child.kill(sig); } catch {} + }); + } + child.on("error", (err) => { + console.error(`stackql-mcp: failed to start server: ${err.message}`); + process.exit(1); + }); + child.on("exit", (code, signal) => { + process.exit(signal ? 1 : code === null ? 1 : code); + }); +} + +main().catch((err) => { + console.error(`stackql-mcp: ${err.message}`); + process.exit(1); +}); diff --git a/npm/package.json b/npm/package.json new file mode 100644 index 0000000..e98587f --- /dev/null +++ b/npm/package.json @@ -0,0 +1,36 @@ +{ + "name": "@stackql/mcp-server", + "version": "0.10.500", + "description": "StackQL MCP server - SQL-native query and provisioning engine for cloud infrastructure, served over MCP. Downloads the signed stackql binary on first run.", + "bin": { + "stackql-mcp": "bin/stackql-mcp.js" + }, + "files": [ + "bin/", + "platforms.json", + "README.md" + ], + "mcpName": "io.github.stackql/stackql-mcp", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/stackql/stackql.git" + }, + "homepage": "https://stackql.io", + "keywords": [ + "mcp", + "model-context-protocol", + "stackql", + "sql", + "cloud", + "infrastructure", + "devops", + "multicloud" + ], + "dependencies": { + "adm-zip": "^0.5.16" + } +} diff --git a/oci/Dockerfile b/oci/Dockerfile new file mode 100644 index 0000000..749bd5b --- /dev/null +++ b/oci/Dockerfile @@ -0,0 +1,43 @@ +# syntax=docker/dockerfile:1 +# +# StackQL MCP server image. The binary is extracted from the signed release +# zips by 'make oci-stage' into .oci-stage/linux// before building - +# this Dockerfile only copies, it does not download. +# +# make oci VERSION=X.Y.Z local single-arch build (amd64) for testing +# make oci-push VERSION=X.Y.Z multi-arch build + push (requires docker login) +# +# Run as an MCP stdio server: +# docker run -i --rm stackql/stackql-mcp:X.Y.Z + +FROM debian:bookworm-slim + +ARG TARGETARCH +ARG VERSION=dev + +RUN apt-get update \ + && apt-get install -y --no-install-recommends ca-certificates \ + && rm -rf /var/lib/apt/lists/* \ + && useradd --create-home --uid 10001 stackql + +# --chmod: the staged file's mode depends on the host that ran unzip (Windows +# stages it non-executable), so set it explicitly. +COPY --chmod=0755 .oci-stage/linux/${TARGETARCH}/stackql /usr/local/bin/stackql + +# io.modelcontextprotocol.server.name is required by the Official MCP Registry +# to validate namespace ownership of oci packages. +LABEL io.modelcontextprotocol.server.name="io.github.stackql/stackql-mcp" \ + org.opencontainers.image.title="StackQL MCP Server" \ + org.opencontainers.image.description="SQL-native query and provisioning engine for cloud infrastructure, served over MCP." \ + org.opencontainers.image.url="https://stackql.io" \ + org.opencontainers.image.source="https://github.com/stackql/stackql" \ + org.opencontainers.image.version="${VERSION}" \ + org.opencontainers.image.licenses="MIT" + +USER stackql +WORKDIR /home/stackql + +ENTRYPOINT ["/usr/local/bin/stackql"] +# approot and audit are pinned to writable locations even though the container +# home is writable - same cwd-independence rules as the .mcpb manifest. +CMD ["mcp", "--mcp.server.type=stdio", "--approot", "/home/stackql/.stackql", "--mcp.config", "{\"server\": {\"audit\": {\"disabled\": true}}}"] diff --git a/registry/server.template.json b/registry/server.template.json index f370db2..d6b1dca 100644 --- a/registry/server.template.json +++ b/registry/server.template.json @@ -37,6 +37,20 @@ "version": "__VERSION__", "fileSha256": "__SHA_DARWIN_UNIVERSAL__", "transport": { "type": "stdio" } + }, + { + "registryType": "oci", + "registryBaseUrl": "https://docker.io", + "identifier": "stackql/stackql-mcp", + "version": "__VERSION__", + "transport": { "type": "stdio" } + }, + { + "registryType": "npm", + "registryBaseUrl": "https://registry.npmjs.org", + "identifier": "@stackql/mcp-server", + "version": "__VERSION__", + "transport": { "type": "stdio" } } ], "_meta": { diff --git a/scripts/render-npm-manifest.sh b/scripts/render-npm-manifest.sh new file mode 100755 index 0000000..62a5cc4 --- /dev/null +++ b/scripts/render-npm-manifest.sh @@ -0,0 +1,78 @@ +#!/usr/bin/env bash +# +# render-npm-manifest.sh - render npm/platforms.json (bundle URLs + sha256 +# pins) and stamp the version into npm/package.json. +# +# Fetches the canonical .sha256 files from the published GitHub release, so it +# must run AFTER the .mcpb assets for the version are published - the same +# rule as render-server-json.sh. Locally built bundles have different bytes +# than the CI-published ones; never pin local hashes. +# +# Usage: +# scripts/render-npm-manifest.sh --version 0.10.500 +# VERSION=0.10.500 scripts/render-npm-manifest.sh +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +NPM_DIR="${NPM_DIR:-$ROOT_DIR/npm}" +RELEASE_BASE="https://github.com/stackql/stackql/releases/download" + +VERSION="${VERSION:-}" +while [ $# -gt 0 ]; do + case "$1" in + --version) VERSION="$2"; shift 2 ;; + --version=*) VERSION="${1#*=}"; shift ;; + -h|--help) sed -n '2,14p' "$0"; exit 0 ;; + *) echo "unknown argument: $1" >&2; exit 2 ;; + esac +done +[ -n "$VERSION" ] || { echo "error: --version required (or VERSION=X.Y.Z)" >&2; exit 2; } + +base_url="$RELEASE_BASE/v$VERSION" + +fetch_sha() { + # args: target-label -> prints the hex digest from the published .sha256 + local target="$1" line + line="$(curl -fsSL "$base_url/stackql-mcp-$target.mcpb.sha256")" || { + echo "error: could not fetch sha256 for $target - are the v$VERSION .mcpb assets published?" >&2 + exit 1 + } + echo "$line" | awk '{print $1; exit}' +} + +SHA_LINUX_X64="$(fetch_sha linux-x64)" +SHA_LINUX_ARM64="$(fetch_sha linux-arm64)" +SHA_WINDOWS_X64="$(fetch_sha windows-x64)" +SHA_DARWIN_UNIVERSAL="$(fetch_sha darwin-universal)" + +cat > "$NPM_DIR/platforms.json" </dev/null 2>&1; then + pkg_json="$(cygpath -m "$pkg_json")" +fi +PKG_JSON="$pkg_json" NEW_VERSION="$VERSION" node -e " +const fs = require('fs'); +const p = process.env.PKG_JSON; +const pkg = JSON.parse(fs.readFileSync(p, 'utf8')); +pkg.version = process.env.NEW_VERSION; +fs.writeFileSync(p, JSON.stringify(pkg, null, 2) + '\n'); +" + +echo "wrote $NPM_DIR/platforms.json (version $VERSION)" +echo " linux-x64 $SHA_LINUX_X64" +echo " linux-arm64 $SHA_LINUX_ARM64" +echo " windows-x64 $SHA_WINDOWS_X64" +echo " darwin-universal $SHA_DARWIN_UNIVERSAL" diff --git a/scripts/smoke-test.py b/scripts/smoke-test.py index d640330..99ed305 100644 --- a/scripts/smoke-test.py +++ b/scripts/smoke-test.py @@ -16,6 +16,7 @@ import json import os +import shlex import subprocess import sys import tempfile @@ -128,83 +129,108 @@ def run(bundle_path: Path) -> None: 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. - proc = subprocess.Popen( - cmd, - stdin=subprocess.PIPE, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, + exercise(cmd) + + log("smoke test passed") + + +def run_command(cmd: list[str]) -> None: + """Smoke-test an arbitrary command that speaks MCP stdio (docker image, + npm wrapper, ...). The command must accept extra stackql flags appended + after its own arguments (used to pass --auth for the github provider).""" + exercise(cmd + [f"--auth={GITHUB_AUTH}"]) + log("smoke test passed") + + +def exercise(cmd: list[str]) -> None: + 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. + proc = subprocess.Popen( + cmd, + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + try: + client = JsonRpcClient(proc) + + client.send( + "initialize", + { + "protocolVersion": "2024-11-05", + "capabilities": {}, + "clientInfo": {"name": "stackql-mcpb-smoke", "version": "1"}, + }, + id_=1, ) + init = client.wait(1, INIT_TIMEOUT_S) + if "result" not in init: + fail(f"initialize did not return a result: {init}") + log(f"initialize ok: server={init['result'].get('serverInfo', {}).get('name')}") + + client.send("notifications/initialized", {}) + + client.send("tools/list", {}, id_=2) + tools = client.wait(2, CALL_TIMEOUT_S).get("result", {}).get("tools", []) + tool_names = {t["name"] for t in tools} + log(f"tools/list returned {len(tool_names)} tools") + for required in ("pull_provider", "list_services", "list_providers"): + if required not in tool_names: + fail(f"missing required tool: {required}") + + client.send( + "tools/call", + {"name": "pull_provider", "arguments": {"provider": "github"}}, + id_=3, + ) + pull = client.wait(3, CALL_TIMEOUT_S) + if "error" in pull: + fail(f"pull_provider failed: {pull['error']}") + log("pull_provider github ok") + + client.send( + "tools/call", + {"name": "list_services", "arguments": {"provider": "github", "row_limit": 5}}, + id_=4, + ) + services = client.wait(4, CALL_TIMEOUT_S) + if "error" in services: + fail(f"list_services failed: {services['error']}") + content = services.get("result", {}).get("content", []) + text_blocks = [c.get("text", "") for c in content if isinstance(c, dict)] + joined = "\n".join(text_blocks) + if "actions" not in joined and "apps" not in joined: + fail(f"list_services did not include expected github services. content={content!r}") + log("list_services returned github services (saw expected service names)") + finally: try: - client = JsonRpcClient(proc) - - client.send( - "initialize", - { - "protocolVersion": "2024-11-05", - "capabilities": {}, - "clientInfo": {"name": "stackql-mcpb-smoke", "version": "1"}, - }, - id_=1, - ) - init = client.wait(1, INIT_TIMEOUT_S) - if "result" not in init: - fail(f"initialize did not return a result: {init}") - log(f"initialize ok: server={init['result'].get('serverInfo', {}).get('name')}") - - client.send("notifications/initialized", {}) - - client.send("tools/list", {}, id_=2) - tools = client.wait(2, CALL_TIMEOUT_S).get("result", {}).get("tools", []) - tool_names = {t["name"] for t in tools} - log(f"tools/list returned {len(tool_names)} tools") - for required in ("pull_provider", "list_services", "list_providers"): - if required not in tool_names: - fail(f"missing required tool: {required}") - - client.send( - "tools/call", - {"name": "pull_provider", "arguments": {"provider": "github"}}, - id_=3, - ) - pull = client.wait(3, CALL_TIMEOUT_S) - if "error" in pull: - fail(f"pull_provider failed: {pull['error']}") - log("pull_provider github ok") - - client.send( - "tools/call", - {"name": "list_services", "arguments": {"provider": "github", "row_limit": 5}}, - id_=4, - ) - services = client.wait(4, CALL_TIMEOUT_S) - if "error" in services: - fail(f"list_services failed: {services['error']}") - content = services.get("result", {}).get("content", []) - text_blocks = [c.get("text", "") for c in content if isinstance(c, dict)] - joined = "\n".join(text_blocks) - if "actions" not in joined and "apps" not in joined: - fail(f"list_services did not include expected github services. content={content!r}") - log("list_services returned github services (saw expected service names)") - finally: - try: - if proc.stdin and not proc.stdin.closed: - proc.stdin.close() - except Exception: - pass - try: - proc.terminate() - proc.wait(timeout=5) - except subprocess.TimeoutExpired: - proc.kill() + if proc.stdin and not proc.stdin.closed: + proc.stdin.close() + except Exception: + pass + try: + proc.terminate() + proc.wait(timeout=5) + except subprocess.TimeoutExpired: + proc.kill() - log("smoke test passed") +USAGE = """usage: + smoke-test.py test an MCPB bundle (manifest-driven args) + smoke-test.py --docker test a docker image (stdio MCP server) + smoke-test.py --cmd "" test an arbitrary stdio MCP command + (e.g. the npm wrapper)""" if __name__ == "__main__": - if len(sys.argv) != 2: - print(f"usage: {sys.argv[0]} ", file=sys.stderr) + if len(sys.argv) == 3 and sys.argv[1] == "--docker": + run_command( + ["docker", "run", "-i", "--rm", sys.argv[2], "mcp", "--mcp.server.type=stdio"] + ) + elif len(sys.argv) == 3 and sys.argv[1] == "--cmd": + run_command(shlex.split(sys.argv[2])) + elif len(sys.argv) == 2 and not sys.argv[1].startswith("-"): + run(Path(sys.argv[1])) + else: + print(USAGE, file=sys.stderr) sys.exit(2) - run(Path(sys.argv[1])) From 96b65b7ac83a44121cb234e5d341603f65b2407a Mon Sep 17 00:00:00 2001 From: Jeffrey Aven Date: Sat, 13 Jun 2026 08:31:46 +1000 Subject: [PATCH 2/3] Add PyPI wrapper install vector (stackql-mcp-server) Same launcher pattern as the npm wrapper, in stdlib-only Python (zero dependencies): downloads the platform's published .mcpb on first run, verifies sha256 against pins baked into the package, caches under ~/.stackql/mcp-server-bin/ (shared with the npm wrapper), execs the binary with the cwd-safe args (os.execv on posix, subprocess on Windows). Console scripts: stackql-mcp and stackql-mcp-server (the latter so 'uvx stackql-mcp-server' resolves directly). - pypi/: hatchling pyproject, package module, README with the 'mcp-name: io.github.stackql/stackql-mcp' marker the MCP Registry requires for pypi namespace validation - scripts/render-pypi-manifest.sh + make pypi-manifest / pypi-build (same post-publish ordering rule as npm; twine upload stays manual for 2FA) - build.yml: pypi-wrapper smoke job (venv install, STACKQL_MCP_BUNDLE override); publish.yml: pypi-dist artifact job - registry template: pypi package entry (schema-validated) - docs: install.md section 5 (uvx/pip), README/CLAUDE.md/npm README Verified locally: full smoke via the installed console script against published 0.10.500 assets (download, sha verify, extract, spawn, full MCP exercise), sdist+wheel build, wheel contains platforms.json + entry points + mcp-name marker in METADATA. Co-Authored-By: Claude Fable 5 --- .github/workflows/build.yml | 38 ++ .github/workflows/publish.yml | 23 + .gitignore | 4 + CLAUDE.md | 508 +++++++++-------- Makefile | 14 +- README.md | 730 ++++++++++++------------ docs/install.md | 23 +- npm/README.md | 135 ++--- pypi/README.md | 71 +++ pypi/pyproject.toml | 41 ++ pypi/src/stackql_mcp_server/__init__.py | 128 +++++ registry/server.template.json | 7 + scripts/render-pypi-manifest.sh | 67 +++ 13 files changed, 1108 insertions(+), 681 deletions(-) create mode 100644 pypi/README.md create mode 100644 pypi/pyproject.toml create mode 100644 pypi/src/stackql_mcp_server/__init__.py create mode 100755 scripts/render-pypi-manifest.sh diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 0d2bd68..df96418 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -151,3 +151,41 @@ jobs: env: STACKQL_MCP_BUNDLE: dist/stackql-mcp-linux-x64.mcpb run: python3 scripts/smoke-test.py --cmd "node npm/bin/stackql-mcp.js" + + # PyPI wrapper: same constraints and approach as the npm wrapper - the real + # platforms.json pins published-asset hashes, so CI tests the wrapper logic + # against a locally built bundle via STACKQL_MCP_BUNDLE with placeholder pins. + pypi-wrapper: + name: pypi wrapper (smoke) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + + - name: Build local bundle for the wrapper to consume + run: make one TARGET=linux-x64 VERSION=${{ inputs.version }} + + - name: Render placeholder manifest + run: | + zeros="0000000000000000000000000000000000000000000000000000000000000000" + cat > pypi/src/stackql_mcp_server/platforms.json </...` into `bin/`, then builds every available bundle: - -```bash -make all VERSION=X.Y.Z -# 'make VERSION=X.Y.Z' is equivalent ('all' is the default target) -# 'make' alone uses the version pinned in release.yaml -``` - -Just download (skip packaging): - -```bash -make download VERSION=X.Y.Z -``` - -Just package whatever is already in `bin/` (skip downloading): - -```bash -make package VERSION=X.Y.Z -# or call the script directly: -./scripts/package.sh --version X.Y.Z -``` - -Build a single target. Two variants: - -```bash -# Download just that target's source artefact and build only that bundle. -# Use this on a Mac to do the darwin slice in the two-machine release flow. -make one TARGET=darwin-universal VERSION=X.Y.Z -make one TARGET=linux-x64 VERSION=X.Y.Z - -# Build from already-present artefacts in bin/ (temporarily hides the -# others under bin/.hidden/ and restores them after). -make linux-x64 VERSION=X.Y.Z -make linux-arm64 VERSION=X.Y.Z -make windows-x64 VERSION=X.Y.Z -make darwin-universal VERSION=X.Y.Z -``` - -Self-signed bundles (testing only - production envelope signing is not currently wired up; see "Trust model" below): - -```bash -make signed VERSION=X.Y.Z -``` - -Build the OCI image (linux amd64 locally for testing; multi-arch push needs `docker login` with push rights on `docker.io/stackql/stackql-mcp`): - -```bash -make oci VERSION=X.Y.Z # local amd64 build from the release zips -python scripts/smoke-test.py --docker stackql/stackql-mcp:X.Y.Z -make oci-push VERSION=X.Y.Z # multi-arch (amd64+arm64) build + push -``` - -Build the npm wrapper package (`@stackql/mcp-server`, an npx-able launcher that downloads the platform's published `.mcpb`, verifies the sha256 pins baked into the package, caches the binary under `~/.stackql/mcp-server-bin/`, and spawns it). ORDERING RULE: `make npm-manifest` fetches the canonical `.sha256` files from the published release - it must run AFTER the `.mcpb` assets for the version are published, same as `make server-json`. Publishing to npmjs is manual (2FA): - -```bash -make npm-manifest VERSION=X.Y.Z # render npm/platforms.json + stamp version -python scripts/smoke-test.py --cmd "node npm/bin/stackql-mcp.js" -make npm-pack VERSION=X.Y.Z # build the tarball -cd npm && npm publish --access public -``` - -The smoke test gates all three vectors: `smoke-test.py ` (manifest-driven args), `--docker `, and `--cmd ""`. Registry namespace validation is baked in: the npm package.json carries `mcpName: io.github.stackql/stackql-mcp` and the Dockerfile carries the `io.modelcontextprotocol.server.name` label - the Official MCP Registry checks both before accepting the oci/npm package entries in server.json. - -Upload everything in `dist/` to the matching `stackql/stackql` release (requires `gh auth login` with `contents:write` on `stackql/stackql`; idempotent via `--clobber`): - -```bash -make publish VERSION=X.Y.Z -``` - -Wipe outputs / inputs: - -```bash -make clean # remove dist/*.mcpb and *.sha256 -make clean-bin # remove downloaded artefacts from bin/ -``` - -Show what is currently in the drop-zone: - -```bash -make list -``` - -## Release flow - -The primary flow is GitHub Actions; the two-machine local flow below remains a supported fallback. The darwin target needs `pkgutil`, so CI builds it on a `macos-latest` runner. - -### CI flow (GitHub Actions) - -Three workflows in [.github/workflows/](.github/workflows/): - -- **[build.yml](.github/workflows/build.yml)** - reusable. Builds each bundle with `make one TARGET= VERSION=` on a runner that can execute the embedded binary (`ubuntu-latest`, `ubuntu-24.04-arm`, `macos-latest`), runs `scripts/smoke-test.py` against it, and uploads `dist/` artefacts. The windows-x64 bundle builds on ubuntu (packaging has no Windows dependency) and is smoke-tested on `windows-latest` from the artifact. The Gemini agent check runs on linux-x64 only and soft-skips without `GEMINI_API_KEY`. -- **[ci.yml](.github/workflows/ci.yml)** - on PRs to main and pushes to main. Reads `stackql_release` from [release.yaml](release.yaml) and calls build.yml. Nothing is published. -- **[publish.yml](.github/workflows/publish.yml)** - on pushing a `v*` tag. Fails fast if the tag does not exactly match `stackql_release` in release.yaml, rebuilds and smoke-tests everything, optionally envelope-signs the bundles (runs `make sign` when the `MCPB_SIGNING_CERT`/`MCPB_SIGNING_KEY` PEM-content secrets are set; soft-skips otherwise), then runs `make publish` to upload all bundles + `.sha256` files to the matching `stackql/stackql` release. Requires the `STACKQL_RELEASE_TOKEN` repo secret (fine-grained PAT with `contents:write` on `stackql/stackql` - the default `GITHUB_TOKEN` cannot upload cross-repo). Signing happens before checksumming: the signature is appended to the bundle bytes, so `make sign` regenerates each `.sha256`. `mcpb verify` is broken in the current upstream CLI (node-forge cannot verify PKCS#7); the scripts treat it as advisory and assert the appended `MCPB_SIG_END` block instead. - -The release sequence: upstream `stackql/stackql` release publishes the core assets -> raise a PR here bumping `stackql_release` in release.yaml (CI proves the bundles build and pass smoke tests against the real release assets) -> merge -> push the matching tag (e.g. `v0.10.500`) -> publish.yml attaches the `.mcpb` assets to the upstream release. - -### Fallback: the two-machine local flow - -**Machine A (your workstation, any OS with bash + node + unzip):** - -```bash -make all VERSION=X.Y.Z # downloads release artefacts, builds linux-x64, - # linux-arm64, windows-x64. Darwin skips with a - # 'pkgutil not found' notice. -python scripts/smoke-test.py dist/stackql-mcp-linux-x64.mcpb # gate -make publish VERSION=X.Y.Z # uploads the 3 bundles + .sha256s to the - # stackql/stackql release matching v -``` - -**Machine B (a Mac - MacInCloud is the typical case):** - -```bash -git clone https://github.com/stackql/stackql-mcpb-packaging -cd stackql-mcpb-packaging -make one TARGET=darwin-universal VERSION=X.Y.Z # downloads only the .pkg, - # extracts the universal - # binary, builds 1 bundle -python scripts/smoke-test.py dist/stackql-mcp-darwin-universal.mcpb -make publish VERSION=X.Y.Z # uploads just that one bundle + sha -``` - -Each machine runs `gh auth login` once with a token that has `contents:write` on `stackql/stackql`. `make publish` uses `gh release upload --clobber`, so it is idempotent and the order between the two machines does not matter. Re-running either step is safe. - -The Mac machine only needs Node.js (for `mcpb` via `npx`) on top of the default macOS toolchain - `make`, `curl`, `unzip`, `pkgutil`, `shasum`, `find` are all preinstalled. - -### Smoke tests - -Two layers, both in [scripts/](scripts/): - -- **[scripts/smoke-test.py](scripts/smoke-test.py)** - deterministic gate. Extracts the `.mcpb`, spawns `stackql mcp --mcp.server.type=stdio --auth='{"github":{"type":"null_auth"}}'`, runs the JSON-RPC handshake, asserts `tools/list` contains `pull_provider`/`list_services`/`list_providers`, calls `pull_provider` for `github`, then `list_services` and confirms real github services come back. Stdlib only. Run before `make publish`: - - ```bash - python scripts/smoke-test.py dist/stackql-mcp-linux-x64.mcpb - ``` - -- **[scripts/gemini-smoke.py](scripts/gemini-smoke.py)** - optional agent check using Gemini Flash. Exposes the MCP tools to Gemini via function calling and asks it to pull github and list services. Skips with exit 0 if `GEMINI_API_KEY` is not set; on failure prints `WARN:` and exits 0. Stdlib only - calls `generativelanguage.googleapis.com` directly via `urllib`. `GEMINI_MODEL` defaults to `gemini-2.0-flash`. - -Both scripts use the `github` provider in `null_auth` mode so no credentials are needed - they hit the public github registry endpoints. - -## Trust model - -The end goal is signed, verifiable, functional MCP binary assets distributed through trusted marketplaces. Today the layers are: - -1. **Mach-O / Authenticode signatures on the embedded binary** - applied upstream during the stackql release build. Windows: Authenticode-signed `stackql.exe`. macOS: Developer ID Application signature embedded in the universal `stackql` binary inside the `.pkg`, plus Apple notarisation keyed to the binary's cdhash. Linux: no platform-level signing, by convention. -2. **SHA-256 on the bundle envelope** - written by `package.sh` next to every `.mcpb`. Published with the bundle and pinned in the official MCP Registry `server.json`. Anyone installing the bundle can verify the bytes. -3. **MCPB envelope signature (`mcpb sign`)** - wired but inactive until signing material is configured. `make sign` (scripts/sign.sh) signs `dist/*.mcpb` in place and regenerates checksums; the publish workflow calls it and soft-skips unless the `MCPB_SIGNING_CERT`/`MCPB_SIGNING_KEY` secrets are set. The same `MCPB_SELF_SIGN`/`MCPB_SIGN_CERT`/`MCPB_SIGN_KEY`/`MCPB_SIGN_INTERMEDIATES` hooks remain in `package.sh` for sign-at-build. -4. **Anthropic Desktop Extensions directory listing** - the editorial "vetted by Claude" signal that users see in Claude Desktop's Browse Extensions UI. Submission is via the review form at `claude.com/docs/connectors/building/submission`; requirements (privacy policy, logo, screenshots) are in [listings.md](listings.md). -5. **Official MCP Registry entry** - canonical metadata pointing at the GitHub release assets and pinning their SHA-256. - -The notarised `.pkg` does the load-bearing trust work for macOS users: Gatekeeper validates the cdhash online when the bundled binary launches, so the binary inside the `.mcpb` is the same trusted binary users get from the `.pkg` installer. The unsigned `.mcpb` envelope is a Claude Desktop UI signal, not a Gatekeeper signal - addressing it requires either a self-signed cert (low value) or a real code-signing cert held in an HSM (the production answer). Until then, the registry SHA-256 plus the embedded platform signatures are what marketplaces verify against. - -Self-signed bundle (testing only): - -```bash -MCPB_SELF_SIGN=true ./scripts/package.sh --version X.Y.Z -``` - -Production-signed bundle: - -```bash -MCPB_SIGN_CERT=cert.pem \ -MCPB_SIGN_KEY=key.pem \ -MCPB_SIGN_INTERMEDIATES="intermediate-ca.pem root-ca.pem" \ -./scripts/package.sh --version X.Y.Z -``` - -`MCPB_SIGN_INTERMEDIATES` is optional and space-separated. Bundle signing is OFF by default. When unset, the script prints a notice and skips. - -The script invokes `mcpb` if on PATH, otherwise falls back to `npx --yes @anthropic-ai/mcpb`. - -## Bin drop-zone layout (required before running package.sh) - -`bin/` is gitignored except for its `README.md` and `.gitignore`. `package.sh` reads the release artefacts directly - no manual extraction. Expected files at the root of `bin/`: - -``` -bin/ - stackql_linux_amd64.zip # contains stackql - stackql_linux_arm64.zip # contains stackql - stackql_windows_amd64.zip # contains stackql.exe (Authenticode-signed upstream) - stackql_darwin_multiarch.pkg # notarised .pkg, universal binary inside -``` - -The darwin glob is `stackql_darwin*.pkg`, so any suffix works. Any target whose source artefact is absent is skipped with a notice - partial drops produce partial bundle sets. - -A legacy fallback layout (pre-extracted binaries at `bin//stackql[.exe]` and `bin/darwin/*.pkg`) is also accepted. The release-artefact layout takes precedence when both are present. - -## Architecture and flow - -Single bash script ([scripts/package.sh](scripts/package.sh)) drives everything. For each target: - -1. Stage a temp dir with `server/` and a per-target `manifest.json` rendered from [manifest/manifest.template.json](manifest/manifest.template.json) by `sed`-substituting `__VERSION__` and `__BINARY_NAME__`. -2. `mcpb validate` the manifest, then `mcpb pack` the staging dir into `dist/stackql-mcp-