Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 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
4 changes: 4 additions & 0 deletions .github/release-notes/imcp2-local-install-note.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
<!-- install-script-summary -->
> **What the install scripts below do:** each downloads the `imcp2-local` binary for your platform from this release, installs it plus an auto-updater into `~/.cargo/bin`, and adds that directory to your PATH — the shell script by appending a line to every shell profile it can find, the PowerShell script by editing your `Path` registry key. The shell script also compares a checksum baked into itself, but skips that check silently on stock macOS, which has no `sha256sum`; the PowerShell script does not check one at all. Where that shell checksum does run it ships inside the very script being piped to a shell, so on either platform the attestation commands at the end of these notes are what establish provenance — `IMCP2_LOCAL_NO_MODIFY_PATH=1` and `IMCP2_LOCAL_DISABLE_UPDATE=1` opt out of the PATH edits and the updater.
>
> **Then connect it to your AI tool:** `imcp2-local setup` registers the server with the clients on this machine — Claude Desktop, Claude Code, Codex, Cursor, Antigravity — and prints Perplexity's UI steps; `imcp2-local setup --print` shows each client's steps without writing anything, and `imcp2-local setup --remove` undoes them. Restart the client afterwards. On Claude Desktop you can skip both steps: double-click `imcp2-local.mcpb` from this release (it is not yet code-signed, so expect an unverified-developer warning). The [README](https://github.com/dfinity/imcp2/blob/main/crates/imcp2-local/README.md#register-it-with-your-ai-tools) lists the per-client registration each one receives.
185 changes: 185 additions & 0 deletions .github/scripts/build-mcpb.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,185 @@
#!/usr/bin/env bash
# Build the Claude Desktop bundle (.mcpb) for one imcp2-local release.
#
# .github/scripts/build-mcpb.sh <tag> <out-dir>
# e.g. .github/scripts/build-mcpb.sh imcp2-local-v0.5.0 dist-mcpb
#
# The bundle is assembled from the release's own published archives, so it
# carries exactly the binaries that release attests, each checked against the
# release's .sha256 before use:
#
# server/imcp2-local universal macOS binary (arm64 + x86_64)
# server/imcp2-local.exe Windows x64
#
# Why universal: MCPB's `platform_overrides` key on the OS alone
# (darwin/win32/linux), not the CPU, so one bundle cannot choose between the
# Apple Silicon and Intel builds. One fat Mach-O runs on both. Windows needs
# no override: for binary servers Claude Desktop appends `.exe` to the
# command itself, so the manifest names the file without it.
#
# Every input archive must carry this repository's release-workflow
# attestation for this very tag before it is used (see verify_provenance).
#
# The manifest's version and tool list are filled in here, the tools from the
# release's own binary answering `tools/list`, so the install dialog can
# never advertise a surface the shipped server doesn't have.
#
# Env:
# LIPO lipo implementation (default `lipo`; `llvm-lipo` works off macOS)
# MCPB_VERSION @anthropic-ai/mcpb CLI version (pinned below)
# MCPB_ALLOW_UNATTESTED `1` builds without verifying provenance; refused in CI
set -euo pipefail

tag="${1:?usage: build-mcpb.sh <tag> <out-dir>}"
out="${2:?usage: build-mcpb.sh <tag> <out-dir>}"
case "$tag" in
imcp2-local-v*) ;;
*) echo "not an imcp2-local release tag: $tag" >&2; exit 2 ;;
esac
version="${tag#imcp2-local-v}"
base="https://github.com/dfinity/imcp2/releases/download/$tag"
repo_root="$(cd "$(dirname "$0")/../.." && pwd)"
lipo="${LIPO:-lipo}"
mcpb="@anthropic-ai/mcpb@${MCPB_VERSION:-2.1.2}"

work="$(mktemp -d)"
trap 'rm -rf "$work"' EXIT

# Verifying the inputs' provenance needs `gh`. It is not optional in CI; a
# local build may opt out explicitly, and says so loudly.
if command -v gh >/dev/null 2>&1; then
attested=1
elif [ "${MCPB_ALLOW_UNATTESTED:-}" = "1" ] && [ -z "${CI:-}" ]; then
attested=0
Comment thread
aterga marked this conversation as resolved.
Outdated
echo "WARNING: building from inputs whose provenance is NOT verified (MCPB_ALLOW_UNATTESTED=1)" >&2
else
echo "gh is required to verify the input archives' attestations" \
"(outside CI, MCPB_ALLOW_UNATTESTED=1 builds without)" >&2
exit 1
fi

# The .sha256 files come from the same mutable release as the archives, so
# they catch a corrupted download but cannot catch a replaced one — and this
# job goes on to attest what it builds, which would lend that attestation to
# whatever sat in the release. So each input must also carry an attestation
# from this repository's release workflow for this very tag; `--source-ref`
# refuses even a genuinely attested archive from an older release.
verify_provenance() {
gh attestation verify "$1" --repo dfinity/imcp2 \
--signer-workflow dfinity/imcp2/.github/workflows/imcp2-local-release.yml \
--source-ref "refs/tags/$tag" --deny-self-hosted-runners >/dev/null
}

# macOS ships `shasum`, not `sha256sum` — the very gap the release notes warn
# about in the installer, so this does not repeat it.
sha256() {
if command -v sha256sum >/dev/null 2>&1; then
sha256sum "$1" | awk '{print $1}'
else
shasum -a 256 "$1" | awk '{print $1}'
fi
}

# Download one release asset and refuse it unless it matches the release's
# published checksum.
fetch() {
local asset="$1" want got
curl -fsSL --retry 3 -o "$work/$asset" "$base/$asset"
curl -fsSL --retry 3 -o "$work/$asset.sha256" "$base/$asset.sha256"
want="$(awk '{print $1}' "$work/$asset.sha256")"
got="$(sha256 "$work/$asset")"
Comment thread
aterga marked this conversation as resolved.
if [ "$want" != "$got" ]; then
echo "checksum mismatch for $asset: want $want, got $got" >&2
exit 1
fi
if [ "$attested" = 1 ] && ! verify_provenance "$work/$asset"; then
echo "$asset has no release-workflow attestation for $tag; refusing it" >&2
exit 1
fi
}

fetch imcp2-local-aarch64-apple-darwin.tar.xz
fetch imcp2-local-x86_64-apple-darwin.tar.xz
fetch imcp2-local-x86_64-pc-windows-msvc.zip
for t in aarch64-apple-darwin x86_64-apple-darwin; do
tar -xJf "$work/imcp2-local-$t.tar.xz" -C "$work"
done
mkdir -p "$work/win"
unzip -q "$work/imcp2-local-x86_64-pc-windows-msvc.zip" -d "$work/win"

bundle="$work/bundle"
mkdir -p "$bundle/server"
"$lipo" -create -output "$bundle/server/imcp2-local" \
"$work/imcp2-local-aarch64-apple-darwin/imcp2-local" \
"$work/imcp2-local-x86_64-apple-darwin/imcp2-local"
chmod 0755 "$bundle/server/imcp2-local"
archs="$("$lipo" -archs "$bundle/server/imcp2-local")"
for a in arm64 x86_64; do
case " $archs " in
*" $a "*) ;;
*) echo "universal binary is missing the $a slice (has: $archs)" >&2; exit 1 ;;
esac
done

exe="$(find "$work/win" -type f -name 'imcp2-local.exe' | awk 'NR == 1')"
if [ -z "$exe" ]; then
echo "no imcp2-local.exe in the Windows archive" >&2
exit 1
fi
cp "$exe" "$bundle/server/imcp2-local.exe"
cp "$repo_root/crates/imcp2-local/mcpb/icon.png" "$bundle/icon.png"

# A binary this host can execute, to ask the shipped server for its tools.
case "$(uname -s)-$(uname -m)" in
Darwin-*) host_bin="$bundle/server/imcp2-local" ;;
Linux-x86_64 | Linux-aarch64)
t="$(uname -m)-unknown-linux-gnu"
fetch "imcp2-local-$t.tar.xz"
tar -xJf "$work/imcp2-local-$t.tar.xz" -C "$work"
host_bin="$work/imcp2-local-$t/imcp2-local"
;;
*) echo "cannot introspect the tool list on $(uname -s)-$(uname -m)" >&2; exit 1 ;;
esac

tools="$(python3 - "$host_bin" <<'PY'
Comment thread
aterga marked this conversation as resolved.
import json, os, re, subprocess, sys

proc = subprocess.Popen(
[sys.argv[1]], stdin=subprocess.PIPE, stdout=subprocess.PIPE, text=True, bufsize=1,
env=dict(os.environ, IMCP2_NO_OPEN="1", RUST_LOG="error"),
)

def call(rid, method, params):
proc.stdin.write(json.dumps({"jsonrpc": "2.0", "id": rid, "method": method, "params": params}) + "\n")
proc.stdin.flush()
return json.loads(proc.stdout.readline())

call(1, "initialize", {"protocolVersion": "2025-06-18", "capabilities": {},
"clientInfo": {"name": "build-mcpb", "version": "0"}})
proc.stdin.write(json.dumps({"jsonrpc": "2.0", "method": "notifications/initialized"}) + "\n")
proc.stdin.flush()
listed = call(2, "tools/list", {})["result"]["tools"]
proc.kill()

def summary(tool):
# The server's own display title where it has one ("Get Candid
# interface"); otherwise the description's first sentence.
title = (tool.get("annotations") or {}).get("title") or tool.get("title")
if title:
return title
text = " ".join((tool.get("description") or "").split())
first = re.match(r"(.+?[.!?])(?:\s|$)", text)
return (first.group(1) if first else text)[:240]

print(json.dumps([{"name": t["name"], "description": summary(t)} for t in listed]))
PY
)"

jq --arg version "$version" --argjson tools "$tools" \
'.version = $version | .tools = $tools' \
"$repo_root/crates/imcp2-local/mcpb/manifest.base.json" > "$bundle/manifest.json"

npx -y "$mcpb" validate "$bundle/manifest.json"
mkdir -p "$out"
npx -y "$mcpb" pack "$bundle" "$out/imcp2-local.mcpb"
Comment thread
aterga marked this conversation as resolved.
Outdated
echo "built $out/imcp2-local.mcpb for $tag ($(echo "$tools" | jq length) tools; macOS slices: $archs)"
66 changes: 66 additions & 0 deletions .github/workflows/imcp2-local-install-note.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
# Prepends a plain-language summary of what the install scripts do to the
# release notes `dist` writes for an `imcp2-local-v*` release.
#
# Why a job rather than a setting: dist builds the release body itself (the
# install one-liners, the download table, the attestation section) and has no
# config for extra prose — a custom job is the only documented seam. This one
# hangs off `post-announce-jobs` in dist-workspace.toml, so `dist generate`
# keeps wiring it in and the release workflow stays reproducible from config.
#
# The prose lives in .github/release-notes/imcp2-local-install-note.md so it
# is reviewed as rendered markdown rather than as a string inside YAML. Keep
# it in step with the installers and with the crate README's Install section.
#
# Runs after the release is published and edits the notes in place; the
# marker in that file makes a re-run a no-op.
name: imcp2-local install note

on:
workflow_call:
inputs:
plan:
required: true
type: string

jobs:
install-note:
runs-on: ubuntu-22.04
Comment thread
aterga marked this conversation as resolved.
Outdated
permissions:
contents: write
env:
PLAN: ${{ inputs.plan }}
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
NOTE_FILE: .github/release-notes/imcp2-local-install-note.md
steps:
# Pinned to a commit SHA, as every other action in this pipeline is.
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd
with:
persist-credentials: false

- name: Prepend the install-script summary to the release notes
shell: bash
run: |
set -euo pipefail

tag="$(printf '%s' "$PLAN" | jq -er '.announcement_tag')"
marker="$(head -n 1 "$NOTE_FILE")"

body="$(gh release view "$tag" --repo "$GITHUB_REPOSITORY" --json body -q .body)"
# A substring match in the shell, not `printf | grep -q`: under
# pipefail, grep exiting on the first match can SIGPIPE printf on a
# large body, fail the test, and prepend the note a second time.
case "$body" in
*"$marker"*)
echo "summary already present on $tag — nothing to do"
exit 0
;;
esac

{
cat "$NOTE_FILE"
printf '\n%s\n' "$body"
} > "$RUNNER_TEMP/notes.md"

gh release edit "$tag" --repo "$GITHUB_REPOSITORY" --notes-file "$RUNNER_TEMP/notes.md"
echo "install-script summary added to $tag"
62 changes: 62 additions & 0 deletions .github/workflows/imcp2-local-mcpb.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
# Builds the Claude Desktop bundle (`imcp2-local.mcpb`) for an
# `imcp2-local-v*` release, attests it, and attaches it to that release.
#
# Hung off `post-announce-jobs` in dist-workspace.toml: dist has no notion of
# an MCPB artifact, and the bundle needs the finished per-platform archives
# anyway, so it is assembled after the release exists — from that release's
# own published archives, each checked against its .sha256 (see
# .github/scripts/build-mcpb.sh for why the macOS binary is universal).
#
# The bundle is a new artifact with its own digest, so it gets its own
# provenance attestation rather than leaning on the archives'; the job needs
# `id-token`/`attestations` for that, granted to its caller through
# `github-custom-job-permissions`.
#
# Runs on macOS for the native `lipo` and to execute the universal binary it
# just built, which is how the manifest's tool list is read off the server.
name: imcp2-local Claude Desktop bundle

on:
workflow_call:
inputs:
plan:
required: true
type: string

jobs:
mcpb:
runs-on: macos-14
permissions:
contents: write
id-token: write
attestations: write
env:
PLAN: ${{ inputs.plan }}
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
steps:
# Pinned to a commit SHA, as every other action in this pipeline is.
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd
with:
persist-credentials: false

- name: Build the bundle from this release's archives
shell: bash
run: |
set -euo pipefail
tag="$(printf '%s' "$PLAN" | jq -er '.announcement_tag')"
echo "TAG=$tag" >> "$GITHUB_ENV"
.github/scripts/build-mcpb.sh "$tag" mcpb-out

- name: Attest
uses: actions/attest-build-provenance@96278af6caaf10aea03fd8d33a09a777ca52d62f
with:
subject-path: mcpb-out/imcp2-local.mcpb

- name: Attach to the release
shell: bash
run: |
set -euo pipefail
# --clobber so a re-run replaces the asset rather than failing on it.
gh release upload "$TAG" mcpb-out/imcp2-local.mcpb \
--repo "$GITHUB_REPOSITORY" --clobber
22 changes: 22 additions & 0 deletions .github/workflows/imcp2-local-release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -302,3 +302,25 @@ jobs:
with:
persist-credentials: false
submodules: recursive

custom-imcp2-local-install-note:
needs:
- plan
- announce
uses: ./.github/workflows/imcp2-local-install-note.yml
with:
plan: ${{ needs.plan.outputs.val }}
secrets: inherit

custom-imcp2-local-mcpb:
needs:
- plan
- announce
uses: ./.github/workflows/imcp2-local-mcpb.yml
with:
plan: ${{ needs.plan.outputs.val }}
secrets: inherit
permissions:
"attestations": "write"
"contents": "write"
"id-token": "write"
Loading
Loading