Skip to content

Design docs: IMCP2 local deployment (minimal stdio binary, no OAuth) - #148

Merged
aterga merged 23 commits into
mainfrom
claude/secondary-mcp-local-deploy-jpof9i-docs
Aug 25, 2026
Merged

aterga merged 23 commits into
mainfrom
claude/secondary-mcp-local-deploy-jpof9i-docs

Conversation

@aterga

@aterga aterga commented Aug 24, 2026

Copy link
Copy Markdown
Collaborator

Summary

Design docs (docs-only) for imcp2-local: a separate, minimal run-it-yourself binary that speaks MCP over stdio, talks to mainnet IC + production Internet Identity, keeps the full II session model, and drops the entire OAuth 2.1 authorization server (the stdio process boundary replaces the bearer gate on the tool surface; login is a built-in browser handshake). The shared components live in a new imcp2-core library from which either binary is composed; the existing imcp2 crate keeps its name, binary, and deployments as the hosted server built on core.

This is the bottom of a two-PR stack — the Stage 1 implementation (extract imcp2-core) sits on top in #77.

Two documents, side by side, both in the standard feature-doc structure (Summary → Problem → Non-goals → Approach → Design components → Implementation Stages):

  • docs/local-deployment.md — the concise, high-level overview for stakeholders: one altitude up, no code-level detail.
  • docs/scoping-local-deployment.md — the full design: code-level partitions, the AI-client capability matrix and registration snippets, file:line evidence, verification plan. Rebased on main (v0.2.0) with every claim re-verified against the current tree.

Related issues

Changes

  • Add docs/local-deployment.md (concise overview) and docs/scoping-local-deployment.md (full design), cross-linked. The full doc's ten design components:
    1. Crate layout — imcp2-core as the common dependency of imcp2 and imcp2-local; imcp2 re-exports core so embedders keep compiling; core becomes a second, family-internal published crate.
    2. Per-crate dependency profile.
    3. Mainnet + prod II wiring, including a local-replica test configuration (an IC-endpoint override with a loopback-guarded fetch_root_key); integration tests run it against PocketIC (the pocket-ic dev dependency).
    4. The auth.rs OAuth-drop / II-connect-keep partition.
    5. The built-in browser II login — lazy, non-blocking, in-band authenticate tool; the loopback listener is axum and the browser opens via open::that, matching the ICP CLI's icp identity link web flow; the transient listener is the RFC 8252-style unavoidable minimum for II's redirect + #4091 origin fetch, and never serves the tool surface.
    6. AI tool client integration (Claude, Codex, Cursor, Perplexity, Antigravity) — capability matrix + registration formats.
    7. Session handling — bearer lookup on hosted, singleton in-memory session on local; identical tools.
    8. Security model — wallet-grade trust note, loopback hardening.
    9. End-user setup UX — no JSON/config editing anywhere: the double-click .mcpb bundle for Claude Desktop, an Add-to-Cursor install link, one pasted command for Claude Code and Codex, connector UIs for Perplexity (macOS) and Antigravity, an imcp2-local setup command that detects installed clients and writes their configs, and in-place upgrades following the ICP CLI's cargo-dist/axoupdater channel.
    10. Signing and provenance — the OS execution gates (macOS Developer ID + notarization; Windows Authenticode via Azure Trusted Signing/EV) versus keyless GitHub artifact attestations; the .mcpb nuance, pipeline hook-in, and DFINITY's acquisition checklist.
  • Implementation Stages with exits and the verification plan (integration tests run imcp2-local in the local-replica test configuration against PocketIC with a deployed II canister; remaining unknowns are loopback mixed-content/CORS).

Testing

Docs-only change — no build/test impact.

  • No code changed (docs/ only)

Checklist

  • I have read the Contributing guidelines.
  • Docs updated (this PR is the docs).
  • No secrets, credentials, or internal-only information are included.

🤖 Generated with Claude Code

https://claude.ai/code/session_01AqNkpMQiQHzYxC2djTfvBK


Generated by Claude Code

claude added 19 commits August 20, 2026 15:01
Analyze the current beta/prod composition and scope a secondary server
instance that bridges the MCP tools to a local dfx replica instead of
mainnet. Documents the mainnet assumptions that would break locally
(root-key fetch, hard-coded IC_URL, the discovery SSRF/https guard,
target_origin canonicalization, and the CMC/ledger management path), a
proposed local run-profile design, the security guardrails, a phased
work breakdown, and open decisions.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AqNkpMQiQHzYxC2djTfvBK
Rewrite the scoping doc for the intended model: a separate, minimal
run-it-yourself binary that speaks MCP over stdio and talks to mainnet
IC + production Internet Identity — not a local dfx replica. It drops
the entire OAuth 2.1 authorization-server layer (the local client is
co-located, so the stdio process boundary replaces bearer-token auth)
while keeping Internet Identity: login runs as a built-in browser
handshake and per-app account delegations work as today.

Adds a verified dependency-stripping analysis (four unreferenced crates
plus a vestigial schemars drop; rmcp swaps to the stdio transport and
drops auth), the auth.rs OAuth-drop/II-connect-keep partition, the
tool/session singleton seam, a 3-crate workspace layout, the security
model, and the production-II verification risks.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AqNkpMQiQHzYxC2djTfvBK
The shared library crate stays imcp2 (its existing embeddable identity)
and the hosted binary stays named imcp2, so the Dockerfile, systemd
unit, and deploy scripts that build/run an imcp2 binary are unchanged.
The hosted server + OAuth layer move behind a default-on `hosted`
feature (optional axum/tower-http); the new minimal stdio binary is a
separate imcp2-local crate depending on imcp2 with default-features
off, so it never compiles the OAuth/HTTP deps.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AqNkpMQiQHzYxC2djTfvBK
Cover how imcp2-local integrates with Claude (Desktop/Code + the
cloud surfaces), Codex, Cursor, Perplexity, and Antigravity, verified
against each client's current MCP docs.

Key findings: a stdio server is reachable only by clients that spawn a
local subprocess (Claude Desktop/Code, Codex CLI/IDE/desktop, Cursor,
Antigravity, and the Perplexity macOS app via its PerplexityXPC helper);
cloud/remote-only surfaces (claude.ai web/mobile, Perplexity web/Windows,
Codex Cloud) can't reach localhost and need the hosted imcp2 server — the
reason the OAuth layer stays in imcp2. Host-driven OAuth never applies to
stdio, and stderr is invisible in chat on every client, so the II login
URL is surfaced in-band via an authenticate tool (lazy, non-blocking)
plus a best-effort browser auto-open. Adds a capability matrix, per-client
registration snippets, the login invariants, and the Perplexity remote
OAuth/discovery caveats; updates the login section and open decisions to
match.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AqNkpMQiQHzYxC2djTfvBK
Re-verify every claim and file:line reference against the rebased tree
and absorb what main already did or changed:

- The five vestigial deps (ed25519-dalek, p256, ic-signature-verification,
  ic-representation-independent-hash, schemars 0.8) were already removed
  on main (#100) — reframe that stripping work as done.
- Account for the new hosted-only surfaces the local split must gate:
  prometheus + src/metrics.rs (/metrics, #124) and McpConfig's
  state_dir/require_resource (#144/#127); note the local binary needs no
  state directory at all. pocket-ic (e2e-only, #76) never enters any
  closure.
- Rewrite §10: production II now serves at /mcp by default (#92), CI
  probes it (#113), and the PocketIC e2e harness verifies the full
  connect contract — the remaining unknowns are the local binary's
  http://127.0.0.1 mixed-content and CORS behavior, and the harness is
  the plan for testing the local flow.
- Prod-instance env overrides are II_URL_PROD/II_CANISTER_ID_PROD (#92);
  registration chains are also verified client-side against the agent's
  root key (new_with_root_key); registration_pubkey_b64 is now fallible;
  AuthStore's dropped fields now include mcp_path and require_resource,
  and the dropped OAuth side grew RFC 8707/9207 and the bounded DCR store.
- Note the crates.io publishing dimension (v0.2.0, tag-guarded trusted
  publishing, docs/ excluded from the .crate) and re-stamp every stale
  file:line reference (auth.rs shifted ~500-650 lines; the 13 call-site
  list and all evidence-index anchors updated).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AqNkpMQiQHzYxC2djTfvBK
Reorganize into Summary -> Problem -> Non-goals -> Approach -> Design
components -> Implementation Stages (with the verification plan and
open decisions under the stages, and the code-evidence index as an
appendix). Content is unchanged apart from the connective text: the
same components, client matrix, security model, and file:line evidence,
now in the house design-doc structure.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AqNkpMQiQHzYxC2djTfvBK
docs/local-deployment.md is the short, stakeholder-facing overview
(same Summary -> Problem -> Non-goals -> Approach -> Design components
-> Implementation Stages shape, one altitude up: no file:line evidence
or dependency minutiae); the full doc keeps the detail and both docs
cross-link.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AqNkpMQiQHzYxC2djTfvBK
Local replicas are spawned by the ICP CLI now; drop the dfx naming from
both design docs' non-goals.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AqNkpMQiQHzYxC2djTfvBK
Talking to an ICP CLI-spawned local replica is a nice-to-have rather
than something we rule out: a test build enables it with an explicit
IC-endpoint override plus a loopback-guarded fetch_root_key, and the
integration tests run imcp2-local in exactly that configuration
(against an ICP CLI-spawned replica or PocketIC carrying a deployed II
canister). Chains are already verified against the injected agent's
root key, so the flow works on a test network. The shipped binary still
targets mainnet + production II only.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AqNkpMQiQHzYxC2djTfvBK
"The server has no listening socket" overclaimed: it is true of the MCP
tool surface (stdio), but the login handshake necessarily binds a
transient loopback listener — II navigates the browser to the callback
and fetches the #4091 well-known from the callback's origin, both of
which require a real HTTP origin (a custom URI scheme has no origin and
II offers no device-grant alternative; this is the RFC 8252 loopback
redirect). Scope the trust-boundary claim to the tool surface, note that
reaching the login listener confers nothing (static pages; /redeem is
state-correlated and only accepts a chain targeting the in-process X),
and add the rationale to the login component.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AqNkpMQiQHzYxC2djTfvBK
Make the shared components a new imcp2-core library that both binaries
build on: imcp2 (unchanged name, lib + hosted binary, OAuth AS, routers,
metrics, e2e harness) and imcp2-local depend on it, and imcp2 re-exports
core's public items so existing embedders keep compiling.

The default-on `hosted` cargo feature is demoted to a rejected
alternative: features unify additively, so any crate enabling
imcp2/hosted would switch the OAuth/axum closure on for the whole build
graph, leaving the minimal-closure claim invocation-dependent; it also
needs cfg gates scattered through the code, where the crate boundary
states the component split structurally — with the core split the
session seam needs no #[cfg] at all (AuthedSession lives in core and the
Bearer arm reads http::request::Parts). Cost accepted: imcp2-core
becomes a second published crate (family-internal, 0.x, published before
imcp2).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AqNkpMQiQHzYxC2djTfvBK
The overview's "session seam" bullet leaned on the full doc's jargon
(seam, bearer-token lookup, singleton). State the motivation instead:
every tool call acts as the user's II session, the hosted server
resolves it per request from the OAuth bearer token, the single-user
local server reads the one in-memory session from login, and the tool
implementations are identical in both.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AqNkpMQiQHzYxC2djTfvBK
State the final design plainly instead of narrating how it evolved:
drop the rejected-alternative paragraphs and every contrast with
superseded drafts ("not a cargo feature", "originally proposed",
"since this design's first draft", "the original concern is resolved"),
recast the dependency profile per crate as final state, and remove the
settled layout item from the open decisions.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AqNkpMQiQHzYxC2djTfvBK
The local-replica test configuration is covered where it belongs (the
II wiring component and the verification plan), and the mainnet +
production II targeting is already stated in the Approach, so the
non-goal bullet added nothing but a hedge.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AqNkpMQiQHzYxC2djTfvBK
The login listener is axum and the browser opens via open::that,
matching the ICP CLI's web-identity flow (icp identity link web: an
axum Router on 127.0.0.1:0 with graceful shutdown; open = "5"), so the
two tools behave the same way. Session persistence stays in-memory.
With all three choices settled, the open-decisions sections are gone.

Add component 9, end-user setup (UX): per-client steps with no JSON or
config editing — the double-click .mcpb bundle for Claude Desktop, an
Add-to-Cursor install link, one pasted command for Claude Code and
Codex, connector UIs for Perplexity (macOS) and Antigravity, an
`imcp2-local setup` command that detects installed clients and writes
their configs, and the signing/notarization packaging that makes
double-click installs work.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AqNkpMQiQHzYxC2djTfvBK
Client registrations point at a stable install path, so an upgrade is
an in-place binary swap picked up on the next server start — nothing to
re-register and no state to migrate (sessions are in-memory).
Distribution and updates follow the ICP CLI: cargo-dist installers with
the axoupdater updater shipped alongside, and channel detection that
surfaces the right upgrade command (axoupdater receipt / Homebrew /
npm). The Claude Desktop bundle upgrades on its own channel by
installing the new .mcpb version.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AqNkpMQiQHzYxC2djTfvBK
Two layers for two verifiers. OS execution gates: macOS Developer ID +
notarization (Gatekeeper is the verifier for browser-downloaded files —
including binaries spawned as MCP subprocesses — and accepts only
Apple-issued signatures; a bare signed+notarized binary passes, no
installer artifact needed) and Windows Authenticode via Azure Trusted
Signing or an EV cert. Supply-chain provenance: keyless GitHub artifact
attestations on every artifact, verifiable with gh attestation verify.
The .mcpb's substance is the signed binary inside (the format specifies
no bundle-level signing), with directory listing as the trusted
distribution path. Notes which channels bypass the gates (installer
script, Homebrew, npm), where the steps hook into the cargo-dist
pipeline, and what DFINITY must acquire with lead time.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AqNkpMQiQHzYxC2djTfvBK
Give the signing content top-level visibility as component 10 instead
of a block inside the end-user setup component, and mirror it in the
overview's component list.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AqNkpMQiQHzYxC2djTfvBK
Integration tests run imcp2-local's local-replica test configuration
against PocketIC carrying a deployed II canister (the pocket-ic dev
dependency, reusing the e2e harness's real-II setup). The endpoint
override still lets a developer point a test build at any other local
replica, but the test plan names one vehicle.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AqNkpMQiQHzYxC2djTfvBK
@aterga
aterga marked this pull request as ready for review August 24, 2026 10:43
@aterga
aterga requested review from a team and a balanced review from Copilot August 24, 2026 10:43

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Documents the proposed architecture and rollout of the local stdio-based imcp2-local binary.

Changes:

  • Adds a stakeholder-focused architecture overview.
  • Adds detailed crate, authentication, client integration, security, packaging, and testing designs.
  • Defines a three-stage implementation plan.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 3 comments.

File Description
docs/local-deployment.md High-level local deployment overview.
docs/scoping-local-deployment.md Detailed implementation design and verification plan.
Suppressed comments (1)

docs/scoping-local-deployment.md:203

  • The net dependency inventory omits axum, although lines 188–195 require it for the loopback login listener. As written, this list cannot describe the binary that the design specifies.
Net minimal local deps: `imcp2-core` +
`rmcp{server,macros,transport-io}` + `tokio` +
`anyhow` + `serde_json` + `url`/`urlencoding` + `tracing`/`tracing-subscriber` + a
browser-opener (`open` crate, or `std::process::Command`).

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread docs/scoping-local-deployment.md Resolved
Comment thread docs/local-deployment.md Resolved
Comment thread docs/scoping-local-deployment.md Resolved
The login-listener decision (axum, matching the ICP CLI) left three
stale claims behind: imcp2-local does depend on axum for its three
transient login routes, so say so in the crate-layout bullet, the net
dependency list, and the overview, scoping the absence claim to
tower-http/prometheus/OAuth and the hosted MCP transport. Also state
the AuthStore reality under the crate split: it stays intact in imcp2
(the retained OAuth routes need all of its state), the extracted
primitives carry none of it, and the local binary replaces it with a
minimal pending-connect state rather than a slimmed copy.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AqNkpMQiQHzYxC2djTfvBK
Copilot AI review requested due to automatic review settings August 24, 2026 10:48

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 2 out of 2 changed files in this pull request and generated 3 comments.

Suppressed comments (1)

docs/scoping-local-deployment.md:407

  • Singleton(String) cannot support reauthentication after the grant expires or is revoked. registration_pubkey_b64 is idempotent for a session ID (src/identities.rs:582-605), while the existing session contract requires a fresh connect/session key after Unauthorized (src/identities.rs:138-143); the inherited connect link currently requests only a 3,600-second grant (src/auth.rs:128-129). A long-lived stdio handler would therefore remain pinned to a dead ID until the AI client restarts. Make the local source replaceable and publish a fresh session ID after each successful authenticate (or explicitly rebuild the handler), and cover same-process reauthentication.
- add `session: SessionSource { Bearer, Singleton(String) }` to `IcTools` (`tools.rs:52`);
- one `current_session_id(&self, &ctx) -> Option<String>` method replacing the free fn;

Comment thread docs/scoping-local-deployment.md Resolved
Comment thread docs/scoping-local-deployment.md Resolved
Comment thread docs/local-deployment.md Resolved
- Component 6: rmcp's #[tool_router] is type-level per impl block, so
  construction mode alone cannot hide tools. Specify that the local-only
  authenticate/auth_status tools live in their own router that only the
  local binary composes, with a hosted-side tools/list exclusion test.
- Component 7: the Singleton session source holds a shared, replaceable
  slot rather than a fixed String — the II contract requires a fresh
  session key after Unauthorized, so re-login must be a same-process
  step with no client restart.
- Sessions wording (both docs): replace the misleading "re-login per
  run" with the actual lifetime — sign in again after a restart, on
  grant expiry, or on revocation; note the one-hour grant requested
  today and the user's consent-time choice of effective TTL.
- Verification: PocketIC exercises the protocol, not browser policy, so
  add a real-browser compatibility gate (automated Chromium/Firefox
  login run plus a manual Safari/enterprise-policy pass) to Stage 2's
  login exit criterion.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AqNkpMQiQHzYxC2djTfvBK
Copilot AI review requested due to automatic review settings August 24, 2026 10:58

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 2 out of 2 changed files in this pull request and generated 2 comments.

Suppressed comments (1)

docs/scoping-local-deployment.md:395

  • This router placement conflicts with the dependency partition: the authenticate methods must start the listener and browser driver owned by imcp2-local, but an impl compiled in imcp2-core cannot directly call code from its dependent crate, and core intentionally excludes axum/open. Define the missing composition seam—for example, a local wrapper handler that merges/delegates the core router, or an injected login-driver trait/callback—so the local-only tools can invoke the local implementation without reversing the dependency direction.
*(Implementation: `authenticate`/`auth_status` are **local-only** tools, and construction
mode alone would not hide them — rmcp's `#[tool_router]` generates the router per impl
block, type-level. So they live in their **own small router** (a second `#[tool_router]`
impl in `imcp2-core`) that only the local binary composes onto `IcTools`'s router; the
hosted server never composes it in, and a hosted-side test asserts its `tools/list` does

Comment thread docs/scoping-local-deployment.md Resolved
Comment thread docs/scoping-local-deployment.md Resolved
- Component 6: the login tools cannot be an impl block in imcp2-core —
  they drive the loopback listener and browser opener, which live in
  imcp2-local, and core cannot call back into its dependent crate. They
  now live on a small wrapper handler in imcp2-local that merges
  tools/list and dispatches tools/call to its own router before
  forwarding to the inner IcTools handler (RequestContext<RoleServer>
  is handler-independent). Hosted stays login-free by construction,
  with the tools/list test kept as a regression guard.
- Component 1: the crate split is not visibility-neutral — workspace
  packages share no crate-private access, so the composition API
  (IcTools/IcTools::new with SessionSource, the Identities
  registration/redeem entry points, the iiconnect builders) graduates
  from pub(crate) to core's deliberate public API, re-exported by
  imcp2; dropped the "kept verbatim" phrasing.
- Component 4/5: dropping the redeem's redirect tail breaks the
  callback page's only success arm (it treats a JSON redirect as
  success, error otherwise). Specify the second success arm: the local
  redeem answers {"done": true} and the shared page renders "Signed in
  — you can close this tab" in place; hosted keeps answering redirect.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AqNkpMQiQHzYxC2djTfvBK
Copilot AI review requested due to automatic review settings August 24, 2026 11:09

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 2 out of 2 changed files in this pull request and generated 2 comments.

Suppressed comments (1)

docs/scoping-local-deployment.md:570

  • This exit criterion cannot pass as written because #77 reports transitive tower-http in the standalone core graph via rmcp/reqwest. Make the criterion distinguish direct hosted dependencies from unavoidable transitive packages.
compiles standalone with no `axum`/`tower-http`/`prometheus`/OAuth in its graph.

Comment thread docs/scoping-local-deployment.md Resolved
Comment thread docs/scoping-local-deployment.md Resolved
- Dependency isolation: distinguish direct dependencies and linked code
  from unavoidable transitives. prometheus and the OAuth modules are
  structurally absent from imcp2-local's graph (it never depends on
  imcp2), but tower-http still appears deep in the graph as an
  rmcp/reqwest internal — the guarantee is that the hosted CORS layer
  is not a dependency, not that the crate name vanishes. Scope the
  Stage 1 exit criterion the same way.
- Versioning: an "internal" 0.x label on imcp2-core cannot exempt items
  imcp2 re-exports — those are imcp2 public API and breaking them is a
  breaking change for imcp2 embedders under imcp2's own semver. State
  that the label relaxes stability only for core-only surface, with the
  re-export set curated to the pre-split imcp2 API.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AqNkpMQiQHzYxC2djTfvBK
Copilot AI review requested due to automatic review settings August 24, 2026 11:16

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 2 out of 2 changed files in this pull request and generated no new comments.

Suppressed comments (3)

Previously missed (1) — in code that hasn't changed since the last review.

docs/scoping-local-deployment.md:109

  • The current tools.rs contains 27 #[tool] methods, so this exact count is stale. Update it to 27; the later “~26” references should also be aligned when describing the local total.
  - `tools.rs` — the **entire** MCP tool surface. The `#[tool_router]` / 26×`#[tool]` /

docs/scoping-local-deployment.md:124

  • This says the newly public core composition types are re-exported by imcp2, but lines 160–165 say the re-export set is exactly the pre-split surface and core-only seams are not re-exported. IcTools, Identities, and their composition entry points are currently crate-private, so re-exporting them would expand—not preserve—the imcp2 API. Clarify that they are public in imcp2-core but remain core-only unless already public before the split.
  - The move is not visibility-neutral: workspace packages share no crate-private access,
    so the items the binaries compose against — `IcTools`/`IcTools::new` with
    `SessionSource`, the `Identities` registration/redeem entry points above, the
    `iiconnect` builders — graduate from `pub(crate)` behind one binary to core's
    deliberate **public API** (`imcp2` re-exports it below, so embedders see no change).

docs/scoping-local-deployment.md:591

  • The stage plan defers the PocketIC integration tests to Stage 3, even though Stage 2 exits only after login works and the Verification section (lines 616–624) explicitly makes both the PocketIC flow and real-browser compatibility a Stage 2 gate. Move those verification deliverables into Stage 2 so the stage cannot be declared complete before its login path is validated.
wallet-grade trust note; integration tests via the local-replica test configuration
(component 3), extending the PocketIC e2e harness to the local login flow (see
Verification); optional keychain-backed session persistence.

@aterga
aterga requested a review from sea-snake August 24, 2026 13:16
@aterga
aterga merged commit d142a49 into main Aug 25, 2026
8 checks passed
aterga pushed a commit that referenced this pull request Aug 25, 2026
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
aterga pushed a commit that referenced this pull request Aug 25, 2026
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
aterga added a commit that referenced this pull request Aug 27, 2026
#150)

* Stage 2: imcp2-local, the stdio binary with built-in II browser login

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

* Address review: structured login-tool outputs, status precedence, stale-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

* Address review: serialize registrations instead of guarding the commit

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

* Address review: Host-validate the login listener (anti-DNS-rebinding)

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

* Address review: readable variable names in the callback page script

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

* Address review: authentication moves out of core behind a SessionResolver

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

* Address review: split the login module, name the contact const, detach 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

* Refine the design's tool/session seam: authentication injected, core 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

* Address review: session reaper + the PocketIC login e2e moves into this 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

* Address review: the session slot is expiry-aware

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
aterga added a commit that referenced this pull request Aug 27, 2026
…eline (#152)

* Stage 2: imcp2-local, the stdio binary with built-in II browser login

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

* Address review: structured login-tool outputs, status precedence, stale-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

* Stage 3: end-user setup, PocketIC login e2e, and the dist release pipeline

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

* Address review: serialize registrations instead of guarding the commit

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

* Address review: Host-validate the login listener (anti-DNS-rebinding)

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

* Address review: readable variable names in the callback page script

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

* Address review: authentication moves out of core behind a SessionResolver

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

* Adapt the e2e to SessionSlot's new home in the login module

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AqNkpMQiQHzYxC2djTfvBK

* Address review: split the login module, name the contact const, detach 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

* Refine the design's tool/session seam: authentication injected, core 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

* Address review: session reaper + the PocketIC login e2e moves into this 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

* Repair the previous merge: dedupe the e2e module declaration, valid lockfile

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

* Address review: the session slot is expiry-aware

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

* Address review: state the real rationale for Codex's file-edit path

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

* rustfmt: wrap the slot-commit line, drop a stray blank line

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AqNkpMQiQHzYxC2djTfvBK

* Address review: honest exit code, mode-aware guidance, escaped snippets, merge-upsert, attest pin

- setup exits nonzero when a detected client's registration fails, so
  scripts see partial failure (the report still covers every client).
- --remove no longer shows 'add' instructions in its fallbacks: the
  claude-CLI fallback prints the remove one-liner and Perplexity gets
  removal steps.
- Pasted snippets escape the binary path per target format (JSON, TOML,
  quoted shell one-liner) — Windows paths and spaces stay valid; test
  parses the emitted snippets with the real parsers.
- Registration upserts merge instead of replace: only `command` is
  owned, so user-added keys (e.g. env) survive re-runs; removal stays
  name-keyed like the vendor CLIs, and the module doc now says so.
- actions/attest-build-provenance pinned to the v3.2.0 commit via
  [dist.github-action-commits] and the workflow regenerated with dist,
  so regeneration preserves the pin.
- README narrows the attestation claim to the platform archives and
  says the installers/checksums carry none.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AqNkpMQiQHzYxC2djTfvBK

* Address review round 2: safer quoting, stricter remove, backups on removal, 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

* Address review round 3: atomic config writes, torn-backup guard, UTF-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

* Address review round 4: keep config permissions, merge inline Codex entries, .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

* Address review round 5: symlinked configs keep their link, conflicting 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

* Codex setup goes vendor-CLI-first with a file fallback

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

* Address review round 6: refuse dangling symlinked configs, honest absent-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

---------

Co-authored-by: Claude <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants