diff --git a/.github/workflows/imcp2-local-release.yml b/.github/workflows/imcp2-local-release.yml new file mode 100644 index 0000000..06a1963 --- /dev/null +++ b/.github/workflows/imcp2-local-release.yml @@ -0,0 +1,304 @@ +# This file was autogenerated by dist: https://axodotdev.github.io/cargo-dist +# +# Copyright 2022-2024, axodotdev +# SPDX-License-Identifier: MIT or Apache-2.0 +# +# CI that: +# +# * checks for a Git Tag that looks like a release +# * builds artifacts with dist (archives, installers, hashes) +# * uploads those artifacts to temporary workflow zip +# * on success, uploads the artifacts to a GitHub Release +# +# Note that the GitHub Release will be created with a generated +# title/body based on your changelogs. + +name: Release +permissions: + "contents": "write" + +# This task will run whenever you push a git tag that looks like a version +# like "1.0.0", "v0.1.0-prerelease.1", "my-app/0.1.0", "releases/v1.0.0", etc. +# Various formats will be parsed into a VERSION and an optional PACKAGE_NAME, where +# PACKAGE_NAME must be the name of a Cargo package in your workspace, and VERSION +# must be a Cargo-style SemVer Version (must have at least major.minor.patch). +# +# If PACKAGE_NAME is specified, then the announcement will be for that +# package (erroring out if it doesn't have the given version or isn't dist-able). +# +# If PACKAGE_NAME isn't specified, then the announcement will be for all +# (dist-able) packages in the workspace with that version (this mode is +# intended for workspaces with only one dist-able package, or with all dist-able +# packages versioned/released in lockstep). +# +# If you push multiple tags at once, separate instances of this workflow will +# spin up, creating an independent announcement for each one. However, GitHub +# will hard limit this to 3 tags per commit, as it will assume more tags is a +# mistake. +# +# If there's a prerelease-style suffix to the version, then the release(s) +# will be marked as a prerelease. +on: + pull_request: + push: + tags: + - 'imcp2-local**[0-9]+.[0-9]+.[0-9]+*' + +jobs: + # Run 'dist plan' (or host) to determine what tasks we need to do + plan: + runs-on: "ubuntu-22.04" + outputs: + val: ${{ steps.plan.outputs.manifest }} + tag: ${{ !github.event.pull_request && github.ref_name || '' }} + tag-flag: ${{ !github.event.pull_request && format('--tag={0}', github.ref_name) || '' }} + publishing: ${{ !github.event.pull_request }} + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd + with: + persist-credentials: false + submodules: recursive + - name: Install dist + # we specify bash to get pipefail; it guards against the `curl` command + # failing. otherwise `sh` won't catch that `curl` returned non-0 + shell: bash + run: "curl --proto '=https' --tlsv1.2 -LsSf https://github.com/axodotdev/cargo-dist/releases/download/v0.31.0/cargo-dist-installer.sh | sh" + - name: Cache dist + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f + with: + name: cargo-dist-cache + path: ~/.cargo/bin/dist + # sure would be cool if github gave us proper conditionals... + # so here's a doubly-nested ternary-via-truthiness to try to provide the best possible + # functionality based on whether this is a pull_request, and whether it's from a fork. + # (PRs run on the *source* but secrets are usually on the *target* -- that's *good* + # but also really annoying to build CI around when it needs secrets to work right.) + - id: plan + run: | + dist ${{ (!github.event.pull_request && format('host --steps=create --tag={0}', github.ref_name)) || 'plan' }} --output-format=json > plan-dist-manifest.json + echo "dist ran successfully" + cat plan-dist-manifest.json + echo "manifest=$(jq -c "." plan-dist-manifest.json)" >> "$GITHUB_OUTPUT" + - name: "Upload dist-manifest.json" + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f + with: + name: artifacts-plan-dist-manifest + path: plan-dist-manifest.json + + # Build and packages all the platform-specific things + build-local-artifacts: + name: build-local-artifacts (${{ join(matrix.targets, ', ') }}) + # Let the initial task tell us to not run (currently very blunt) + needs: + - plan + if: ${{ fromJson(needs.plan.outputs.val).ci.github.artifacts_matrix.include != null && (needs.plan.outputs.publishing == 'true' || fromJson(needs.plan.outputs.val).ci.github.pr_run_mode == 'upload') }} + strategy: + fail-fast: false + # Target platforms/runners are computed by dist in create-release. + # Each member of the matrix has the following arguments: + # + # - runner: the github runner + # - dist-args: cli flags to pass to dist + # - install-dist: expression to run to install dist on the runner + # + # Typically there will be: + # - 1 "global" task that builds universal installers + # - N "local" tasks that build each platform's binaries and platform-specific installers + matrix: ${{ fromJson(needs.plan.outputs.val).ci.github.artifacts_matrix }} + runs-on: ${{ matrix.runner }} + container: ${{ matrix.container && matrix.container.image || null }} + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + BUILD_MANIFEST_NAME: target/distrib/${{ join(matrix.targets, '-') }}-dist-manifest.json + permissions: + "attestations": "write" + "contents": "read" + "id-token": "write" + steps: + - name: enable windows longpaths + run: | + git config --global core.longpaths true + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd + with: + persist-credentials: false + submodules: recursive + - name: Install Rust non-interactively if not already installed + if: ${{ matrix.container }} + run: | + if ! command -v cargo > /dev/null 2>&1; then + curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y + echo "$HOME/.cargo/bin" >> $GITHUB_PATH + fi + - name: Install dist + run: ${{ matrix.install_dist.run }} + # Get the dist-manifest + - name: Fetch local artifacts + uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 + with: + pattern: artifacts-* + path: target/distrib/ + merge-multiple: true + - name: Install dependencies + run: | + ${{ matrix.packages_install }} + - name: Build artifacts + run: | + # Actually do builds and make zips and whatnot + dist build ${{ needs.plan.outputs.tag-flag }} --print=linkage --output-format=json ${{ matrix.dist_args }} > dist-manifest.json + echo "dist ran successfully" + - name: Attest + uses: actions/attest-build-provenance@96278af6caaf10aea03fd8d33a09a777ca52d62f + with: + subject-path: "target/distrib/*${{ join(matrix.targets, ', ') }}*" + - id: cargo-dist + name: Post-build + # We force bash here just because github makes it really hard to get values up + # to "real" actions without writing to env-vars, and writing to env-vars has + # inconsistent syntax between shell and powershell. + shell: bash + run: | + # Parse out what we just built and upload it to scratch storage + echo "paths<> "$GITHUB_OUTPUT" + dist print-upload-files-from-manifest --manifest dist-manifest.json >> "$GITHUB_OUTPUT" + echo "EOF" >> "$GITHUB_OUTPUT" + + cp dist-manifest.json "$BUILD_MANIFEST_NAME" + - name: "Upload artifacts" + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f + with: + name: artifacts-build-local-${{ join(matrix.targets, '_') }} + path: | + ${{ steps.cargo-dist.outputs.paths }} + ${{ env.BUILD_MANIFEST_NAME }} + + # Build and package all the platform-agnostic(ish) things + build-global-artifacts: + needs: + - plan + - build-local-artifacts + runs-on: "ubuntu-22.04" + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + BUILD_MANIFEST_NAME: target/distrib/global-dist-manifest.json + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd + with: + persist-credentials: false + submodules: recursive + - name: Install cached dist + uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 + with: + name: cargo-dist-cache + path: ~/.cargo/bin/ + - run: chmod +x ~/.cargo/bin/dist + # Get all the local artifacts for the global tasks to use (for e.g. checksums) + - name: Fetch local artifacts + uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 + with: + pattern: artifacts-* + path: target/distrib/ + merge-multiple: true + - id: cargo-dist + shell: bash + run: | + dist build ${{ needs.plan.outputs.tag-flag }} --output-format=json "--artifacts=global" > dist-manifest.json + echo "dist ran successfully" + + # Parse out what we just built and upload it to scratch storage + echo "paths<> "$GITHUB_OUTPUT" + jq --raw-output ".upload_files[]" dist-manifest.json >> "$GITHUB_OUTPUT" + echo "EOF" >> "$GITHUB_OUTPUT" + + cp dist-manifest.json "$BUILD_MANIFEST_NAME" + - name: "Upload artifacts" + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f + with: + name: artifacts-build-global + path: | + ${{ steps.cargo-dist.outputs.paths }} + ${{ env.BUILD_MANIFEST_NAME }} + # Determines if we should publish/announce + host: + needs: + - plan + - build-local-artifacts + - build-global-artifacts + # Only run if we're "publishing", and only if plan, local and global didn't fail (skipped is fine) + if: ${{ always() && needs.plan.result == 'success' && needs.plan.outputs.publishing == 'true' && (needs.build-global-artifacts.result == 'skipped' || needs.build-global-artifacts.result == 'success') && (needs.build-local-artifacts.result == 'skipped' || needs.build-local-artifacts.result == 'success') }} + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + runs-on: "ubuntu-22.04" + outputs: + val: ${{ steps.host.outputs.manifest }} + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd + with: + persist-credentials: false + submodules: recursive + - name: Install cached dist + uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 + with: + name: cargo-dist-cache + path: ~/.cargo/bin/ + - run: chmod +x ~/.cargo/bin/dist + # Fetch artifacts from scratch-storage + - name: Fetch artifacts + uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 + with: + pattern: artifacts-* + path: target/distrib/ + merge-multiple: true + - id: host + shell: bash + run: | + dist host ${{ needs.plan.outputs.tag-flag }} --steps=upload --steps=release --output-format=json > dist-manifest.json + echo "artifacts uploaded and released successfully" + cat dist-manifest.json + echo "manifest=$(jq -c "." dist-manifest.json)" >> "$GITHUB_OUTPUT" + - name: "Upload dist-manifest.json" + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f + with: + # Overwrite the previous copy + name: artifacts-dist-manifest + path: dist-manifest.json + # Create a GitHub Release while uploading all files to it + - name: "Download GitHub Artifacts" + uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 + with: + pattern: artifacts-* + path: artifacts + merge-multiple: true + - name: Cleanup + run: | + # Remove the granular manifests + rm -f artifacts/*-dist-manifest.json + - name: Create GitHub Release + env: + PRERELEASE_FLAG: "${{ fromJson(steps.host.outputs.manifest).announcement_is_prerelease && '--prerelease' || '' }}" + ANNOUNCEMENT_TITLE: "${{ fromJson(steps.host.outputs.manifest).announcement_title }}" + ANNOUNCEMENT_BODY: "${{ fromJson(steps.host.outputs.manifest).announcement_github_body }}" + RELEASE_COMMIT: "${{ github.sha }}" + run: | + # Write and read notes from a file to avoid quoting breaking things + echo "$ANNOUNCEMENT_BODY" > $RUNNER_TEMP/notes.txt + + gh release create "${{ needs.plan.outputs.tag }}" --target "$RELEASE_COMMIT" $PRERELEASE_FLAG --title "$ANNOUNCEMENT_TITLE" --notes-file "$RUNNER_TEMP/notes.txt" artifacts/* + + announce: + needs: + - plan + - host + # use "always() && ..." to allow us to wait for all publish jobs while + # still allowing individual publish jobs to skip themselves (for prereleases). + # "host" however must run to completion, no skipping allowed! + if: ${{ always() && needs.host.result == 'success' }} + runs-on: "ubuntu-22.04" + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd + with: + persist-credentials: false + submodules: recursive diff --git a/Cargo.lock b/Cargo.lock index 2b2d537..fc913f9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -839,6 +839,15 @@ dependencies = [ "crypto-common 0.2.2", ] +[[package]] +name = "dirs" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" +dependencies = [ + "dirs-sys", +] + [[package]] name = "dirs-next" version = "2.0.0" @@ -849,6 +858,18 @@ dependencies = [ "dirs-sys-next", ] +[[package]] +name = "dirs-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" +dependencies = [ + "libc", + "option-ext", + "redox_users 0.5.2", + "windows-sys 0.61.2", +] + [[package]] name = "dirs-sys-next" version = "0.1.2" @@ -856,7 +877,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4ebda144c4fe02d1f7ea1a7d9641b6fc6b580adcfa024ae48797ecdeb6825b4d" dependencies = [ "libc", - "redox_users", + "redox_users 0.4.6", "winapi", ] @@ -1830,6 +1851,7 @@ dependencies = [ "axum", "base64 0.22.1", "candid", + "dirs", "hex", "imcp2-core", "open", @@ -1839,6 +1861,7 @@ dependencies = [ "serde", "serde_json", "tokio", + "toml_edit 0.23.10+spec-1.0.0", "tracing", "tracing-subscriber", "url", @@ -2307,6 +2330,12 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + [[package]] name = "p256" version = "0.13.2" @@ -2809,6 +2838,17 @@ dependencies = [ "thiserror 1.0.69", ] +[[package]] +name = "redox_users" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" +dependencies = [ + "getrandom 0.2.17", + "libredox", + "thiserror 2.0.18", +] + [[package]] name = "ref-cast" version = "1.0.25" @@ -3870,8 +3910,8 @@ checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" dependencies = [ "serde", "serde_spanned", - "toml_datetime", - "toml_edit", + "toml_datetime 0.6.11", + "toml_edit 0.22.27", ] [[package]] @@ -3883,6 +3923,15 @@ dependencies = [ "serde", ] +[[package]] +name = "toml_datetime" +version = "0.7.5+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347" +dependencies = [ + "serde_core", +] + [[package]] name = "toml_edit" version = "0.22.27" @@ -3892,10 +3941,38 @@ dependencies = [ "indexmap", "serde", "serde_spanned", - "toml_datetime", - "winnow", + "toml_datetime 0.6.11", + "winnow 0.7.15", +] + +[[package]] +name = "toml_edit" +version = "0.23.10+spec-1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84c8b9f757e028cee9fa244aea147aab2a9ec09d5325a9b01e0a49730c2b5269" +dependencies = [ + "indexmap", + "toml_datetime 0.7.5+spec-1.1.0", + "toml_parser", + "toml_writer", + "winnow 0.7.15", ] +[[package]] +name = "toml_parser" +version = "1.1.3+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56" +dependencies = [ + "winnow 1.0.4", +] + +[[package]] +name = "toml_writer" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2" + [[package]] name = "tower" version = "0.5.3" @@ -4610,6 +4687,12 @@ dependencies = [ "memchr", ] +[[package]] +name = "winnow" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" + [[package]] name = "wit-bindgen" version = "0.51.0" diff --git a/Cargo.toml b/Cargo.toml index 3b850f7..384ce96 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -36,6 +36,11 @@ exclude = [ [workspace] members = [".", "crates/imcp2-core", "crates/imcp2-local"] +# The hosted server releases to crates.io (publish-crate.yml), never as dist +# binaries — dist releases only imcp2-local (see dist-workspace.toml). +[package.metadata.dist] +dist = false + # Single source of truth for the version pins the crates share, so the agent, # codec, and candid lines can never drift between imcp2-core, imcp2, and # imcp2-local. @@ -126,3 +131,8 @@ e2e = ["dep:pocket-ic"] # tower::ServiceExt::oneshot and read bodies via http-body-util. tower = { version = "0.5", features = ["util"] } http-body-util = "0.1" + +# The profile that 'dist' will build with +[profile.dist] +inherits = "release" +lto = "thin" diff --git a/crates/imcp2-local/Cargo.toml b/crates/imcp2-local/Cargo.toml index d64e33e..d3924ca 100644 --- a/crates/imcp2-local/Cargo.toml +++ b/crates/imcp2-local/Cargo.toml @@ -4,6 +4,7 @@ version = "0.2.0" edition = "2021" description = "Local MCP server for the Internet Computer: the imcp2 tool surface over stdio, with a built-in Internet Identity browser login." license = "Apache-2.0" +readme = "README.md" repository = "https://github.com/dfinity/imcp2" # Distributed as signed release binaries (the design's end-user setup and # signing components), not through crates.io — flip deliberately if that ever @@ -11,6 +12,11 @@ repository = "https://github.com/dfinity/imcp2" # imcp2-core + imcp2. publish = false +# The one dist-released app in this workspace: release binaries + installers +# are built by dist (see dist-workspace.toml) on `imcp2-local-v*` tags. +[package.metadata.dist] +dist = true + [dependencies] # The whole server is composed from imcp2-core: the IcTools surface (served # over stdio here), the II session engine, and the connect-handshake @@ -34,6 +40,10 @@ tracing = { workspace = true } tracing-subscriber = { workspace = true } uuid = { workspace = true } url = { workspace = true } +# `setup` only: locating each client's config home (dirs) and editing Codex's +# config.toml without destroying the user's comments/formatting (toml_edit). +dirs = "6" +toml_edit = "0.23" # Only for the PocketIC-backed end-to-end login test (src/e2e_local_login.rs), # behind the `e2e` feature so the default build never compiles it — the same # gating as the hosted crate's harness. (An optional dependency, not a @@ -51,12 +61,12 @@ e2e = ["dep:pocket-ic"] # The login-flow tests play the browser: fetch the callback page, the # allow-list, and POST redeems against the loopback listener. reqwest = { workspace = true, features = ["json"] } -# The wrapper tests run a REAL MCP round-trip: the server served over an -# in-process duplex pipe (the same shape as stdio), driven by rmcp's client. -rmcp = { workspace = true, features = ["client"] } -tokio = { workspace = true, features = ["io-util"] } # The e2e login test speaks the II canister's candid interface directly # (playing II's frontend) and (de)codes the delegation-chain wire fields. candid = { workspace = true } hex = { workspace = true } base64 = { workspace = true } +# The wrapper tests run a REAL MCP round-trip: the server served over an +# in-process duplex pipe (the same shape as stdio), driven by rmcp's client. +rmcp = { workspace = true, features = ["client"] } +tokio = { workspace = true, features = ["io-util"] } diff --git a/crates/imcp2-local/README.md b/crates/imcp2-local/README.md new file mode 100644 index 0000000..c745c28 --- /dev/null +++ b/crates/imcp2-local/README.md @@ -0,0 +1,135 @@ +# imcp2-local + +The Internet Computer MCP server for **local use**: a single-user binary your +AI tool (Claude Desktop, Claude Code, Codex, Cursor, Antigravity, the +Perplexity macOS app, …) spawns on your machine and talks to over **stdio**. +It serves the same tools as the hosted server at +[mcp.internetcomputer.org](https://mcp.internetcomputer.org) — canister reads +and writes in textual Candid, app discovery, OQL, canister management — +against the same **IC mainnet** and the same **production Internet +Identity**. What it drops is the hosted server's entire OAuth 2.1 layer: a +single-user process reached over a pipe needs no bearer tokens, so your II +login never passes through a third-party server. + +Cloud-only AI surfaces (claude.ai web/mobile, Perplexity web, Codex Cloud) +cannot spawn local processes; they keep using the hosted server. + +## Install + +Release binaries (macOS arm64/x64, Linux x64/arm64, Windows x64) ship from +this repository's GitHub releases with shell/PowerShell installers, built by +`dist` from `imcp2-local-v*` tags. Until the first release is cut, build from +source: + +```sh +cargo build --release -p imcp2-local +# binary at target/release/imcp2-local +``` + +## Register it with your AI tools + +One command detects the clients installed on your machine and writes each +one's own MCP registration (with a one-time backup next to any file it +modifies): + +```sh +imcp2-local setup # register everywhere it can +imcp2-local setup --remove # remove those imcp2 registrations +imcp2-local setup --print # only show the per-client steps +``` + +Per client, that amounts to: + +| Client | Registration | +|---|---| +| Claude Desktop | `claude_desktop_config.json` → `mcpServers.imcp2` | +| Claude Code | `claude mcp add --scope user --transport stdio imcp2 -- ` | +| Codex | `codex mcp add imcp2 -- ` when a recent `codex` is on PATH; else `$CODEX_HOME/config.toml` (default `~/.codex`) → `[mcp_servers.imcp2]` | +| Cursor | `~/.cursor/mcp.json` → `mcpServers.imcp2` | +| Antigravity | `~/.gemini/config/mcp_config.json` → `mcpServers.imcp2` | +| Perplexity (macOS) | Settings → Connectors → Add Connector → Advanced (the app's UI; `setup` prints the JSON to paste) | + +Restart the client afterwards so it picks the server up. + +## Signing in + +On the first tool call that needs your identity, the agent calls the +`authenticate` tool: it answers with an [id.ai](https://id.ai) sign-in link +(and best-effort opens your browser), without blocking. Sign in with your +Internet Identity, pick the access level and session length on II's consent +screen, and the tab says it can be closed — the session is live. `auth_status` +(or simply retrying the original tool) confirms it. + +Sessions are **in memory only**: you sign in again after a restart, when the +grant expires, or if you revoke it at id.ai (Manage access). Signing in again +is the same one-step browser round-trip — no client restart needed. + +## Upgrading + +Client registrations point at a stable installed path, so an upgrade is a +binary swap at that path — nothing to re-register, and no state to migrate +(the binary keeps nothing on disk). Installs made by the release installers +include the standalone updater: run `imcp2-local-update` to upgrade in place. + +## Configuration + +The defaults are production: IC mainnet (`https://icp-api.io`) and production +Internet Identity (`https://id.ai`). Environment overrides, mainly for tests: + +| Variable | Effect | +|---|---| +| `IMCP2_IC_URL` | IC API endpoint. | +| `IMCP2_FETCH_ROOT_KEY` | Truthy: trust the endpoint's fetched root key — honoured **only** when `IMCP2_IC_URL` targets loopback (a local replica / PocketIC); startup refuses otherwise. | +| `II_URL_PROD` / `II_CANISTER_ID_PROD` | Override the Internet Identity instance (e.g. beta II, or an II canister in PocketIC). | +| `IMCP2_MANAGEMENT_ORIGIN` | Derivation origin of the canister-management identity. Defaults to the hosted server's origin so the same anchor keeps the same controller principal locally and hosted. | +| `IMCP2_NO_OPEN` | Truthy: never auto-open the browser on sign-in (the link is still returned in-band). | + +All diagnostics go to stderr (`RUST_LOG` filters them); stdout is the MCP +JSON-RPC channel. + +## Security model — treat it like a wallet + +Whoever drives this binary **acts as your real Internet Identity accounts on +mainnet**, up to the access level and lifetime you chose on II's consent +screen. Concretely: + +- The AI client that spawns the binary can call every tool as you — including, + with full access, transfers and canister management. Prefer clients that ask + before tool calls, and prefer **read-only** grants unless you need writes. +- Anything that can edit your client's MCP registration or replace the binary + on disk can substitute a malicious server. Install from this repository's + releases only, and verify downloads (below). +- The session key lives in the binary's memory and is never written to disk; + the sign-in listener exists only during a login, on `127.0.0.1`, and serves + only the login handshake. Revoke a session any time at id.ai. + +## Verifying a download + +Every platform archive carries a keyless provenance attestation proving it +was built by this repository's release workflow: + +```sh +# (Windows archives are .zip — substitute the extension.) +gh attestation verify imcp2-local-.tar.xz -R dfinity/imcp2 \ + --signer-workflow dfinity/imcp2/.github/workflows/imcp2-local-release.yml +``` + +plus a SHA256 checksum alongside each archive. The convenience installers +(`.sh`/`.ps1`) and the checksum files themselves are assembled by the release +pipeline without their own attestations — verify the archive, or read an +installer before running it. macOS (Developer ID + +notarization) and Windows (Authenticode) code signing for the double-click +paths hooks into the same release pipeline once the organization's signing +credentials are in place — until then, install via the shell/PowerShell +installers, which are not subject to those OS gates. + +## Releasing (maintainers) + +Binary releases are cut by pushing an `imcp2-local-vX.Y.Z` tag (the version +must match this crate's `Cargo.toml`). Those tags must stay covered by the same +protected-tag ruleset as `v*`: the generated workflow publishes whatever commit +the tag names, so who can push the tag is the control that decides what ships; `.github/workflows/imcp2-local-release.yml` +(generated by `dist` from `dist-workspace.toml`) builds the five platform +archives, the installers, the updater companions, checksums, and the GitHub +attestations. Plain `vX.Y.Z` tags remain the crates.io publish trigger for +`imcp2`/`imcp2-core` and never ship binaries. diff --git a/crates/imcp2-local/src/login/routes.rs b/crates/imcp2-local/src/login/routes.rs index f1ce9f1..88381df 100644 --- a/crates/imcp2-local/src/login/routes.rs +++ b/crates/imcp2-local/src/login/routes.rs @@ -240,7 +240,10 @@ async fn redeem(State(ctx): State, Json(body): Json) -> Re Ok(outcome) => { let principal = driver.inner.identities.session_principal(&body.state).await; let mut state = driver.inner.state.lock().await; - driver.inner.slot.set(body.state.clone(), outcome.expiration_ns); + driver + .inner + .slot + .set(body.state.clone(), outcome.expiration_ns); state.grant = Some(Grant { session_id: body.state.clone(), principal, diff --git a/crates/imcp2-local/src/main.rs b/crates/imcp2-local/src/main.rs index b4950ae..d16de14 100644 --- a/crates/imcp2-local/src/main.rs +++ b/crates/imcp2-local/src/main.rs @@ -32,6 +32,7 @@ mod login; mod server; +mod setup; // The PocketIC-backed end-to-end login test (the design's local-replica test // configuration). Compiled only for `cargo test --features e2e`. @@ -43,6 +44,42 @@ use login::SessionSlot; use rmcp::ServiceExt; use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt}; +const HELP: &str = "\ +imcp2-local — Internet Computer MCP server for local use (stdio) + +Run with no arguments to serve MCP over stdio (this is what an AI client's +registration invokes). Subcommands: + + setup Register this binary with the AI tools installed on this + machine (Claude Desktop, Claude Code, Codex, Cursor, + Antigravity; prints Perplexity's UI steps). + setup --remove Undo those registrations. + setup --print Only show the per-client steps; write nothing. + --version, -V Print the version. + --help, -h This text. + +Environment (serving): IMCP2_IC_URL, IMCP2_FETCH_ROOT_KEY (loopback only), +II_URL_PROD / II_CANISTER_ID_PROD, IMCP2_MANAGEMENT_ORIGIN, IMCP2_NO_OPEN — +see the crate documentation for details. +"; + +fn main() -> anyhow::Result<()> { + let args: Vec = std::env::args().skip(1).collect(); + match args.first().map(String::as_str) { + None => serve(), + Some("setup") => setup::run(&args[1..]), + Some("--version" | "-V") => { + println!("imcp2-local {}", env!("CARGO_PKG_VERSION")); + Ok(()) + } + Some("--help" | "-h" | "help") => { + print!("{HELP}"); + Ok(()) + } + Some(other) => anyhow::bail!("unknown argument {other:?}\n\n{HELP}"), + } +} + fn truthy(var: &str) -> bool { std::env::var(var) .map(|v| { @@ -79,8 +116,10 @@ fn is_loopback_url(url: &str) -> bool { } } +/// Serve MCP over stdio — the no-argument path an AI client's registration +/// invokes. #[tokio::main] -async fn main() -> anyhow::Result<()> { +async fn serve() -> anyhow::Result<()> { // stderr only: stdout carries the MCP JSON-RPC stream, and every client // routes a stdio server's stderr to a log file/panel, never the chat. tracing_subscriber::registry() diff --git a/crates/imcp2-local/src/setup.rs b/crates/imcp2-local/src/setup.rs new file mode 100644 index 0000000..35a3f51 --- /dev/null +++ b/crates/imcp2-local/src/setup.rs @@ -0,0 +1,1024 @@ +//! `imcp2-local setup` — register this binary with the AI tools installed on +//! this machine, so the user never opens a JSON/TOML file (the design's +//! end-user-setup bar). It detects each supported client, says what it will +//! write, and writes that client's own MCP registration format: +//! +//! * Claude Desktop — `claude_desktop_config.json` → `mcpServers.imcp2` +//! * Cursor — `~/.cursor/mcp.json` → `mcpServers.imcp2` +//! * Antigravity — `~/.gemini/config/mcp_config.json` → `mcpServers.imcp2` +//! * Codex — via its own CLI (`codex mcp add`) when a `codex` recent +//! enough to have that subcommand is on `PATH`; otherwise +//! `$CODEX_HOME/config.toml` (default `~/.codex`) → `[mcp_servers.imcp2]` +//! is edited in place, covering IDE-only and older installs +//! * Claude Code — via its own CLI (`claude mcp add --scope user …`), which +//! owns that config's format +//! * Perplexity (macOS) — has no writable config file; its UI-driven steps +//! are printed instead +//! +//! `setup --remove` deletes those `imcp2` registrations — by name, whatever +//! they hold by then, with the one-time backup keeping the pre-imcp2 state; +//! `setup --print` only +//! shows the per-client instructions (nothing is written). The first time an +//! existing config file is modified, a one-time backup is kept next to it as +//! `.imcp2-bak`. +//! +//! This is a plain CLI subcommand: it runs and exits before the MCP server +//! starts, so printing to stdout here is fine (stdout is only reserved for +//! JSON-RPC while *serving*). + +use std::path::{Path, PathBuf}; + +use anyhow::Context; + +/// The server name registered in every client, matching the design's +/// registration snippets. +const SERVER_NAME: &str = "imcp2"; + +pub fn run(args: &[String]) -> anyhow::Result<()> { + let mut mode = Mode::Apply; + for a in args { + let requested = match a.as_str() { + "--remove" => Mode::Remove, + "--print" => Mode::Print, + other => anyhow::bail!( + "unknown setup flag {other:?} — usage: imcp2-local setup [--remove | --print]" + ), + }; + // The usage marks these mutually exclusive; last-flag-wins would let + // `--print --remove` write despite `--print` promising it won't. + anyhow::ensure!( + mode == Mode::Apply || mode == requested, + "--remove and --print are mutually exclusive" + ); + mode = requested; + } + let env = Env::from_system()?; + let (report, failed) = run_in(&env, mode); + println!("{report}"); + if failed > 0 { + anyhow::bail!("{failed} client registration(s) failed — see the report above"); + } + Ok(()) +} + +#[derive(Clone, Copy, PartialEq)] +enum Mode { + /// Detect clients and write their registrations. + Apply, + /// Detect clients and remove the registrations `Apply` wrote. + Remove, + /// Only print what would be done, plus manual steps for every client. + Print, +} + +/// Everything `setup` reads from the machine, injected so the tests can point +/// it at a scratch directory instead of the real home. +struct Env { + /// The user's home directory (`~`). + home: PathBuf, + /// The OS config base: `~/Library/Application Support` on macOS, + /// `%APPDATA%` on Windows, `~/.config` elsewhere — where Claude Desktop + /// keeps its config. + config_dir: PathBuf, + /// The absolute path registered as the server `command`. + exe: PathBuf, + /// `claude` (Claude Code's CLI) on `PATH`, if present. + claude_cli: Option, + /// `codex` (Codex's CLI) on `PATH`, if present. + codex_cli: Option, + /// Codex's config directory: `$CODEX_HOME`, defaulting to `~/.codex`. + codex_home: PathBuf, + /// Where macOS applications live, for detecting the Perplexity app. + applications_dir: PathBuf, +} + +impl Env { + fn from_system() -> anyhow::Result { + let exe = std::env::current_exe().context("resolve this binary's path")?; + // Canonical, so client configs point at the real file even when setup + // ran through a symlink; fall back to the raw path if e.g. a network + // mount refuses canonicalization. + let exe = exe.canonicalize().unwrap_or(exe); + // Every registration format below carries the path as UTF-8 text; a + // lossy conversion would register a command that does not exist. + anyhow::ensure!( + exe.to_str().is_some(), + "this binary's path ({}) is not valid UTF-8 — move the binary to a \ + UTF-8 path and rerun setup", + exe.display() + ); + let home = dirs::home_dir().context("no home directory")?; + Ok(Self { + config_dir: dirs::config_dir().context("no OS config directory")?, + exe, + claude_cli: find_on_path("claude"), + codex_cli: find_on_path("codex"), + codex_home: std::env::var_os("CODEX_HOME") + .map(PathBuf::from) + .unwrap_or_else(|| home.join(".codex")), + home, + applications_dir: PathBuf::from("/Applications"), + }) + } +} + +/// A minimal `which`: the first existing file named `name` (with the usual +/// Windows extensions) in `PATH`. +fn find_on_path(name: &str) -> Option { + let path = std::env::var_os("PATH")?; + for dir in std::env::split_paths(&path) { + let candidate = dir.join(name); + if candidate.is_file() { + return Some(candidate); + } + if cfg!(windows) { + for ext in ["exe", "cmd", "bat"] { + let candidate = dir.join(format!("{name}.{ext}")); + if candidate.is_file() { + return Some(candidate); + } + } + } + } + None +} + +/// Run one mode over every supported client and render the report the user +/// reads, plus how many clients FAILED (detected and attempted, but not +/// done), so `run` can exit nonzero where a script would otherwise read +/// partial failure as success. Pure with respect to `Env`, so the tests +/// drive it end to end against a scratch home. +fn run_in(env: &Env, mode: Mode) -> (String, usize) { + let mut lines: Vec = Vec::new(); + let mut failed = 0usize; + let verb = match mode { + Mode::Apply => "Registering", + Mode::Remove => "Removing", + Mode::Print => "Setup steps for", + }; + lines.push(format!( + "{verb} the imcp2 local MCP server (binary: {}).\n", + env.exe.display() + )); + + for client in clients(env) { + let line = match mode { + Mode::Print => format!( + "• {}\n {}", + client.name, + client.manual.replace('\n', "\n ") + ), + Mode::Apply | Mode::Remove => match &client.target { + Target::NotDetected(reason) => { + format!("• {}: not detected ({reason}) — skipped.", client.name) + } + Target::Json(file) => { + let result = if mode == Mode::Apply { + upsert_json_server(file, &env.exe) + } else { + remove_json_server(file) + }; + if result.is_err() { + failed += 1; + } + render(client.name, file, result) + } + Target::Codex { config, cli } => { + // The vendor CLI is preferred when it is recent enough to + // have `codex mcp` (probed here); otherwise — IDE-only + // installs with no binary on `PATH`, or older CLIs — the + // documented config.toml is edited directly. + let capable = cli.as_ref().filter(|c| { + std::process::Command::new(c) + .args(["mcp", "--help"]) + .output() + .is_ok_and(|out| out.status.success()) + }); + match capable { + Some(c) => { + let mut cmd = std::process::Command::new(c); + if mode == Mode::Apply { + cmd.args(["mcp", "add", SERVER_NAME, "--"]); + cmd.arg(&env.exe); + } else { + cmd.args(["mcp", "remove", SERVER_NAME]); + } + let fallback = match mode { + Mode::Remove => format!("codex mcp remove {SERVER_NAME}"), + _ => client.manual.lines().next().unwrap_or_default().to_string(), + }; + vendor_cli_line( + client.name, + "codex mcp", + cmd, + &fallback, + mode, + &mut failed, + ) + } + None => { + let result = if mode == Mode::Apply { + upsert_codex_server(config, &env.exe) + } else { + remove_codex_server(config) + }; + if result.is_err() { + failed += 1; + } + render(client.name, config, result) + } + } + } + Target::ClaudeCli(cli) => { + let mut cmd = std::process::Command::new(cli); + if mode == Mode::Apply { + cmd.args([ + "mcp", + "add", + "--scope", + "user", + "--transport", + "stdio", + SERVER_NAME, + "--", + ]); + cmd.arg(&env.exe); + } else { + cmd.args(["mcp", "remove", "--scope", "user", SERVER_NAME]); + } + // The fallback one-liner must match the mode: telling a + // removing user to re-add the server would invert the ask. + let fallback = match mode { + Mode::Remove => format!("claude mcp remove --scope user {SERVER_NAME}"), + _ => client.manual.lines().next().unwrap_or_default().to_string(), + }; + vendor_cli_line(client.name, "claude mcp", cmd, &fallback, mode, &mut failed) + } + Target::Manual => match mode { + Mode::Remove => format!( + "• {}: has no config file this tool can write — remove the \ + {SERVER_NAME} connector in the app (Settings → Connectors).", + client.name + ), + _ => format!( + "• {}: has no config file this tool can write — do it in the app:\n {}", + client.name, + client.manual.replace('\n', "\n ") + ), + }, + }, + }; + lines.push(line); + } + + lines.push(String::new()); + lines.push(match mode { + Mode::Apply => "Restart each client to pick the server up. On the first tool call that \ + needs your identity, approve the `authenticate` tool and finish the \ + sign-in in your browser." + .to_string(), + Mode::Remove => "Restart each client to drop the server.".to_string(), + Mode::Print => { + "Run `imcp2-local setup` to apply the writable ones automatically.".to_string() + } + }); + (lines.join("\n"), failed) +} + +/// Where (and whether) one client's registration lives on this machine. +enum Target { + /// A `mcpServers` JSON file to edit. + Json(PathBuf), + /// Codex: its CLI when recent enough (`codex mcp`), else its + /// `config.toml` (`[mcp_servers.]`). + Codex { + config: PathBuf, + cli: Option, + }, + /// Claude Code: registered through its own CLI. + ClaudeCli(PathBuf), + /// Detected, but only registrable through the app's UI (Perplexity). + Manual, + /// Not installed here (the reason names what was looked for). + NotDetected(String), +} + +struct Client { + name: &'static str, + target: Target, + /// The manual steps / one-liner for `--print` and failure fallbacks. + manual: String, +} + +/// The supported clients, in the design's component-9 order, resolved against +/// this machine. +fn clients(env: &Env) -> Vec { + // The pasted snippets embed the path as a *literal* of each target + // format, so it gets that format's own escaping (Windows `\` in + // JSON/TOML, spaces in the shell one-liner) — a raw `display()` would + // hand the user an invalid file. + let exe_str = env.exe.display().to_string(); + let exe_json = serde_json::Value::from(exe_str.as_str()).to_string(); + let exe_toml = toml_edit::Value::from(exe_str.as_str()).to_string(); + // Always single-quote: literal in POSIX shells and PowerShell alike, so + // `$`, backticks, and spaces in the path never expand or split. (Double + // quotes would interpolate `$USER` in both.) + let exe_sh = if cfg!(windows) { + format!("'{}'", exe_str.replace('\'', "''")) + } else { + format!("'{}'", exe_str.replace('\'', r"'\''")) + }; + let json_snippet = + format!("{{ \"mcpServers\": {{ \"{SERVER_NAME}\": {{ \"command\": {exe_json} }} }} }}"); + + let detect_dir = |dir: PathBuf, target_file: PathBuf, label: &str| { + if dir.is_dir() { + Target::Json(target_file) + } else { + Target::NotDetected(format!("{label} missing")) + } + }; + + let claude_dir = env.config_dir.join("Claude"); + let cursor_dir = env.home.join(".cursor"); + let gemini_dir = env.home.join(".gemini"); + + vec![ + Client { + name: "Claude Desktop", + target: detect_dir( + claude_dir.clone(), + claude_dir.join("claude_desktop_config.json"), + "its config directory is", + ), + manual: format!( + "Merge into claude_desktop_config.json (Settings → Developer → Edit Config):\n{json_snippet}\n(Or install the imcp2 .mcpb bundle by double-clicking it, once released.)" + ), + }, + Client { + name: "Claude Code", + target: match &env.claude_cli { + Some(cli) => Target::ClaudeCli(cli.clone()), + None => Target::NotDetected("`claude` is not on PATH".into()), + }, + manual: format!( + "claude mcp add --scope user --transport stdio {SERVER_NAME} -- {exe_sh}" + ), + }, + Client { + name: "Codex", + target: if env.codex_home.is_dir() || env.codex_cli.is_some() { + Target::Codex { + config: env.codex_home.join("config.toml"), + cli: env.codex_cli.clone(), + } + } else { + Target::NotDetected( + "neither $CODEX_HOME (default ~/.codex) nor `codex` on PATH".into(), + ) + }, + manual: format!( + "codex mcp add {SERVER_NAME} -- {exe_sh}\nOr add to $CODEX_HOME/config.toml (default ~/.codex/config.toml):\n[mcp_servers.{SERVER_NAME}]\ncommand = {exe_toml}" + ), + }, + Client { + name: "Cursor", + target: detect_dir( + cursor_dir.clone(), + cursor_dir.join("mcp.json"), + "~/.cursor is", + ), + manual: format!("Merge into ~/.cursor/mcp.json:\n{json_snippet}"), + }, + Client { + name: "Antigravity", + target: detect_dir( + gemini_dir.clone(), + gemini_dir.join("config").join("mcp_config.json"), + "~/.gemini is", + ), + manual: format!("Merge into ~/.gemini/config/mcp_config.json:\n{json_snippet}"), + }, + Client { + name: "Perplexity (macOS)", + target: if env.applications_dir.join("Perplexity.app").exists() { + Target::Manual + } else { + Target::NotDetected("Perplexity.app is not in /Applications".into()) + }, + manual: format!( + "Settings → Connectors → Add Connector → Advanced, then paste:\n{{ \"command\": {exe_json}, \"args\": [], \"env\": {{}} }}\n(Needs Perplexity's local-MCP helper; the app prompts to install it once.)" + ), + }, + ] +} + +/// Run a vendor CLI registration/removal (`claude mcp …` / `codex mcp …`) +/// and render the report line, with the shared refusal contract: a removal +/// refused because the registration is recognizably absent is benign; every +/// other refusal or spawn failure counts toward the nonzero exit. +fn vendor_cli_line( + name: &str, + via: &str, + mut cmd: std::process::Command, + fallback: &str, + mode: Mode, + failed: &mut usize, +) -> String { + match cmd.output() { + Ok(out) if out.status.success() => format!("• {name}: done (via `{via}`)."), + Ok(out) => { + let msg = format!( + "{}{}", + String::from_utf8_lossy(&out.stdout), + String::from_utf8_lossy(&out.stderr) + ) + .to_lowercase(); + let absent = mode == Mode::Remove + && (msg.contains("not found") || msg.contains("no mcp server")); + if absent { + return format!("• {name}: nothing to remove (per `{via}`)."); + } + *failed += 1; + format!( + "• {name}: `{via}` refused ({}). Run it yourself:\n {fallback}", + String::from_utf8_lossy(&out.stderr).trim(), + ) + } + Err(e) => { + *failed += 1; + format!("• {name}: could not run `{via}` ({e}). Run it yourself:\n {fallback}") + } + } +} + +fn render(name: &str, file: &Path, result: anyhow::Result) -> String { + match result { + Ok(summary) => format!("• {name}: {summary} ({})", file.display()), + Err(e) => format!("• {name}: FAILED — {e:#} ({})", file.display()), + } +} + +/// Keep a one-time backup next to a config file about to be modified. Only +/// the FIRST setup run makes one, so the backup always holds the pre-imcp2 +/// state rather than being rotated away by a later run. +fn backup_once(path: &Path) -> anyhow::Result> { + if !path.exists() { + return Ok(None); + } + let backup = path.with_extension(match path.extension().and_then(|e| e.to_str()) { + Some(ext) => format!("{ext}.imcp2-bak"), + None => "imcp2-bak".to_string(), + }); + if backup.exists() { + return Ok(Some(backup)); + } + // Copy via a sibling temp + rename: a partial copy (full disk, crash) + // must never pass as the valid one-time backup on later runs. + let tmp = backup.with_extension("imcp2-bak.tmp"); + std::fs::copy(path, &tmp).with_context(|| format!("back up {}", path.display()))?; + if let Err(e) = std::fs::rename(&tmp, &backup) { + let _ = std::fs::remove_file(&tmp); + return Err(e).with_context(|| format!("finalize backup {}", backup.display())); + } + Ok(Some(backup)) +} + +/// Write a config atomically: sibling temp file, then rename over `path` — +/// a crash or full disk never leaves the client's live config truncated. +fn persist(path: &Path, contents: &str) -> anyhow::Result<()> { + // A symlinked config (dotfiles managers) must keep being a symlink: write + // through to the TARGET, so the rename never replaces the link itself + // with a regular file. A dangling link cannot be written through, so it + // is refused rather than silently replaced; a plain not-yet-existing + // path stays as given. + let path = &match path.canonicalize() { + Ok(resolved) => resolved, + Err(_) if path.is_symlink() => anyhow::bail!( + "{} is a symlink whose target cannot be resolved — fix the link, then rerun", + path.display() + ), + Err(_) => path.to_path_buf(), + }; + let tmp = path.with_extension("imcp2.tmp"); + std::fs::write(&tmp, contents).with_context(|| format!("write {}", tmp.display()))?; + // The rename must not swap the user's permissions for the umask default: + // a 0600 config (API keys live in MCP `env` entries) stays 0600. A fresh + // file keeps the default. Failing to carry them over fails the write. + if let Ok(meta) = std::fs::metadata(path) { + if let Err(e) = std::fs::set_permissions(&tmp, meta.permissions()) { + let _ = std::fs::remove_file(&tmp); + return Err(e).with_context(|| format!("carry permissions onto {}", tmp.display())); + } + } + if let Err(e) = std::fs::rename(&tmp, path) { + let _ = std::fs::remove_file(&tmp); + return Err(e).with_context(|| format!("replace {}", path.display())); + } + Ok(()) +} + +// ---- mcpServers JSON files (Claude Desktop, Cursor, Antigravity) ----------- + +fn upsert_json_server(path: &Path, exe: &Path) -> anyhow::Result { + let mut root: serde_json::Value = if path.exists() { + let text = + std::fs::read_to_string(path).with_context(|| format!("read {}", path.display()))?; + if text.trim().is_empty() { + serde_json::json!({}) + } else { + serde_json::from_str(&text).with_context(|| { + format!( + "{} is not valid JSON — fix or remove it, then rerun", + path.display() + ) + })? + } + } else { + serde_json::json!({}) + }; + let obj = root + .as_object_mut() + .with_context(|| format!("{} top level is not a JSON object", path.display()))?; + let servers = obj + .entry("mcpServers") + .or_insert_with(|| serde_json::json!({})) + .as_object_mut() + .with_context(|| format!("`mcpServers` in {} is not an object", path.display()))?; + // Merge, don't replace: `command` is the only key setup owns, so a + // user's additions to the entry (say `env` for a local replica) survive + // re-runs and upgrades. A non-object under the name is replaced. + match servers.get_mut(SERVER_NAME) { + Some(serde_json::Value::Object(entry)) => { + entry.insert( + "command".to_string(), + serde_json::Value::from(exe.to_string_lossy().as_ref()), + ); + } + _ => { + servers.insert( + SERVER_NAME.to_string(), + serde_json::json!({ "command": exe.to_string_lossy() }), + ); + } + } + let backup = backup_once(path)?; + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent).with_context(|| format!("create {}", parent.display()))?; + } + persist(path, &format!("{:#}\n", root))?; + Ok(match backup { + Some(b) => format!("registered; backup at {}", b.display()), + None => "registered".to_string(), + }) +} + +fn remove_json_server(path: &Path) -> anyhow::Result { + if !path.exists() { + return Ok("nothing to remove".to_string()); + } + let text = std::fs::read_to_string(path).with_context(|| format!("read {}", path.display()))?; + let mut root: serde_json::Value = serde_json::from_str(&text) + .with_context(|| format!("{} is not valid JSON", path.display()))?; + let removed = root + .get_mut("mcpServers") + .and_then(|s| s.as_object_mut()) + .and_then(|s| s.remove(SERVER_NAME)) + .is_some(); + if !removed { + return Ok("nothing to remove".to_string()); + } + // A removal is a modification too: the first one still earns the one-time + // pre-imcp2 backup (e.g. `--remove` before any apply on this machine). + let backup = backup_once(path)?; + persist(path, &format!("{:#}\n", root))?; + Ok(match backup { + Some(b) => format!("removed; backup at {}", b.display()), + None => "removed".to_string(), + }) +} + +// ---- Codex config.toml ------------------------------------------------------ +// +// The FALLBACK half of the Codex target: the vendor CLI (`codex mcp add` / +// `codex mcp remove`) is preferred whenever a `codex` recent enough to have +// the `mcp` subcommand is on `PATH` (probed per run, in `run_in`), and these +// direct, atomic edits of the documented config.toml cover the rest — +// IDE-extension installs that never expose the binary, and older CLIs. +// `$CODEX_HOME` is respected via `Env::codex_home`. + +fn upsert_codex_server(path: &Path, exe: &Path) -> anyhow::Result { + let mut doc: toml_edit::DocumentMut = if path.exists() { + std::fs::read_to_string(path) + .with_context(|| format!("read {}", path.display()))? + .parse() + .with_context(|| format!("{} is not valid TOML", path.display()))? + } else { + toml_edit::DocumentMut::new() + }; + // `[mcp_servers]` as an implicit parent so an absent table doesn't render + // an empty `[mcp_servers]` header; the entry itself is `[mcp_servers.imcp2]`. + let servers = doc + .entry("mcp_servers") + .or_insert(toml_edit::Item::Table({ + let mut t = toml_edit::Table::new(); + t.set_implicit(true); + t + })) + .as_table_mut() + .with_context(|| format!("`mcp_servers` in {} is not a table", path.display()))?; + // Merge, don't replace — same contract as the JSON path: only `command` + // is owned here, user-added keys survive, whether the entry is a regular + // `[mcp_servers.imcp2]` table or an inline `imcp2 = { … }` one. + let exe_text = exe.to_string_lossy(); + let merged = match servers.get_mut(SERVER_NAME) { + Some(item) => { + if let Some(entry) = item.as_table_mut() { + entry.insert("command", toml_edit::value(exe_text.as_ref())); + true + } else if let Some(entry) = item.as_inline_table_mut() { + entry.insert("command", toml_edit::Value::from(exe_text.as_ref())); + true + } else { + false + } + } + None => false, + }; + if !merged { + let mut entry = toml_edit::Table::new(); + entry.insert("command", toml_edit::value(exe_text.as_ref())); + servers.insert(SERVER_NAME, toml_edit::Item::Table(entry)); + } + let backup = backup_once(path)?; + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent).with_context(|| format!("create {}", parent.display()))?; + } + persist(path, &doc.to_string())?; + Ok(match backup { + Some(b) => format!("registered; backup at {}", b.display()), + None => "registered".to_string(), + }) +} + +fn remove_codex_server(path: &Path) -> anyhow::Result { + if !path.exists() { + return Ok("nothing to remove".to_string()); + } + let mut doc: toml_edit::DocumentMut = std::fs::read_to_string(path) + .with_context(|| format!("read {}", path.display()))? + .parse() + .with_context(|| format!("{} is not valid TOML", path.display()))?; + let removed = doc + .get_mut("mcp_servers") + .and_then(|s| s.as_table_mut()) + .map(|s| s.remove(SERVER_NAME).is_some()) + .unwrap_or(false); + if !removed { + return Ok("nothing to remove".to_string()); + } + // Drop a now-empty `[mcp_servers]` table rather than leaving a bare header. + if doc + .get("mcp_servers") + .and_then(|s| s.as_table()) + .is_some_and(|t| t.is_empty()) + { + doc.remove("mcp_servers"); + } + // Same one-time backup contract as the JSON removal above. + let backup = backup_once(path)?; + persist(path, &doc.to_string())?; + Ok(match backup { + Some(b) => format!("removed; backup at {}", b.display()), + None => "removed".to_string(), + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// A scratch machine: a temp home with the given client dirs pre-created, + /// no `claude` CLI, no /Applications. + fn scratch(dirs_present: &[&str]) -> (tempdir::Guard, Env) { + let root = tempdir::fresh(); + for d in dirs_present { + std::fs::create_dir_all(root.path.join(d)).unwrap(); + } + let env = Env { + home: root.path.clone(), + config_dir: root.path.join("config"), + exe: PathBuf::from("/opt/imcp2/imcp2-local"), + claude_cli: None, + codex_cli: None, + codex_home: root.path.join(".codex"), + applications_dir: root.path.join("Applications"), + }; + (root, env) + } + + /// Tiny self-cleaning temp dir (no external crate): unique per test via + /// name + pid + a counter, removed on drop even across panics. + mod tempdir { + use std::path::PathBuf; + use std::sync::atomic::{AtomicU64, Ordering}; + + static N: AtomicU64 = AtomicU64::new(0); + + pub struct Guard { + pub path: PathBuf, + } + + impl Drop for Guard { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.path); + } + } + + pub fn fresh() -> Guard { + let path = std::env::temp_dir().join(format!( + "imcp2-setup-test-{}-{}", + std::process::id(), + N.fetch_add(1, Ordering::Relaxed) + )); + std::fs::create_dir_all(&path).unwrap(); + Guard { path } + } + } + + // The full apply → remove cycle over every file-based client: registers + // exactly the design's per-client formats, then removes exactly that + // entry — leaving everything else in each file intact. + #[test] + fn apply_then_remove_round_trips_every_file_based_client() { + let (root, env) = scratch(&["config/Claude", ".cursor", ".gemini", ".codex"]); + + // Pre-existing content that must survive: another MCP server in + // Cursor's JSON, comments + another server in Codex's TOML. + std::fs::write( + root.path.join(".cursor/mcp.json"), + r#"{ "mcpServers": { "other": { "command": "/bin/other" }, "imcp2": { "command": "/old/imcp2-local", "env": { "IMCP2_IC_URL": "http://127.0.0.1:4943" } } }, "theme": "dark" }"#, + ) + .unwrap(); + std::fs::write( + root.path.join(".codex/config.toml"), + "# my codex config\nmodel = \"o5\"\n\n[mcp_servers]\nimcp2 = { command = \"/old/imcp2\", env = { FOO = \"bar\" } }\n\n[mcp_servers.other]\ncommand = \"/bin/other\"\n", + ) + .unwrap(); + + let (report, failed) = run_in(&env, Mode::Apply); + assert_eq!(failed, 0, "{report}"); + assert!(report.contains("Claude Desktop: registered"), "{report}"); + assert!(report.contains("Cursor: registered; backup at"), "{report}"); + assert!(report.contains("Antigravity: registered"), "{report}"); + assert!(report.contains("Codex: registered; backup at"), "{report}"); + assert!(report.contains("Claude Code: not detected"), "{report}"); + assert!( + report.contains("Perplexity (macOS): not detected"), + "{report}" + ); + + // Claude Desktop / Antigravity: files created with the exact snippet. + for file in [ + root.path.join("config/Claude/claude_desktop_config.json"), + root.path.join(".gemini/config/mcp_config.json"), + ] { + let v: serde_json::Value = + serde_json::from_str(&std::fs::read_to_string(&file).unwrap()).unwrap(); + assert_eq!( + v["mcpServers"]["imcp2"]["command"], "/opt/imcp2/imcp2-local", + "{file:?}" + ); + } + // Cursor: our entry added, the other server and unrelated keys intact. + let cursor: serde_json::Value = serde_json::from_str( + &std::fs::read_to_string(root.path.join(".cursor/mcp.json")).unwrap(), + ) + .unwrap(); + assert_eq!( + cursor["mcpServers"]["imcp2"]["command"], + "/opt/imcp2/imcp2-local" + ); + assert_eq!( + cursor["mcpServers"]["imcp2"]["env"]["IMCP2_IC_URL"], "http://127.0.0.1:4943", + "user-added keys survive re-registration" + ); + assert_eq!(cursor["mcpServers"]["other"]["command"], "/bin/other"); + assert_eq!(cursor["theme"], "dark"); + assert!( + root.path.join(".cursor/mcp.json.imcp2-bak").exists(), + "backup kept" + ); + // Codex: comments and existing entries survive toml_edit. + let codex = std::fs::read_to_string(root.path.join(".codex/config.toml")).unwrap(); + assert!(codex.contains("# my codex config"), "{codex}"); + assert!(codex.contains("model = \"o5\""), "{codex}"); + assert!(codex.contains("[mcp_servers.other]"), "{codex}"); + let doc: toml_edit::DocumentMut = codex.parse().unwrap(); + assert_eq!( + doc["mcp_servers"]["imcp2"]["command"].as_str(), + Some("/opt/imcp2/imcp2-local") + ); + assert_eq!( + doc["mcp_servers"]["imcp2"]["env"]["FOO"].as_str(), + Some("bar"), + "user-added keys survive an inline-table entry too: {codex}" + ); + + // Remove: exactly our entry goes; everything else stays. + let (report, _) = run_in(&env, Mode::Remove); + assert!(report.contains("Cursor: removed"), "{report}"); + assert!(report.contains("Codex: removed"), "{report}"); + let cursor: serde_json::Value = serde_json::from_str( + &std::fs::read_to_string(root.path.join(".cursor/mcp.json")).unwrap(), + ) + .unwrap(); + assert!(cursor["mcpServers"].get("imcp2").is_none()); + assert_eq!(cursor["mcpServers"]["other"]["command"], "/bin/other"); + let codex = std::fs::read_to_string(root.path.join(".codex/config.toml")).unwrap(); + assert!(!codex.contains("imcp2"), "{codex}"); + assert!(codex.contains("[mcp_servers.other]"), "{codex}"); + assert!(codex.contains("# my codex config"), "{codex}"); + + // A second remove is a clean no-op. + let (report, _) = run_in(&env, Mode::Remove); + assert!(report.contains("Cursor: nothing to remove"), "{report}"); + } + + // Undetected clients are skipped with the reason named — setup never + // creates a client's config tree the client itself hasn't created. + #[test] + fn undetected_clients_are_skipped_not_invented() { + let (root, env) = scratch(&[]); + let (report, _) = run_in(&env, Mode::Apply); + for client in [ + "Claude Desktop", + "Claude Code", + "Codex", + "Cursor", + "Antigravity", + ] { + assert!( + report.contains(&format!("{client}: not detected")), + "{client} should be skipped:\n{report}" + ); + } + assert!(!root.path.join(".cursor").exists()); + assert!(!root.path.join(".codex").exists()); + } + + // --print writes nothing and shows every client's manual path, including + // the one-liners for Claude Code / Codex and Perplexity's UI steps. + #[test] + fn print_mode_only_prints() { + let (root, env) = scratch(&[".cursor"]); + let (report, _) = run_in(&env, Mode::Print); + assert!( + report.contains("claude mcp add --scope user --transport stdio imcp2"), + "{report}" + ); + assert!(report.contains("[mcp_servers.imcp2]"), "{report}"); + assert!( + report.contains("Settings → Connectors → Add Connector"), + "{report}" + ); + assert!(report.contains("mcpServers"), "{report}"); + assert!( + !root.path.join(".cursor/mcp.json").exists(), + "--print must not write" + ); + } + + // A corrupt config is refused with a clear error naming the file — setup + // must never destroy what it cannot parse. + #[test] + fn corrupt_configs_are_refused_not_overwritten() { + let (root, env) = scratch(&[".cursor"]); + let file = root.path.join(".cursor/mcp.json"); + std::fs::write(&file, "{ not json").unwrap(); + let (report, failed) = run_in(&env, Mode::Apply); + assert_eq!(failed, 1, "the refusal must reach the exit code: {report}"); + assert!(report.contains("Cursor: FAILED"), "{report}"); + assert!(report.contains("not valid JSON"), "{report}"); + assert_eq!( + std::fs::read_to_string(&file).unwrap(), + "{ not json", + "file untouched" + ); + } + + // The vendor CLI is preferred when it's recent enough: a `codex` that + // answers `mcp --help` gets the `mcp add`/`mcp remove` invocations, and + // the config file is never touched directly. + #[cfg(unix)] + #[test] + fn a_capable_codex_cli_is_preferred_over_the_file_edit() { + use std::os::unix::fs::PermissionsExt; + let (root, mut env) = scratch(&[".codex"]); + let log = root.path.join("codex-calls.log"); + let script = root.path.join("codex"); + std::fs::write( + &script, + format!("#!/bin/sh\necho \"$@\" >> '{}'\nexit 0\n", log.display()), + ) + .unwrap(); + std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o755)).unwrap(); + env.codex_cli = Some(script); + + let (report, failed) = run_in(&env, Mode::Apply); + assert_eq!(failed, 0, "{report}"); + assert!(report.contains("Codex: done (via `codex mcp`)"), "{report}"); + assert!( + !root.path.join(".codex/config.toml").exists(), + "the CLI path must not write the file" + ); + let calls = std::fs::read_to_string(&log).unwrap(); + assert!(calls.contains("mcp --help"), "{calls}"); + assert!( + calls.contains("mcp add imcp2 -- /opt/imcp2/imcp2-local"), + "{calls}" + ); + + let (report, failed) = run_in(&env, Mode::Remove); + assert_eq!(failed, 0, "{report}"); + assert!(report.contains("Codex: done (via `codex mcp`)"), "{report}"); + let calls = std::fs::read_to_string(&log).unwrap(); + assert!(calls.contains("mcp remove imcp2"), "{calls}"); + } + + // An older codex (no `mcp` subcommand) falls back to the direct file + // edit, exactly as if only the IDE extension were installed. + #[cfg(unix)] + #[test] + fn an_old_codex_cli_falls_back_to_the_file_edit() { + use std::os::unix::fs::PermissionsExt; + let (root, mut env) = scratch(&[".codex"]); + let script = root.path.join("codex"); + std::fs::write(&script, "#!/bin/sh\nexit 2\n").unwrap(); + std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o755)).unwrap(); + env.codex_cli = Some(script); + + let (report, failed) = run_in(&env, Mode::Apply); + assert_eq!(failed, 0, "{report}"); + assert!(report.contains("Codex: registered"), "{report}"); + let written = std::fs::read_to_string(root.path.join(".codex/config.toml")).unwrap(); + assert!(written.contains("[mcp_servers.imcp2]"), "{written}"); + } + + // The pasted snippets must stay valid in their target formats even for an + // awkward binary path — Windows backslashes and spaces are the cases a + // raw `display()` interpolation used to corrupt. + #[test] + fn manual_snippets_escape_awkward_paths() { + let (_root, mut env) = scratch(&[]); + env.exe = PathBuf::from(r"C:\Program Files\imcp2\imcp2-local.exe"); + let by_name = |name: &str| { + clients(&env) + .into_iter() + .find(|c| c.name == name) + .unwrap() + .manual + }; + + let cursor = by_name("Cursor"); + let snippet = cursor.split_once(":\n").unwrap().1; + let parsed: serde_json::Value = serde_json::from_str(snippet).expect(snippet); + assert_eq!( + parsed["mcpServers"]["imcp2"]["command"], + r"C:\Program Files\imcp2\imcp2-local.exe" + ); + + let codex = by_name("Codex"); + let toml_body = codex.split_once(":\n").unwrap().1; + let doc: toml_edit::DocumentMut = toml_body.parse().expect(toml_body); + assert_eq!( + doc["mcp_servers"]["imcp2"]["command"].as_str(), + Some(r"C:\Program Files\imcp2\imcp2-local.exe") + ); + + let claude = by_name("Claude Code"); + assert!( + claude.ends_with(r#"-- 'C:\Program Files\imcp2\imcp2-local.exe'"#), + "the one-liner must quote a spaced path literally: {claude}" + ); + } + + // The one-time backup holds the PRE-imcp2 state: a second apply must not + // rotate it away with an already-registered copy. + #[test] + fn the_backup_is_first_run_only() { + let (root, env) = scratch(&[".cursor"]); + let file = root.path.join(".cursor/mcp.json"); + std::fs::write(&file, r#"{ "mcpServers": {} }"#).unwrap(); + run_in(&env, Mode::Apply); + let bak = root.path.join(".cursor/mcp.json.imcp2-bak"); + let first = std::fs::read_to_string(&bak).unwrap(); + assert!(!first.contains("imcp2"), "backup is the pre-imcp2 state"); + run_in(&env, Mode::Apply); + assert_eq!( + std::fs::read_to_string(&bak).unwrap(), + first, + "backup not rotated" + ); + } +} diff --git a/dist-workspace.toml b/dist-workspace.toml new file mode 100644 index 0000000..7af03a3 --- /dev/null +++ b/dist-workspace.toml @@ -0,0 +1,33 @@ +# Release configuration for the `imcp2-local` binary (the design's end-user +# distribution channel), built with dist (cargo-dist) following the ICP CLI's +# setup. Only `imcp2-local` is distributed (see `[package.metadata.dist]` in +# the crate manifests); the `imcp2`/`imcp2-core` crates release to crates.io +# through .github/workflows/publish-crate.yml instead. +[workspace] +members = ["cargo:."] + +# Config for 'dist' +[dist] +# The preferred dist version to use in CI (Cargo.toml SemVer syntax) +cargo-dist-version = "0.31.0" +# CI backends to support +ci = "github" +# The installers to generate for each app +installers = ["shell", "powershell"] +# Target platforms to build apps for (Rust target-triple syntax) +targets = ["aarch64-apple-darwin", "aarch64-unknown-linux-gnu", "x86_64-apple-darwin", "x86_64-unknown-linux-gnu", "x86_64-pc-windows-msvc"] +# Path that installers should place binaries in +install-path = "CARGO_HOME" +# Whether to install an updater program +install-updater = true +# A prefix git tags must include for dist to care about them +tag-namespace = "imcp2-local" +# Whether to enable GitHub Attestations +github-attestations = true + +# Pin GitHub Actions to exact commit SHAs in the generated release.yml +[dist.github-action-commits] +"actions/checkout" = "de0fac2e4500dabe0009e67214ff5f5447ce83dd" # v6.0.2 +"actions/upload-artifact" = "b7c566a772e6b6bfb58ed0dc250532a479d7789f" # v6.0.0 +"actions/download-artifact" = "37930b1c2abaa49bbe596cd826c3c89aef350131" # v7.0.0 +"actions/attest-build-provenance" = "96278af6caaf10aea03fd8d33a09a777ca52d62f" # v3.2.0