Repository navigation
Stage 3: end-user setup, PocketIC login e2e, and the dist release pipeline - #152
Conversation
The local half of the design merged in #148: a single-user binary an AI tool spawns and talks to over stdio, serving the same tool surface as the hosted server against the same mainnet and production Internet Identity, with none of the hosted server's OAuth machinery. imcp2-local (new crate, publish = false; distributed as release binaries per the design's Stage 3): - login driver: `authenticate` mints a FRESH session id + registration key, binds a transient loopback listener on 127.0.0.1:0, and returns the id.ai connect link in-band (plus a best-effort `open::that` browser launch), never blocking on the callback. The listener serves exactly three routes — the pinned fragment-reading callback page (shared with hosted, same CSP + hardening headers), the slim redeem, and the #4091 allow-list (CORS-readable, no-store) — and is torn down on redeem, on the 10-minute handshake timeout, or when a fresh flow replaces it. The redeem mirrors the hosted order (validate state, parse before claiming, single-flight claim, one mcp_register_v2 call) minus the OAuth tail: success answers {"done": true} and the page renders "Signed in — you can close this tab" in place. - wrapper handler: the two local-only login tools live on LocalServer's own #[tool_router]; tools/list is the merged surface (26 core + 2), tools/call dispatches to the login router first and forwards everything else (calls, resources, get_tool) to the inner IcTools handler verbatim. get_info identifies as imcp2-local and appends the sign-in guidance to core's instructions. - main: mainnet agent (IMCP2_IC_URL override; IMCP2_FETCH_ROOT_KEY only honoured for loopback endpoints — the local-replica test configuration), production II (II_URL_PROD/II_CANISTER_ID_PROD overrides), management identity derived at the hosted server's origin by default (IMCP2_MANAGEMENT_ORIGIN) so the same anchor keeps the same controller principal across deployments, IMCP2_NO_OPEN, all logging to stderr (stdout is the JSON-RPC channel). imcp2-core (the two seams Stage 1 left for the login driver): - SessionSource::Singleton now holds a SessionSlot — a shared, REPLACEABLE holder, not a fixed id: the II contract requires a fresh session key after Unauthorized, so each successful login writes a fresh session id and reauthentication is a same-process step. - the pinned callback page's script gains the {"done": true} success arm (hosted keeps answering `redirect`), with a `.done` terminal state in connect.css that drops the spinner without the error line. - regression guard: the core router must never carry the login tools (it IS the hosted tools/list). Tests: 218 pass workspace-wide (9 new in imcp2-local, 3 new in core), including a real MCP round-trip over an in-process duplex pipe (merged tools/list, login lifecycle through tools/call, forwarding into core tools) and browser-played listener tests (pinned page + CSP, allow-list verbatim-match + CORS, redeem refusal paths staying retryable). The built binary answers initialize/tools/list over real stdio. No behavior change to the hosted server. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AqNkpMQiQHzYxC2djTfvBK
…le-redeem guard - authenticate/auth_status now declare the object-rooted outputSchema and attach matching structuredContent (one AuthOutput shape for the whole sign-in lifecycle), preserving the all-tools MCP contract the core surface enforces. - auth_status reports a live pending handshake BEFORE a live grant, matching begin's precedence: after authenticate(refresh=true) the truthful answer is "a replacement sign-in is waiting", not "signed in". - The redeem's commit is no longer last-writer-wins: each flow carries an epoch, and a stale redeem (claimed, then outlived by the watchdog while its mcp_register_v2 call ran) is refused when a NEWER pending or completed sign-in exists — while a late success with nothing newer around still lands. Tests: 220 pass workspace-wide (2 new: pending-outranks-grant status, stale-redeem supersession) with structured-content assertions added to the MCP round-trip and the outputSchema check extended to the merged surface. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AqNkpMQiQHzYxC2djTfvBK
…eline The polish stage of the local-deployment design (docs in #148, the binary in the Stage 2 PR), in three pieces: imcp2-local setup (component 9). One command registers this binary with the AI tools installed on the machine — Claude Desktop, Cursor, and Antigravity through their mcpServers JSON; Codex through ~/.codex/config.toml edited with toml_edit so the user's comments and formatting survive; Claude Code through its own CLI (user scope); Perplexity's UI-only steps are printed. `setup --remove` undoes exactly those registrations, `setup --print` only shows the steps, corrupt configs are refused rather than overwritten, and the first modification of an existing file keeps a one-time .imcp2-bak backup next to it. The binary also grows --help/--version; serving stays the bare no-argument path clients invoke. PocketIC end-to-end login (component 3 / Verification). The hosted crate's e2e harness, extended to the local flow: a live Internet Identity canister in PocketIC issues a genuine canister-signed registration delegation; the login driver binds its real loopback listener; the test plays the browser over real HTTP (pinned page, #4091 allow-list fetch, redeem POST) and the driver redeems via mcp_register_v2 against the live II — asserting {"done": true}, the filled session slot, the signed-in status with the real principal, and the listener teardown. Gated exactly like the hosted harness (cargo feature `e2e` + II_WASM/POCKET_IC_BIN at runtime). Verified here for real: both the new local e2e and the hosted e2e pass against the same pocket-ic 15.0.0 + II release-2026-07-17 artifacts. Release pipeline (components 9/10). dist (cargo-dist 0.31, the ICP CLI's setup): five platform targets, shell/PowerShell installers, install-updater = true (one `imcp2-local-update` upgrades in place), GitHub artifact attestations on every artifact, and pinned action SHAs. Releases announce on `imcp2-local-v*` tags only (tag-namespace), so the plain v* namespace stays the crates.io publish trigger and a crate release never implicitly ships binaries. Rehearsed locally: `dist plan` for the full matrix and a real `dist build` of the linux artifact (archive + updater companion + checksum; the packaged binary runs). Apple Developer ID + notarization and Windows Authenticode hook into this same pipeline once the organization's signing credentials exist — until then the installer channels (which the OS gates don't cover) are the supported path, as the design lays out. Plus crates/imcp2-local/README.md: install, per-client registration, signing in, upgrading, configuration, the wallet-grade trust note, and download verification (gh attestation verify). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AqNkpMQiQHzYxC2djTfvBK
…local-deploy-jpof9i-stage2 # Conflicts: # crates/imcp2-core/src/tools.rs
…laude/secondary-mcp-local-deploy-jpof9i-stage3
The epoch guard fixed only the local half of the stale-redeem race: II's mcp_register_v2 REPLACES the anchor's previous grant, so a timed-out flow's slow registration landing after a newer flow's left II honoring the old session while the guarded slot kept naming the new one — every subsequent tool call would come back Unauthorized. A local commit-time check cannot undo the remote side effect. Replace the guard with what the race actually needs: a registration mutex that serializes each redeem's mcp_register_v2 AND its local commit, across flows. The last registration to settle at II is the live grant, and under the lock the commit order equals the settle order, so the slot always names the session II actually honors — in both interleavings of an old in-flight registration and its replacement. The per-flow single-flight claim stays (it stops page double-submits); the epoch machinery goes. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AqNkpMQiQHzYxC2djTfvBK
…laude/secondary-mcp-local-deploy-jpof9i-stage3
The design's loopback hardening requires it (bind 127.0.0.1 explicitly AND validate the Host header, the local analogue of the hosted server's allowed-hosts list), and the listener accepted any Host: a page on an attacker's hostname that rebinds to 127.0.0.1 could reach the three routes with `Host: attacker.example`. Every URL this flow hands out carries exactly one authority — this listener's 127.0.0.1:<port> — so a middleware now rejects anything else (exact string match, like II's own allow-list check) before routing. Even without it the routes are largely inert to a rebinder (/redeem requires the connect state and a chain targeting the in-process X); this closes the door outright. Tested with raw-socket requests: a rebound hostname and even localhost:<port> bounce with 403; the one advertised authority passes. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AqNkpMQiQHzYxC2djTfvBK
…laude/secondary-mcp-local-deploy-jpof9i-stage3
The pinned page's inline script used single-character names (t, cls, c, r, d); source stays readable — minification, if ever wanted, is a build transformation, not a source style. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AqNkpMQiQHzYxC2djTfvBK
…laude/secondary-mcp-local-deploy-jpof9i-stage3
…lver The SessionSource enum put deployment knowledge (bearer vs singleton) inside core's tool layer. Replace it with an injected resolver: core only asks "which already-validated session does this call act as?", and each binary owns its authentication end to end, in its own package — the hosted crate's require_token middleware validates the bearer and its resolver reads the stashed AuthedSession back (both now defined in imcp2's auth.rs again); the local binary's login flow fills its SessionSlot (now defined in imcp2-local's login.rs) and hands core the slot-reading resolver. Core drops its `http` dependency along the way — the request-extension read was the only use. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AqNkpMQiQHzYxC2djTfvBK
…laude/secondary-mcp-local-deploy-jpof9i-stage3
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AqNkpMQiQHzYxC2djTfvBK
…h the opener - login.rs (~1000 lines) becomes the login/ module: mod.rs (the driver and its state machine), slot.rs (the SessionSlot), routes.rs (the loopback listener's three routes + Host validation), tests.rs. - CONTACT -> CONTACT_EMAIL, saying what kind of string it is. - The browser opener uses open::that_detached: open 5.x already nulls the launcher's stdio either way (so nothing could interleave with the JSON-RPC stream), but detached additionally double-forks the launcher away off macOS, so it can never hold a thread or a handle on us. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AqNkpMQiQHzYxC2djTfvBK
…blind to it Component 7 now says what the reviewed implementation does: IcTools asks one injected SessionResolver "which already-validated session is this call?" and knows nothing else — not the transport, not how callers are validated, not whether the deployment is single- or multi-user. Each binary owns its authentication end to end in its own package (the hosted bearer middleware + AuthedSession in imcp2's auth.rs; the local login flow's SessionSlot in imcp2-local), and the doc spells out why the seam is a value-returning callback rather than a middleware slot in core: real authentication middleware needs its transport, so the middlewares live with their transports and core receives only their outcome. Core's dependency profile loses `http` accordingly, and the concise doc's component 7 matches. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AqNkpMQiQHzYxC2djTfvBK
…laude/secondary-mcp-local-deploy-jpof9i-stage3
…is PR - The local binary now runs the same 60-second session reaper the hosted server does: expired grants, abandoned sign-in keys, and their cached delegations leave memory promptly instead of accumulating over a long-lived process (below MAX_PENDING_CONNECTS nothing else scans the map). - The register -> slot/grant commit -> listener-shutdown success path now has an in-repository test ON THIS PR: the PocketIC end-to-end login (a live II canister issues a genuine canister-signed registration delegation; the test plays the browser over real HTTP against the real listener) moves here from the follow-up branch, gated exactly like the hosted crate's harness (cargo feature `e2e` + II_WASM/POCKET_IC_BIN at runtime). Run here and passing. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AqNkpMQiQHzYxC2djTfvBK
…laude/secondary-mcp-local-deploy-jpof9i-stage3 # Conflicts: # Cargo.lock # crates/imcp2-local/Cargo.toml
…ockfile The merge commit landed with both branches' e2e module declarations and an unresolved Cargo.lock — committed without the verification gate actually running. Full suite + the live PocketIC e2e verified green on this tree. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AqNkpMQiQHzYxC2djTfvBK
The reaper removes an expired session's server-side state, but the slot kept resolving its id — session-requiring tools would then bypass their "needs an authenticated session" guidance and surface internal errors (or re-create a keyless session via ensure_session). The slot now stores the grant expiration II returned and reports NO session the moment the grant lapses, so unauthenticated is immediate and accurate — the reaper's 60-second cadence frees memory, it does not gate authentication. auth_status keeps its Expired reporting via the driver's retained Grant. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AqNkpMQiQHzYxC2djTfvBK
…laude/secondary-mcp-local-deploy-jpof9i-stage3
…local-deploy-jpof9i-stage3
The module header credited comment preservation via toml_edit, which is not what distinguishes the file edit from `codex mcp add`. The actual reasons: directory-keyed detection reaches installs whose codex binary is not on PATH, removal stays idempotent, and the format works on Codex versions predating the `codex mcp` subcommand. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AqNkpMQiQHzYxC2djTfvBK
Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AqNkpMQiQHzYxC2djTfvBK
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 8 out of 9 changed files in this pull request and generated 5 comments.
Suppressed comments (3)
crates/imcp2-local/src/setup.rs:468
- A first invocation of
setup --removecan modify an existing JSON config without creating the promised one-time backup;backup_onceis called only by the apply path. Create the backup before this write so removal cannot delete a pre-existingimcp2registration without a recovery copy.
std::fs::write(path, format!("{:#}\n", root))
.with_context(|| format!("write {}", path.display()))?;
crates/imcp2-local/src/setup.rs:550
- The Codex removal path also skips
backup_once, so runningsetup --removebefore apply deletes an existing table without the backup promised for the first config modification. Back up the parsed file immediately before writing the removal.
std::fs::write(path, doc.to_string()).with_context(|| format!("write {}", path.display()))?;
.github/workflows/imcp2-local-release.yml:206
- The global artifacts produced here (installers and checksums) are uploaded and released, but only
build-local-artifactshas an attestation step. This contradicts the PR's claim of attestations on every artifact. Either grant this jobid-token/attestationspermissions and attest its upload files, or narrow the release guarantee to platform archives.
- id: cargo-dist
shell: bash
run: |
dist build ${{ needs.plan.outputs.tag-flag }} --output-format=json "--artifacts=global" > dist-manifest.json
…moval, tighter verify - The one-liner path is always single-quoted (POSIX and PowerShell literal quoting), so shell metacharacters in a path never expand. - A refused `claude mcp remove` counts as a failure unless the output says the registration was absent. - Removal now also takes the one-time pre-imcp2 backup before its first modification of a config file. - README: the verify command pins --signer-workflow, and the maintainer flow names the protected-tag ruleset for imcp2-local-v* tags as the control over what ships. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AqNkpMQiQHzYxC2djTfvBK
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 8 out of 9 changed files in this pull request and generated 1 comment.
Suppressed comments (7)
Previously missed (2) — in code that hasn't changed since the last review.
crates/imcp2-local/src/setup.rs:89
- A non-UTF-8 executable path is accepted here, but every generated registration later uses
display()/to_string_lossy(), replacing bytes with U+FFFD and registering a nonexistent command while reporting success. Reject paths that cannot be represented as UTF-8 before constructingEnv.
let exe = exe.canonicalize().unwrap_or(exe);
crates/imcp2-local/README.md:37
--removedoes not necessarily “undo exactly” what setup added: it intentionally removes the reservedimcp2entry by name even if that entry predated setup or was later changed, and it does not restore the backup. Describe the name-keyed removal semantics so users do not mistake this for exact restoration.
imcp2-local setup --remove # undo exactly that
.github/workflows/imcp2-local-release.yml:18
- The workflow grants
contents: writeto every job, including the PR/tag planning job that executes a downloaded installer and repository-controlled dist configuration. Only the publishing job should receive release-write authority; default the workflow tocontents: readand grantcontents: writeexplicitly tohost.
permissions:
"contents": "write"
crates/imcp2-local/src/setup.rs:457
- This truncates the live client config in place. A crash or write failure can leave invalid JSON (and newly created files have no backup); a concurrent client read can also observe partial content. Serialize to a sibling temporary file and atomically replace the destination, and use the same persistence helper in the JSON removal and both TOML paths.
std::fs::write(path, format!("{:#}\n", root))
.with_context(|| format!("write {}", path.display()))?;
crates/imcp2-local/src/setup.rs:483
- The removal path also truncates the live JSON config in place, so interruption can leave the client unable to parse any of its registrations. Use the same cross-platform atomic replacement helper as the apply path.
std::fs::write(path, format!("{:#}\n", root))
.with_context(|| format!("write {}", path.display()))?;
crates/imcp2-local/src/setup.rs:537
- The Codex apply path truncates
config.tomlin place, so a crash or write failure can corrupt the user's entire Codex configuration. Persist through the same sibling-temp-and-atomic-replace helper as the JSON paths.
std::fs::write(path, doc.to_string()).with_context(|| format!("write {}", path.display()))?;
crates/imcp2-local/src/setup.rs:570
- The Codex removal path likewise truncates the live TOML file in place and can leave it partially written. Route this write through the shared atomic replacement helper as well.
std::fs::write(path, doc.to_string()).with_context(|| format!("write {}", path.display()))?;
…8 path check - All four live-config writes (JSON/TOML, apply/remove) go through one persist() helper: sibling temp file + rename, so a crash or full disk never leaves a client's config truncated. - The one-time backup copies to a temp and renames into place, so a partial copy can never pass as the valid backup on later runs. - setup refuses a non-UTF-8 binary path up front instead of lossily registering a command that does not exist. - README quickstart no longer says --remove 'undoes exactly' — it removes the imcp2 registrations by name. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AqNkpMQiQHzYxC2djTfvBK
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 8 out of 9 changed files in this pull request and generated 1 comment.
Suppressed comments (2)
Previously missed (2) — in code that hasn't changed since the last review.
crates/imcp2-local/README.md:113
- The release matrix includes Windows, whose cargo-dist archive is a
.zip, so this.tar.xzcommand cannot verify the Windows download. Use a format-neutral placeholder (or show both extensions) so every documented platform can follow the verification instructions.
gh attestation verify imcp2-local-<target>.tar.xz -R dfinity/imcp2 \
--signer-workflow dfinity/imcp2/.github/workflows/imcp2-local-release.yml
crates/imcp2-local/src/setup.rs:557
- A valid Codex entry written as an inline table (
[mcp_servers] imcp2 = { command = "...", args = [...] }) is not returned byas_table_mut(), so this branch replaces the whole entry and silently dropsargs,env, and other settings. Handle both regular and inline TOML tables when updatingcommand.
match servers.get_mut(SERVER_NAME).and_then(|i| i.as_table_mut()) {
Some(entry) => {
entry.insert("command", toml_edit::value(exe.to_string_lossy().as_ref()));
}
…ntries, .zip note
- persist() carries the destination's permissions onto the temp before
the rename, so a 0600 config never comes back umask-default.
- The Codex upsert merges into an inline imcp2 = { ... } entry too,
instead of replacing it wholesale; the round-trip test now seeds one
with env and asserts survival by parsing the result.
- README verify example notes Windows archives are .zip.
Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AqNkpMQiQHzYxC2djTfvBK
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 8 out of 9 changed files in this pull request and generated 1 comment.
Suppressed comments (1)
Previously missed (1) — in code that hasn't changed since the last review.
crates/imcp2-local/src/setup.rs:46
- Because each flag overwrites
mode,setup --print --removeremoves registrations even though--printpromises that nothing is written and the usage marks these options as mutually exclusive. Reject conflicting modes instead of letting the last flag win.
"--remove" => mode = Mode::Remove,
"--print" => mode = Mode::Print,
…g flags rejected - persist() canonicalizes the destination first, so a symlinked config (dotfiles managers) gets its target updated instead of the link being replaced by a regular file. - setup rejects --print together with --remove instead of letting the last flag win. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AqNkpMQiQHzYxC2djTfvBK
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 8 out of 9 changed files in this pull request and generated no new comments.
Suppressed comments (3)
Previously missed (2) — in code that hasn't changed since the last review.
crates/imcp2-local/src/setup.rs:490
- When retargeting an existing entry, preserving
argscan leave this registration unusable.imcp2-localserves only with no arguments, so arguments from a previous command are passed to the new binary and makemainreject them as unknown. Remove the owned invocation's staleargsfield while retaining compatible additions such asenv.
This issue also appears on line 572 of the same file.
entry.insert(
"command".to_string(),
serde_json::Value::from(exe.to_string_lossy().as_ref()),
);
crates/imcp2-local/src/setup.rs:516
- Apply mode explicitly treats an empty JSON config as an empty object, but remove mode parses the same file as JSON and fails. Thus
setup --removereports a failure even though an empty config cannot contain the registration. Handle blank content as the same clean “nothing to remove” case used for a missing file.
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()))?;
crates/imcp2-local/src/setup.rs:576
- The Codex merge likewise preserves a prior server's
args. After changingcommandtoimcp2-local, Codex will still launch it with those arguments, but this binary only serves on its no-argument path and exits on any unknown argument. Clearargsin both regular and inline tables when retargeting the entry.
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()));
Per review: use `codex mcp add`/`remove` when a codex recent enough to have the mcp subcommand is on PATH (probed per run); fall back to the direct, atomic config.toml edit for IDE-only installs with no binary on PATH and for older CLIs. The fallback respects $CODEX_HOME. The vendor invocation shares the claude-path refusal contract via one helper (absent-on-remove is benign, everything else fails the exit code), and two tests drive a fake codex script through both halves. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AqNkpMQiQHzYxC2djTfvBK
…ent-removal line - persist() refuses a config symlink whose target cannot be resolved instead of silently replacing the link with a regular file. - A vendor-CLI removal refused as absent now reports 'nothing to remove' rather than a refusal, matching the zero exit. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AqNkpMQiQHzYxC2djTfvBK
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 8 out of 9 changed files in this pull request and generated no new comments.
Suppressed comments (3)
Previously missed (2) — in code that hasn't changed since the last review.
crates/imcp2-local/src/setup.rs:133
is_file()also accepts non-executable PATH entries. On Unix, a stale non-executablecodexfile is treated as an installation; its probe then fails and this command falls back to creating~/.codex/config.toml, while the equivalent Claude case reports a registration failure. Filter extensionless PATH candidates by executable permission before returning them.
let candidate = dir.join(name);
if candidate.is_file() {
return Some(candidate);
crates/imcp2-local/src/setup.rs:408
- This misses a valid per-user macOS installation in
~/Applications/Perplexity.app, so an installed client is reported as absent and its connector instructions are omitted in apply mode. Check both the system and per-user Applications directories.
target: if env.applications_dir.join("Perplexity.app").exists() {
Target::Manual
} else {
Target::NotDetected("Perplexity.app is not in /Applications".into())
crates/imcp2-local/src/setup.rs:512
- This preserves only the metadata represented by
std::fs::Permissions; on Windows that is just the read-only flag, not the file's DACL. Because the temporary file inherits the directory ACL and then replaces the destination, an existing config with a restricted ACL can become accessible to additional users even though MCP configs may contain API keys. Use a Windows replacement API that preserves the destination DACL (or explicitly copy its security descriptor) before replacing it.
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()));
}
Summary
Stage 3 of the local-deployment design (docs merged in #148, Stage 1 in #77; stacked on Stage 2 in #150 — merge that first, GitHub retargets this PR automatically): the polish stage — the
setupend-user registration command (component 9), the PocketIC end-to-end test of the real login flow (component 3 / Verification), and thedistrelease pipeline with provenance attestations (components 9/10), plus the crate README with the wallet-grade trust note (component 8).Related issues
Implements Implementation Stages → Stage 3 of the design docs merged in #148.
Changes
imcp2-local setup— one command, no JSON/TOML editing for the user:mcpServersJSON, other keys preserved), Codex (~/.codex/config.tomlviatoml_edit, comments and formatting preserved), Claude Code (via its ownclaude mcp add --scope user, which owns that config), Perplexity macOS (UI-only; the steps + JSON to paste are printed).setup --removeundoes exactly those registrations;setup --printonly shows the steps; corrupt configs are refused, never overwritten; the first modification of an existing file keeps a one-time.imcp2-bakbackup.--help/--version; serving stays the bare no-argument path client registrations invoke.src/e2e_local_login.rs, featuree2e+ optionalpocket-ic, mirroring the hosted harness's gating): a live II canister issues a genuine canister-signed registration delegation; the login driver binds its real loopback listener; the test plays the browser over real HTTP (pinned page, #4091 allow-list fetch, redeem POST); the driver redeems viamcp_register_v2on the live II — asserting{"done": true}, the filled session slot, signed-in status with the real principal, "already signed in" on a repeatauthenticate, and listener teardown.dist(cargo-dist 0.31, the ICP CLI's setup):dist-workspace.toml+ the generated.github/workflows/imcp2-local-release.yml; five targets (macOS arm64/x64, Linux x64/arm64, Windows x64), shell/PowerShell installers,install-updater = true(oneimcp2-local-updateupgrades in place), GitHub artifact attestations on every artifact, pinned action SHAs. Tag namespaces stay separate: releases announce only onimcp2-local-v*tags, so the plainv*namespace remains the crates.io publish trigger and a crate release never implicitly ships binaries.crates/imcp2-local/README.md— install, per-client registration, signing in, upgrading, configuration, the wallet-grade security note, and download verification (gh attestation verify … -R dfinity/imcp2).Deliberately not in this PR (with the design's own reasoning):
.mcpbbundle: both need the organization's signing credentials (Apple Developer ID + notarization; Azure Trusted Signing) — the design notes Gatekeeper checks the binary inside an.mcpb, so shipping the bundle before signing exists would produce exactly the blocked double-click the design warns about. The acquisition list and the pipeline hook points are in component 10; until then the installer channels (not subject to the OS gates) are the supported path.Testing
cargo build --workspace --all-targets/cargo test --workspace --all-targets— 225 passed, 0 failed (imcp2-local now 16: 5 newsetuptests covering the apply→remove round-trip with foreign-key/comment preservation, skip-not-invent detection, print-only mode, corrupt-config refusal, first-run-only backups)release-2026-07-17): the full local login against a live II canister in ~3.8s — and the hosted e2e passes with the same artifacts as a cross-checksetupexercised live in this container:--print, apply against a scratch home (correct skips, realclaude mcp addexecution honoring$HOME, "already exists" refusal surfaced cleanly)dist plan --tag imcp2-local-v0.2.0plans the full matrix; a realdist buildof the Linux artifact produced the archive + updater companion + checksum, and the packaged binary runscargo check --features e2e --all-targetsfor both crates;cargo fmtclean on the new files; internal-identifier scan clean over the whole rangeimcp2-local*version tags (verified pattern), so existingv*crate publishes are unaffectedChecklist
crates/imcp2-local/README.mdadded; the design docs (Design docs: IMCP2 local deployment (minimal stdio binary, no OAuth) #148) describe this stage.🤖 Generated with Claude Code
https://claude.ai/code/session_01AqNkpMQiQHzYxC2djTfvBK
Generated by Claude Code