From 05438d950d2e359396f49c52bf74ff037a1a7a3f Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 23 Jul 2026 16:29:58 +0000 Subject: [PATCH 01/23] Add scoping doc for a local-deployment MCP instance 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 Claude-Session: https://claude.ai/code/session_01AqNkpMQiQHzYxC2djTfvBK --- docs/scoping-local-deployment.md | 339 +++++++++++++++++++++++++++++++ 1 file changed, 339 insertions(+) create mode 100644 docs/scoping-local-deployment.md diff --git a/docs/scoping-local-deployment.md b/docs/scoping-local-deployment.md new file mode 100644 index 0000000..2d7812c --- /dev/null +++ b/docs/scoping-local-deployment.md @@ -0,0 +1,339 @@ +# Scoping: a secondary MCP server instance for local deployments + +Status: **draft / scoping** — no code changes proposed here yet, only the +analysis and the design that an implementation PR would follow. + +## 1. Goal + +Today IMCP2 bridges an LLM to **mainnet**. This document scopes a second server +instance that bridges the same tools to a **local `dfx` replica** — so a +developer building canisters/apps locally can point an MCP client (Claude, +ChatGPT, Grok, …) at their own machine and use `open_app`, `canister_query`, +`canister_update_call`, canister management, and per-app Internet Identity +accounts against `http://localhost:4943` instead of `https://icp-api.io`. + +The headline finding: **the server is already factored to make this mostly a +composition change, but four mainnet assumptions are hard-coded and would each +break a local instance.** The bulk of the work is (a) an agent bootstrap that +fetches the local root key, (b) a strictly-scoped relaxation of the discovery +SSRF/https guards, (c) local-aware origin canonicalization, and (d) a local +canister-creation path. Each is small; the care is in making sure none of them +can ever weaken the mainnet instances. + +## 2. How the server is composed today + +The crate is both a library and a binary (`Cargo.toml`, `src/lib.rs`, +`src/main.rs`). + +- **`McpServer`** (`src/lib.rs`) is one server instance built from an + **`McpConfig`**: an injected `ic-agent::Agent`, an **`IiInstance`** (which + Internet Identity to log users in against), the public URL, the mount path, + and a shared dynamic-client-registration store. +- **`main.rs`** composes **two** instances into one process: + `/mcp` → **beta** II and `/mcp-prod` → **prod** II. Both are handed the + **same** agent, built once as + `Agent::builder().with_url(IC_URL).build()` where + `IC_URL = "https://icp-api.io"` (`src/lib.rs:100`, `src/main.rs:85`). +- **`IiInstance`** (`src/identities.rs:106`) carries only *which II* — an origin + and a canister id. `beta()` → `https://beta.id.ai` / + `fgte5-ciaaa-aaaad-aaatq-cai`; `prod()` → `https://id.ai` / + `rdmx6-jaaaa-aaaaa-aaadq-cai`. Both are already env-overridable + (`II_URL`/`II_CANISTER_ID`, `II_URL_PROD`/`II_CANISTER_ID_PROD`). +- **Tools** (`src/tools.rs`) sign anonymous calls with the injected agent and + mint per-app account delegations on demand from the connection's registered + session key (`src/identities.rs`). Every identity-bearing agent is + `self.agent.clone()` with the identity swapped + (`Identities::agent_as`, `src/identities.rs:457`), so **whatever endpoint the + injected agent points at is the endpoint every tool call rides** — anonymous + reads, II delegation calls, and canister management alike. + +### The key insight: two different axes + +The existing beta/prod split varies along exactly one axis — **which mainnet +Internet Identity**. Both instances still talk to the **same replica** +(mainnet) through the **same agent**. + +"Local deployment" varies along a *different* axis — **which replica**. That +difference is what cascades into the hard-coded assumptions below, because the +agent endpoint, the root key, the app origins, and the system canisters all +change together when you move off mainnet. A local instance is therefore not +"beta/prod plus a third II"; it is "a different agent + a local II + relaxed +guards," which is why it deserves its own scoping rather than another +`IiInstance::…()` constructor. + +## 3. Gap analysis — what breaks against a local replica + +Each item below is a concrete mainnet assumption, where it lives, why it breaks +locally, and what a local instance needs instead. + +### 3.1 The agent never fetches the local root key — **hard blocker** + +`main.rs` builds the agent against `IC_URL` and **never calls +`agent.fetch_root_key()`** (confirmed: no occurrence anywhere in `src/`). +`ic-agent` verifies every response certificate against the mainnet IC root key +baked into the crate. A local `dfx` replica has a **different** root key, so +*every* call — `read_state_canister_metadata` in `get_canister_candid` +(`src/tools.rs:85`), `canister_query`, `canister_update_call`, and the II +delegation calls `mcp_register_v2` / `mcp_get_accounts` / +`mcp_prepare_delegation` / `mcp_get_delegation` (`src/identities.rs`) — fails +certificate verification. + +The server does **not** verify II's delegation signatures itself; it hands the +chain to the replica and lets the replica verify (`src/auth.rs:1198`+, "the +replica verifies every hop authoritatively"). So the root key matters purely at +the `ic-agent` layer, and `fetch_root_key()` on the local agent fixes the whole +surface at once. + +**Needed:** a local agent built against the local replica URL that calls +`fetch_root_key().await` at startup. This is **insecure against mainnet by +design** (it trusts whatever key the endpoint returns), so it must be gated so +it can only ever run for the local instance (see §5). + +### 3.2 `IC_URL` is not overridable — the local endpoint has nowhere to come from + +`IC_URL` is a `const` and the only agent is built from it (`src/lib.rs:100`, +`src/main.rs:85`). II origin/canister are env-overridable but the **replica +endpoint** is not. A local instance needs its agent pointed at e.g. +`http://localhost:4943`. + +**Needed:** a config knob (env var, e.g. `IC_URL_LOCAL`/`LOCAL_REPLICA_URL`) for +the local agent's endpoint, defaulting to the `dfx` default +`http://localhost:4943`. + +### 3.3 Discovery refuses loopback and non-https — **blocks `open_app` locally** + +`src/discover.rs` runs a deliberate SSRF guard (CWE-918): `resolve_public_url` +(`:958`) rejects any host that resolves to a non-global IP via `ip_is_global` +(`:915`) — loopback/private/CGNAT/etc. — **and** rejects any non-`https` +scheme. A local app is served at `http://.localhost:4943`, which is +both loopback and http, so `open_app`, `resolve_app`, and +`discover_app_canisters` all refuse it before any request. `redirect_hop_ok` +(`:997`) and `fetch_alternative_origins` (`:642`) share the same guard. + +**Needed (design choice, see §4.3):** for the local instance only, a discovery +mode that permits `http` + loopback for a configured local host, while the +mainnet instances keep the guard fully intact. The relaxation must be a +per-instance capability, never a global toggle. + +### 3.4 `target_origin` forces https and only remaps mainnet gateways + +`target_origin` (`src/identities.rs:193`) computes the II derivation origin: it +strips the scheme and always re-emits `https://`, and it remaps +`*.icp0.io` / `*.icp.net` → `*.ic0.app`. Local origins are +`http://.localhost:4943`; forcing `https` and dropping the port would +derive the wrong II principal (or none). The per-app account delegation is keyed +on this origin (`derive_app_delegation`, `src/identities.rs:974`), so a wrong +origin means wrong/absent accounts. + +**Needed:** local-aware canonicalization that preserves `http://…localhost:port` +for the local instance. Scope carefully — this feeds the identity the user acts +as, so it must match exactly what the local II derives against. + +### 3.5 Canister management hard-codes mainnet system canisters + +`src/management.rs:40-45` pins the mainnet **cycles ledger** +(`um5iw-rqaaa-aaaaq-qaaba-cai`), **CMC** (`rkp4c-7iaaa-aaaaa-aaaca-cai`), and +**ICP ledger** (`ryjl3-tyaaa-aaaaa-aaaba-cai`). A bare local replica has none of +these, so `icp_create_canister` (both the cycles-ledger and the ICP→CMC funding +paths), `icp_top_up_canister`, and `icp_cycles_balance` cannot work locally +unless the developer ran `dfx nns install`. + +The plain lifecycle calls to the management canister `aaaaa-aa` +(`Principal::management_canister()`, `src/management.rs:555`) — status, start, +stop, install, delete — work fine locally. Only **creation and funding** are +mainnet-shaped: locally, canisters are created with the management canister's +`provisional_create_canister_with_cycles` (free cycles, local/testnet only). + +**Needed:** for the local instance, a creation path via +`provisional_create_canister_with_cycles`, and either hide or clearly degrade +the ICP/CMC/cycles-ledger tools (they only apply if a local NNS is installed). +Lowest-priority gap — reads/writes/discovery are the primary local use case; can +be a later phase. + +### 3.6 Operational prerequisite — a local II with the MCP feature set + +The connect handshake redirects the browser to `{ii_url}/mcp#…` +(`ii_mcp_url`, `src/auth.rs:750`) and the delegation methods target the II +canister. A **local** II must be a build that carries the merged MCP contract: +`mcp_register_v2`, `mcp_get_accounts`, `mcp_prepare_delegation`, +`mcp_get_delegation` (II #4086), the `/mcp` connect page and #4093 chain JSON, +and the #4091 callback allow-list fetch. Stock II may not have these (the README +roadmap still lists the live round-trip as pending, `README.md:836`). + +Two sub-points to verify against the local II build: +- **Callback allow-list over http.** II fetches + `/.well-known/ii-auth-callbacks` and requires an exact match + (`src/auth.rs:780`+). Locally the MCP origin is `http://localhost:8000`; + confirm the local II will fetch it over http/loopback. +- **Cookie `Secure` flag.** The connect cookie is `Secure` only when + `public_url` is https; `normalize_public_url` (`src/lib.rs:468`) already + preserves a local `http://localhost` origin, so a local run correctly omits + `Secure`. No change expected — just noted. + +This is an environment prerequisite, not server code, but the design must +document how to obtain/deploy such an II locally (e.g. `dfx deploy` an MCP-enabled +II WASM) or the instance is untestable end-to-end. + +### 3.7 Non-blockers (degrade gracefully) + +- **Dashboard enrichment** in discovery adds human names/kinds from the mainnet + IC dashboard. Local canister ids won't be found; it fails soft (labels stay + null). No change needed. +- **Known-app registry** (`KNOWN_DERIVATION_ORIGINS`, `KNOWN_APPS` in + `discover.rs`) is mainnet apps; irrelevant locally but harmless — a local app + is resolved as a URL, not a known name. +- **DNS-rebinding `allowed_hosts`** already includes loopback + (`allowed_hosts_for`, `src/lib.rs:385`), so a locally-bound MCP server accepts + its own `Host` header. + +## 4. Proposed design + +### 4.1 Shape: a local *run profile* of the same binary (recommended) + +Run the **same** `imcp2` binary in a "local" configuration on the developer's +machine, next to their `dfx` replica. Concretely, `main.rs` gains a branch +(selected by env, e.g. `IMCP2_MODE=local` or presence of `LOCAL_REPLICA_URL`) +that, instead of composing beta+prod, composes a single **local** instance: + +- an agent built against the local replica URL, with `fetch_root_key()` called; +- `IiInstance::local()` (new) from `II_URL_LOCAL` / `II_CANISTER_ID_LOCAL`; +- the local discovery capability enabled (see §4.3); +- mounted at `/mcp` (a lone instance can own the root well-known docs, per the + existing `root_well_known_router` contract in `src/lib.rs:312`). + +**Why a profile, not a third bundled instance in the hosted binary:** the hosted +server can't reach a developer's `localhost` replica, and — more importantly — +enabling `fetch_root_key()` and the SSRF relaxation inside the *hosted* process +would be a security regression for the mainnet instances sharing it. Keeping +"local" a separate run keeps those capabilities off the mainnet deployment +entirely. + +**Alternatives considered:** +- *Third bundled instance `/mcp-local` in the same process as beta/prod.* + Rejected: forces the dangerous capabilities into the hosted binary; a hosted + box still can't see the developer's replica. +- *Separate `imcp2-local` binary / cargo feature.* Viable and arguably the + safest (the mainnet build literally cannot contain `fetch_root_key`/relaxed + discovery if they're behind `#[cfg(feature = "local")]`). Slightly more build + plumbing. Worth deciding at implementation time; the library changes below are + the same either way. **This is the main open decision (see §7).** + +### 4.2 Config surface + +New env vars, all read only on the local path: + +| Var | Purpose | Default | +|---|---|---| +| `IMCP2_MODE` (or a `--local` flag) | select the local profile | unset → mainnet beta+prod (unchanged) | +| `LOCAL_REPLICA_URL` | local agent endpoint | `http://localhost:4943` | +| `II_URL_LOCAL` | local II origin | `http://.localhost:4943` | +| `II_CANISTER_ID_LOCAL` | local II canister id | dfx-assigned (no default) | + +`PUBLIC_URL` (existing) is the local MCP origin, e.g. +`http://localhost:8000` (already the default). + +### 4.3 Library changes (small, additive) + +1. **Agent bootstrap.** A helper (in `main.rs`, or a `local_agent()` in the lib) + that builds the agent against `LOCAL_REPLICA_URL` and awaits + `fetch_root_key()`. Guard: refuse to fetch the root key unless the URL is a + loopback/local host, so it can never run against a real endpoint even if + mis-configured. +2. **`IiInstance::local()`** — mirror `beta()`/`prod()` reading the `_LOCAL` + vars (`src/identities.rs`). +3. **Local-aware discovery.** Thread a "local host allowed" capability into + `McpConfig`/`Identities`/discovery so `resolve_public_url` and + `redirect_hop_ok` permit `http` + the configured loopback host **for the + local instance only**. Options, cheapest first: + - **(a) Bypass discovery locally.** Simplest and safest: on the local + instance, developers pass canister ids directly to + `get_canister_candid` / `canister_query` (they know their own ids from + `dfx`). `open_app`-style discovery of a localhost URL is disabled with a + clear message. Ships value immediately with **zero** change to the SSRF + guard. + - **(b) Scoped relaxation.** Add a per-call/per-instance flag that lets the + guard accept the one configured local origin (still pinning + everything else). More faithful to the mainnet UX; more surface to review. + Recommend shipping **(a)** first, then **(b)** if local `open_app` is wanted. +4. **Local-aware `target_origin`.** Preserve `http://host:port` for the local + instance's derivation origins (§3.4). +5. **Local canister creation** (later phase): a + `provisional_create_canister_with_cycles` path in `management.rs`, selected + for the local instance. + +### 4.4 What stays identical + +The OAuth 2.1 AS, the registration-delegation connect handshake, session/token +model, the per-app on-demand delegation machinery, the tool schemas, and the +streamable-HTTP transport are all reused verbatim. The local instance is the +same product pointed at a different replica + II. + +## 5. Security guardrails (non-negotiable) + +- **`fetch_root_key()` must be unreachable from the mainnet instances.** Behind + a cargo feature or a startup branch that only the local profile takes, plus a + runtime assertion that the target URL is loopback. This is the single most + important invariant — a mainnet agent that trusts a fetched root key is fully + spoofable. +- **The SSRF/http relaxation must be per-instance, never global.** The mainnet + discovery path keeps `ip_is_global` + https-only exactly as-is. Any relaxation + is carried as an explicit capability on the local instance and defaults off. +- **No cross-contamination in one process.** Because the recommended shape runs + local as its *own* process, the hosted binary never links the relaxed paths at + runtime; a cargo feature makes that a compile-time guarantee. +- Preserve existing hardening (body-size caps, redirect limits, callback + allow-list) unchanged. + +## 6. Work breakdown + +**Phase 1 — reads/writes against a local replica (core value)** +1. `LOCAL_REPLICA_URL` + local agent with guarded `fetch_root_key()`. +2. `IiInstance::local()` + `_LOCAL` env vars. +3. Local profile branch in `main.rs` composing the single local instance. +4. Discovery bypass locally (§4.3 option a) with a clear "pass canister ids + directly" message. +5. Local-aware `target_origin`. +6. Docs: how to deploy an MCP-enabled II locally and connect a client. + +*Exit:* `get_canister_candid`, `canister_query`, `canister_update_call`, and +per-app accounts work against a local replica + local II. + +**Phase 2 — local discovery (optional UX parity)** +7. Scoped SSRF/http relaxation (§4.3 option b) so `open_app`/`resolve_app` + resolve a localhost app. + +**Phase 3 — local canister management** +8. `provisional_create_canister_with_cycles` creation path; hide/degrade the + ICP/CMC/cycles-ledger tools unless a local NNS is present. + +## 7. Open questions / decisions for the user + +1. **Packaging:** separate `imcp2-local` binary or cargo `feature = "local"`, + vs. a runtime `IMCP2_MODE=local` branch in the one binary? (Recommendation: a + cargo feature — strongest compile-time guarantee that mainnet can't fetch a + root key or relax SSRF.) +2. **Local discovery:** ship the bypass (Phase 1) only, or is `open_app` against + a localhost app in scope (Phase 2)? +3. **Local II:** is there a canonical MCP-enabled II WASM/`dfx` recipe the docs + should point to, or should the scope include producing one? +4. **Canister management locally:** in scope now (Phase 3) or deferred? + +## 8. Out of scope + +- Deploying/packaging a local II (an environment prerequisite; the scope only + documents it). +- Persisting sessions (already a general roadmap item, `README.md:839`). +- Any change to the mainnet beta/prod instances' behavior. + +## 9. Evidence index (file references) + +- Agent build, no root-key fetch: `src/main.rs:85`, `src/lib.rs:100`. +- Per-instance agent injection: `src/lib.rs:103` (`McpConfig`), agent cloning + `src/identities.rs:457`. +- II instances: `src/identities.rs:106-133`. +- `target_origin`: `src/identities.rs:193`. +- Discovery SSRF/https guard: `src/discover.rs:915`, `:958`, `:997`, `:642`. +- Management system canisters + provisional gap: `src/management.rs:40-45`, + `:255`, `:555`. +- Connect handshake + callback allow-list: `src/auth.rs:750`, `:780`. +- `normalize_public_url` (Secure-cookie/local origin): `src/lib.rs:468`. From 343f2c4b8e3fcbf3914152c0402cdd7659d1256f Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 23 Jul 2026 17:08:39 +0000 Subject: [PATCH 02/23] Rescope: minimal stdio local binary, no OAuth, prod II MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01AqNkpMQiQHzYxC2djTfvBK --- docs/scoping-local-deployment.md | 603 ++++++++++++++----------------- 1 file changed, 265 insertions(+), 338 deletions(-) diff --git a/docs/scoping-local-deployment.md b/docs/scoping-local-deployment.md index 2d7812c..5a43906 100644 --- a/docs/scoping-local-deployment.md +++ b/docs/scoping-local-deployment.md @@ -1,339 +1,266 @@ -# Scoping: a secondary MCP server instance for local deployments - -Status: **draft / scoping** — no code changes proposed here yet, only the -analysis and the design that an implementation PR would follow. - -## 1. Goal - -Today IMCP2 bridges an LLM to **mainnet**. This document scopes a second server -instance that bridges the same tools to a **local `dfx` replica** — so a -developer building canisters/apps locally can point an MCP client (Claude, -ChatGPT, Grok, …) at their own machine and use `open_app`, `canister_query`, -`canister_update_call`, canister management, and per-app Internet Identity -accounts against `http://localhost:4943` instead of `https://icp-api.io`. - -The headline finding: **the server is already factored to make this mostly a -composition change, but four mainnet assumptions are hard-coded and would each -break a local instance.** The bulk of the work is (a) an agent bootstrap that -fetches the local root key, (b) a strictly-scoped relaxation of the discovery -SSRF/https guards, (c) local-aware origin canonicalization, and (d) a local -canister-creation path. Each is small; the care is in making sure none of them -can ever weaken the mainnet instances. - -## 2. How the server is composed today - -The crate is both a library and a binary (`Cargo.toml`, `src/lib.rs`, -`src/main.rs`). - -- **`McpServer`** (`src/lib.rs`) is one server instance built from an - **`McpConfig`**: an injected `ic-agent::Agent`, an **`IiInstance`** (which - Internet Identity to log users in against), the public URL, the mount path, - and a shared dynamic-client-registration store. -- **`main.rs`** composes **two** instances into one process: - `/mcp` → **beta** II and `/mcp-prod` → **prod** II. Both are handed the - **same** agent, built once as - `Agent::builder().with_url(IC_URL).build()` where - `IC_URL = "https://icp-api.io"` (`src/lib.rs:100`, `src/main.rs:85`). -- **`IiInstance`** (`src/identities.rs:106`) carries only *which II* — an origin - and a canister id. `beta()` → `https://beta.id.ai` / - `fgte5-ciaaa-aaaad-aaatq-cai`; `prod()` → `https://id.ai` / - `rdmx6-jaaaa-aaaaa-aaadq-cai`. Both are already env-overridable - (`II_URL`/`II_CANISTER_ID`, `II_URL_PROD`/`II_CANISTER_ID_PROD`). -- **Tools** (`src/tools.rs`) sign anonymous calls with the injected agent and - mint per-app account delegations on demand from the connection's registered - session key (`src/identities.rs`). Every identity-bearing agent is - `self.agent.clone()` with the identity swapped - (`Identities::agent_as`, `src/identities.rs:457`), so **whatever endpoint the - injected agent points at is the endpoint every tool call rides** — anonymous - reads, II delegation calls, and canister management alike. - -### The key insight: two different axes - -The existing beta/prod split varies along exactly one axis — **which mainnet -Internet Identity**. Both instances still talk to the **same replica** -(mainnet) through the **same agent**. - -"Local deployment" varies along a *different* axis — **which replica**. That -difference is what cascades into the hard-coded assumptions below, because the -agent endpoint, the root key, the app origins, and the system canisters all -change together when you move off mainnet. A local instance is therefore not -"beta/prod plus a third II"; it is "a different agent + a local II + relaxed -guards," which is why it deserves its own scoping rather than another -`IiInstance::…()` constructor. - -## 3. Gap analysis — what breaks against a local replica - -Each item below is a concrete mainnet assumption, where it lives, why it breaks -locally, and what a local instance needs instead. - -### 3.1 The agent never fetches the local root key — **hard blocker** - -`main.rs` builds the agent against `IC_URL` and **never calls -`agent.fetch_root_key()`** (confirmed: no occurrence anywhere in `src/`). -`ic-agent` verifies every response certificate against the mainnet IC root key -baked into the crate. A local `dfx` replica has a **different** root key, so -*every* call — `read_state_canister_metadata` in `get_canister_candid` -(`src/tools.rs:85`), `canister_query`, `canister_update_call`, and the II -delegation calls `mcp_register_v2` / `mcp_get_accounts` / -`mcp_prepare_delegation` / `mcp_get_delegation` (`src/identities.rs`) — fails -certificate verification. - -The server does **not** verify II's delegation signatures itself; it hands the -chain to the replica and lets the replica verify (`src/auth.rs:1198`+, "the -replica verifies every hop authoritatively"). So the root key matters purely at -the `ic-agent` layer, and `fetch_root_key()` on the local agent fixes the whole -surface at once. - -**Needed:** a local agent built against the local replica URL that calls -`fetch_root_key().await` at startup. This is **insecure against mainnet by -design** (it trusts whatever key the endpoint returns), so it must be gated so -it can only ever run for the local instance (see §5). - -### 3.2 `IC_URL` is not overridable — the local endpoint has nowhere to come from - -`IC_URL` is a `const` and the only agent is built from it (`src/lib.rs:100`, -`src/main.rs:85`). II origin/canister are env-overridable but the **replica -endpoint** is not. A local instance needs its agent pointed at e.g. -`http://localhost:4943`. - -**Needed:** a config knob (env var, e.g. `IC_URL_LOCAL`/`LOCAL_REPLICA_URL`) for -the local agent's endpoint, defaulting to the `dfx` default -`http://localhost:4943`. - -### 3.3 Discovery refuses loopback and non-https — **blocks `open_app` locally** - -`src/discover.rs` runs a deliberate SSRF guard (CWE-918): `resolve_public_url` -(`:958`) rejects any host that resolves to a non-global IP via `ip_is_global` -(`:915`) — loopback/private/CGNAT/etc. — **and** rejects any non-`https` -scheme. A local app is served at `http://.localhost:4943`, which is -both loopback and http, so `open_app`, `resolve_app`, and -`discover_app_canisters` all refuse it before any request. `redirect_hop_ok` -(`:997`) and `fetch_alternative_origins` (`:642`) share the same guard. - -**Needed (design choice, see §4.3):** for the local instance only, a discovery -mode that permits `http` + loopback for a configured local host, while the -mainnet instances keep the guard fully intact. The relaxation must be a -per-instance capability, never a global toggle. - -### 3.4 `target_origin` forces https and only remaps mainnet gateways - -`target_origin` (`src/identities.rs:193`) computes the II derivation origin: it -strips the scheme and always re-emits `https://`, and it remaps -`*.icp0.io` / `*.icp.net` → `*.ic0.app`. Local origins are -`http://.localhost:4943`; forcing `https` and dropping the port would -derive the wrong II principal (or none). The per-app account delegation is keyed -on this origin (`derive_app_delegation`, `src/identities.rs:974`), so a wrong -origin means wrong/absent accounts. - -**Needed:** local-aware canonicalization that preserves `http://…localhost:port` -for the local instance. Scope carefully — this feeds the identity the user acts -as, so it must match exactly what the local II derives against. - -### 3.5 Canister management hard-codes mainnet system canisters - -`src/management.rs:40-45` pins the mainnet **cycles ledger** -(`um5iw-rqaaa-aaaaq-qaaba-cai`), **CMC** (`rkp4c-7iaaa-aaaaa-aaaca-cai`), and -**ICP ledger** (`ryjl3-tyaaa-aaaaa-aaaba-cai`). A bare local replica has none of -these, so `icp_create_canister` (both the cycles-ledger and the ICP→CMC funding -paths), `icp_top_up_canister`, and `icp_cycles_balance` cannot work locally -unless the developer ran `dfx nns install`. - -The plain lifecycle calls to the management canister `aaaaa-aa` -(`Principal::management_canister()`, `src/management.rs:555`) — status, start, -stop, install, delete — work fine locally. Only **creation and funding** are -mainnet-shaped: locally, canisters are created with the management canister's -`provisional_create_canister_with_cycles` (free cycles, local/testnet only). - -**Needed:** for the local instance, a creation path via -`provisional_create_canister_with_cycles`, and either hide or clearly degrade -the ICP/CMC/cycles-ledger tools (they only apply if a local NNS is installed). -Lowest-priority gap — reads/writes/discovery are the primary local use case; can -be a later phase. - -### 3.6 Operational prerequisite — a local II with the MCP feature set - -The connect handshake redirects the browser to `{ii_url}/mcp#…` -(`ii_mcp_url`, `src/auth.rs:750`) and the delegation methods target the II -canister. A **local** II must be a build that carries the merged MCP contract: -`mcp_register_v2`, `mcp_get_accounts`, `mcp_prepare_delegation`, -`mcp_get_delegation` (II #4086), the `/mcp` connect page and #4093 chain JSON, -and the #4091 callback allow-list fetch. Stock II may not have these (the README -roadmap still lists the live round-trip as pending, `README.md:836`). - -Two sub-points to verify against the local II build: -- **Callback allow-list over http.** II fetches - `/.well-known/ii-auth-callbacks` and requires an exact match - (`src/auth.rs:780`+). Locally the MCP origin is `http://localhost:8000`; - confirm the local II will fetch it over http/loopback. -- **Cookie `Secure` flag.** The connect cookie is `Secure` only when - `public_url` is https; `normalize_public_url` (`src/lib.rs:468`) already - preserves a local `http://localhost` origin, so a local run correctly omits - `Secure`. No change expected — just noted. - -This is an environment prerequisite, not server code, but the design must -document how to obtain/deploy such an II locally (e.g. `dfx deploy` an MCP-enabled -II WASM) or the instance is untestable end-to-end. - -### 3.7 Non-blockers (degrade gracefully) - -- **Dashboard enrichment** in discovery adds human names/kinds from the mainnet - IC dashboard. Local canister ids won't be found; it fails soft (labels stay - null). No change needed. -- **Known-app registry** (`KNOWN_DERIVATION_ORIGINS`, `KNOWN_APPS` in - `discover.rs`) is mainnet apps; irrelevant locally but harmless — a local app - is resolved as a URL, not a known name. -- **DNS-rebinding `allowed_hosts`** already includes loopback - (`allowed_hosts_for`, `src/lib.rs:385`), so a locally-bound MCP server accepts - its own `Host` header. - -## 4. Proposed design - -### 4.1 Shape: a local *run profile* of the same binary (recommended) - -Run the **same** `imcp2` binary in a "local" configuration on the developer's -machine, next to their `dfx` replica. Concretely, `main.rs` gains a branch -(selected by env, e.g. `IMCP2_MODE=local` or presence of `LOCAL_REPLICA_URL`) -that, instead of composing beta+prod, composes a single **local** instance: - -- an agent built against the local replica URL, with `fetch_root_key()` called; -- `IiInstance::local()` (new) from `II_URL_LOCAL` / `II_CANISTER_ID_LOCAL`; -- the local discovery capability enabled (see §4.3); -- mounted at `/mcp` (a lone instance can own the root well-known docs, per the - existing `root_well_known_router` contract in `src/lib.rs:312`). - -**Why a profile, not a third bundled instance in the hosted binary:** the hosted -server can't reach a developer's `localhost` replica, and — more importantly — -enabling `fetch_root_key()` and the SSRF relaxation inside the *hosted* process -would be a security regression for the mainnet instances sharing it. Keeping -"local" a separate run keeps those capabilities off the mainnet deployment -entirely. - -**Alternatives considered:** -- *Third bundled instance `/mcp-local` in the same process as beta/prod.* - Rejected: forces the dangerous capabilities into the hosted binary; a hosted - box still can't see the developer's replica. -- *Separate `imcp2-local` binary / cargo feature.* Viable and arguably the - safest (the mainnet build literally cannot contain `fetch_root_key`/relaxed - discovery if they're behind `#[cfg(feature = "local")]`). Slightly more build - plumbing. Worth deciding at implementation time; the library changes below are - the same either way. **This is the main open decision (see §7).** - -### 4.2 Config surface - -New env vars, all read only on the local path: - -| Var | Purpose | Default | +# Scoping: a minimal local MCP server binary (stdio, no OAuth) + +Status: **draft / scoping** — analysis and design only, no code changes here. + +## 1. What "local deployment" means + +A second, **separate binary** that a user runs **on their own machine** (run-it-yourself), +reached by a co-located MCP client (Claude Desktop, a local Claude Code, …). It still +talks to **mainnet IC** (`https://icp-api.io`) and **production Internet Identity** +(`https://id.ai`, `rdmx6-jaaaa-aaaaa-aaadq-cai`) — it is **not** a local `dfx` replica. + +The distinction from the hosted server is the **deployment axis**, not the network: + +| | Hosted `imcp2` (today) | Local binary (this scope) | |---|---|---| -| `IMCP2_MODE` (or a `--local` flag) | select the local profile | unset → mainnet beta+prod (unchanged) | -| `LOCAL_REPLICA_URL` | local agent endpoint | `http://localhost:4943` | -| `II_URL_LOCAL` | local II origin | `http://.localhost:4943` | -| `II_CANISTER_ID_LOCAL` | local II canister id | dfx-assigned (no default) | - -`PUBLIC_URL` (existing) is the local MCP origin, e.g. -`http://localhost:8000` (already the default). - -### 4.3 Library changes (small, additive) - -1. **Agent bootstrap.** A helper (in `main.rs`, or a `local_agent()` in the lib) - that builds the agent against `LOCAL_REPLICA_URL` and awaits - `fetch_root_key()`. Guard: refuse to fetch the root key unless the URL is a - loopback/local host, so it can never run against a real endpoint even if - mis-configured. -2. **`IiInstance::local()`** — mirror `beta()`/`prod()` reading the `_LOCAL` - vars (`src/identities.rs`). -3. **Local-aware discovery.** Thread a "local host allowed" capability into - `McpConfig`/`Identities`/discovery so `resolve_public_url` and - `redirect_hop_ok` permit `http` + the configured loopback host **for the - local instance only**. Options, cheapest first: - - **(a) Bypass discovery locally.** Simplest and safest: on the local - instance, developers pass canister ids directly to - `get_canister_candid` / `canister_query` (they know their own ids from - `dfx`). `open_app`-style discovery of a localhost URL is disabled with a - clear message. Ships value immediately with **zero** change to the SSRF - guard. - - **(b) Scoped relaxation.** Add a per-call/per-instance flag that lets the - guard accept the one configured local origin (still pinning - everything else). More faithful to the mainnet UX; more surface to review. - Recommend shipping **(a)** first, then **(b)** if local `open_app` is wanted. -4. **Local-aware `target_origin`.** Preserve `http://host:port` for the local - instance's derivation origins (§3.4). -5. **Local canister creation** (later phase): a - `provisional_create_canister_with_cycles` path in `management.rs`, selected - for the local instance. - -### 4.4 What stays identical - -The OAuth 2.1 AS, the registration-delegation connect handshake, session/token -model, the per-app on-demand delegation machinery, the tool schemas, and the -streamable-HTTP transport are all reused verbatim. The local instance is the -same product pointed at a different replica + II. - -## 5. Security guardrails (non-negotiable) - -- **`fetch_root_key()` must be unreachable from the mainnet instances.** Behind - a cargo feature or a startup branch that only the local profile takes, plus a - runtime assertion that the target URL is loopback. This is the single most - important invariant — a mainnet agent that trusts a fetched root key is fully - spoofable. -- **The SSRF/http relaxation must be per-instance, never global.** The mainnet - discovery path keeps `ip_is_global` + https-only exactly as-is. Any relaxation - is carried as an explicit capability on the local instance and defaults off. -- **No cross-contamination in one process.** Because the recommended shape runs - local as its *own* process, the hosted binary never links the relaxed paths at - runtime; a cargo feature makes that a compile-time guarantee. -- Preserve existing hardening (body-size caps, redirect limits, callback - allow-list) unchanged. - -## 6. Work breakdown - -**Phase 1 — reads/writes against a local replica (core value)** -1. `LOCAL_REPLICA_URL` + local agent with guarded `fetch_root_key()`. -2. `IiInstance::local()` + `_LOCAL` env vars. -3. Local profile branch in `main.rs` composing the single local instance. -4. Discovery bypass locally (§4.3 option a) with a clear "pass canister ids - directly" message. -5. Local-aware `target_origin`. -6. Docs: how to deploy an MCP-enabled II locally and connect a client. - -*Exit:* `get_canister_candid`, `canister_query`, `canister_update_call`, and -per-app accounts work against a local replica + local II. - -**Phase 2 — local discovery (optional UX parity)** -7. Scoped SSRF/http relaxation (§4.3 option b) so `open_app`/`resolve_app` - resolve a localhost app. - -**Phase 3 — local canister management** -8. `provisional_create_canister_with_cycles` creation path; hide/degrade the - ICP/CMC/cycles-ledger tools unless a local NNS is present. - -## 7. Open questions / decisions for the user - -1. **Packaging:** separate `imcp2-local` binary or cargo `feature = "local"`, - vs. a runtime `IMCP2_MODE=local` branch in the one binary? (Recommendation: a - cargo feature — strongest compile-time guarantee that mainnet can't fetch a - root key or relax SSRF.) -2. **Local discovery:** ship the bypass (Phase 1) only, or is `open_app` against - a localhost app in scope (Phase 2)? -3. **Local II:** is there a canonical MCP-enabled II WASM/`dfx` recipe the docs - should point to, or should the scope include producing one? -4. **Canister management locally:** in scope now (Phase 3) or deferred? - -## 8. Out of scope - -- Deploying/packaging a local II (an environment prerequisite; the scope only - documents it). -- Persisting sessions (already a general roadmap item, `README.md:839`). -- Any change to the mainnet beta/prod instances' behavior. - -## 9. Evidence index (file references) - -- Agent build, no root-key fetch: `src/main.rs:85`, `src/lib.rs:100`. -- Per-instance agent injection: `src/lib.rs:103` (`McpConfig`), agent cloning - `src/identities.rs:457`. -- II instances: `src/identities.rs:106-133`. -- `target_origin`: `src/identities.rs:193`. -- Discovery SSRF/https guard: `src/discover.rs:915`, `:958`, `:997`, `:642`. -- Management system canisters + provisional gap: `src/management.rs:40-45`, - `:255`, `:555`. -- Connect handshake + callback allow-list: `src/auth.rs:750`, `:780`. -- `normalize_public_url` (Secure-cookie/local origin): `src/lib.rs:468`. +| Where it runs | a public server | the user's own machine | +| MCP transport | streamable-HTTP | **stdio** | +| Reached by | remote, third-party MCP clients | the co-located client that spawned it | +| Client auth | **OAuth 2.1** (bearer tokens, PKCE, DCR) | **none** (process boundary) | +| II login | OAuth-wrapped connect handshake | **built-in browser handshake** | +| IC / II target | mainnet + beta/prod II | **mainnet + production II** | + +Because it is single-user and reachable only through the pipe of the client that launched +it, the entire OAuth 2.1 authorization-server layer — which exists so *remote* clients can +authenticate to a *public* endpoint — is unnecessary. Internet Identity itself is **kept**: +the user still logs in with their real anchor and acts as their real II accounts on real +mainnet apps, from a bridge they run themselves rather than trusting a hosted third party. + +Design decisions locked with the requester: +- **Separate binary**, minimal dependencies (Cargo workspace split, not a runtime flag). +- **stdio** MCP transport. +- **No OAuth 2.1** authorization server. +- **Built-in browser II handshake**: the binary mints the session key, opens the browser to + production II, receives the delegation on a transient localhost callback, redeems it, and + holds the session in memory. + +All findings below were verified against the current code; `file:line` references point at it. + +## 2. Design overview — a 3-crate workspace + +Split the crate so the local binary's dependency closure never includes the OAuth/HTTP +machinery. Feature flags are rejected: they are additive and unify per resolved graph, so a +`--features local` build would still compile the OAuth AS + axum closure into the shipped +artifact. `cargo build -p imcp-local` compiles only the local closure. + +- **`imcp-core`** (lib) — everything both binaries share, all transport/OAuth-agnostic: + - `identities.rs` **kept verbatim** (the II session-key grant + on-demand per-app account + delegations against production II — `IiInstance::prod()` at `identities.rs:126`, + `registration_pubkey_b64` `:501`, `redeem_registration_delegation` `:821`, + `delegated_identity_for` `:908`, `list_accounts` `:758`). + - `calls.rs` (Candid textual↔binary codec, `raw_call`), `discover.rs` (discovery + SSRF + guard), `management.rs`, `skills.rs`. + - `tools.rs` — the **entire** MCP tool surface. The `#[tool_router]` / 26×`#[tool]` / + `#[tool_handler] impl ServerHandler for IcTools` macros expand into one impl on `IcTools` + and **must stay co-located** in one crate; both binaries construct `IcTools` and differ + only in transport + session source. + - **new `iiconnect` module** — the II connect-handshake primitives lifted out of `auth.rs` + (see §5), re-parameterised to plain values so they carry no `AuthStore`/OAuth state. + - rmcp features: `["server", "macros"]` only (no transport). +- **`imcp-hosted`** (bin) — today's streamable-HTTP + OAuth 2.1 server, behavior unchanged. + Adds the OAuth AS *wrapper* around `iiconnect`, the bearer gate, `McpServer`/routers + (`lib.rs`), the landing page (`main.rs`), and `tests/routers.rs`. +- **`imcp-local`** (bin, new) — a few hundred lines: serve `IcTools` over rmcp's stdio + transport; a browser-handshake login driver; a transient loopback callback listener. + +## 3. Dependency stripping (verified) + +**Drop outright — zero references in `src/`** (verified by grep). These are pure dead weight +today, independent of the local work, but the local crate must not carry them: + +- `ed25519-dalek` — all Ed25519 keygen/signing goes through `ic-agent`'s + `BasicIdentity::from_raw_key` (`identities.rs:1044`), never this crate. +- `p256`, `ic-signature-verification` — the server never verifies delegation signatures + itself; the replica verifies every hop at redeem (`identities.rs:1064`). +- `ic-representation-independent-hash` — unreferenced. +- top-level `schemars = "0.8"` — **vestigial**: every `schemars::JsonSchema` derive resolves + through `use rmcp::schemars` (rmcp's re-exported 1.x), verified across all modules. rmcp's + own schemars re-export must stay enabled; the direct dep is removed. + +**rmcp features:** keep `server` + `macros`; **swap** `transport-streamable-http-server` → +the stdio/io transport (`transport-io` in rmcp 1.x — provides `rmcp::transport::stdio()` over +tokio stdin/stdout, replacing `StreamableHttpService`); **drop** `auth` (unused — the bearer +gate is hand-rolled in `auth.rs`, and drops with the OAuth AS). + +**Stays, but not for OAuth** (so the local crate keeps them): +- discovery: `reqwest` (SSRF-pinned client, `discover.rs`), `url`, `regex`. +- canister management: `sha2` (Wasm hash + ledger AccountIdentifier), `crc32fast`, `base64`, + `hex` (`management.rs`). +- II connect/delegation: `getrandom` (key seeds + CSP nonce), `urlencoding` (II link), + `uuid` (connect `state`; replaceable by `getrandom`), `base64`/`hex` (delegation chain). +- core: `ic-agent`, `candid` (`value`), `candid_parser`, `tokio`, `serde`, `serde_json`, + `tracing`, `tracing-subscriber`, `anyhow`. + +**Drops with the HTTP surface:** `tower-http` (CORS — only the `#4091` well-known needs one +`Access-Control-Allow-Origin`, hand-settable), `tokio-util` (only cancels the streamable-HTTP +sessions; the reaper can be managed without a token), dev-deps `tower` + `http-body-util` +(HTTP router tests), and `tokio`'s `signal` feature (graceful HTTP drain). + +**`axum`** shrinks to at most the transient login callback (§6). The recommendation is to +**hand-roll** that 3-route loopback listener so the local crate drops `axum`/`tower-http` +entirely; reusing axum is the lower-effort fallback (see §8 decision). + +Net minimal local deps: `imcp-core` + `rmcp{server,macros,transport-io}` + `tokio` + +`anyhow` + `serde_json` + `url`/`urlencoding` + `tracing`/`tracing-subscriber` + a +browser-opener (`open` crate, or `std::process::Command`). + +## 4. Talking to mainnet + production II + +No replica changes: the local agent is `Agent::builder().with_url(IC_URL).build()` with +`IC_URL = "https://icp-api.io"` and **no** `fetch_root_key` (mainnet root key is baked in). +`Identities::new(IiInstance::prod()?, public_url, agent)` — production II. `public_url` is +only used as the management-identity derivation origin (`identities.rs:746`) and can be a +fixed local value; it need not be a reachable server. + +`II_URL`/`II_CANISTER_ID` remain env-overridable (`identities.rs:120`), so the binary can be +pointed at **beta** II for testing (see §9 — beta is the only instance verified end-to-end +today) while defaulting to production per the locked decision. + +## 5. Dropping OAuth 2.1, keeping the II handshake + +`auth.rs` splits along the boundary its own module docs already draw (`auth.rs:24-60`). + +**Kept for local (the de-OAuth'd II browser handshake):** +- `ii_mcp_url` (`auth.rs:750`) — builds II's `/mcp#callback=…&state=…&ttl=…®istration_key=…` + link. Re-parameterise from `&AuthStore` to plain values. +- the pinned callback page `connect_callback_page`/`pinned_callback_page` + assets + CSP nonce + (`auth.rs:808-978`). II delivers the delegation in the URL **fragment**, so the callback + *must* be an HTML page that reads `location.hash` client-side and POSTs it back. +- `parse_registration_delegation` + the `Json*` chain types + the 64 KB pre-parse bound + (`auth.rs:1170-1245`). +- a slimmed `connect_redeem` (`auth.rs:1302`): shape-check the fragment, single-flight, call + `Identities::redeem_registration_delegation`, signal completion — **minus** the PKCE/code/ + token/cookie/redirect tail. +- the **`#4091` allow-list** `/.well-known/ii-auth-callbacks` (`auth.rs:770,785`) — still + mandatory: II fail-closed-fetches it before honoring the callback. + +**Dropped (the entire OAuth 2.1 AS):** `/authorize` (`auth.rs:566`), `/token` + PKCE + tokens +(`auth.rs:1427-1528`), `/register` + DCR + `SharedClients` persistence (`auth.rs:1532-1616`, +`465-474`), the hosted-redirect allow-list (`auth.rs:198-390`), AS/PR discovery metadata +(`auth.rs:1627-1652`), the `require_token` bearer gate + `bearer_challenge` (`auth.rs:1664`), +and the front-channel HTML error-screen machinery. `AuthStore` slims to +`{identities, public_url, authz}` (loses `clients`/`tokens`/`codes`). + +**Consent-Bound Completion / the initiator cookie can be dropped locally.** The `sid` cookie +(`auth.rs:129`, checked `:1332`) defends a *split-browser confused-deputy* that requires a +public, multi-tenant initiate endpoint an attacker can start a connect on. Locally there is +**no HTTP initiate endpoint**: the binary itself mints `X`, `priv(X)` never leaves the +process, and `/redeem` is loopback-only and single-user. The consenter proof alone — the +delegation's final hop must target the in-memory `X`, replica-verified at redeem +(`registration_identity`, `identities.rs:1066`) — suffices; keep the random `state` as the +callback↔connect correlator. + +## 6. The built-in browser II login + +1. Build the mainnet agent + `Identities` (prod II) once. +2. Mint the session: `registration_pubkey_b64(&session_id)` → in-memory Ed25519 `S` + the + registration key `X`, returns base64url `pub(X)`. +3. Bind a transient listener on `127.0.0.1:0`; the callback origin is + `http://127.0.0.1:`. Both the II link's `callback` and the well-known entry derive + from this one value, so they cannot drift (II matches by exact string equality). +4. Build the II link (`iiconnect::ii_mcp_url`) against `https://id.ai`, open the browser + (print the URL to **stderr** always — stdout is the JSON-RPC channel — plus best-effort + `open`/`webbrowser`). +5. Serve exactly three loopback routes: `GET /callback` (the pinned fragment-reading page), + `POST /redeem` (slim redeem → `redeem_registration_delegation`), and + `GET /.well-known/ii-auth-callbacks` (the `#4091` allow-list, one `Access-Control-Allow-Origin: *` + header since II fetches it cross-origin). +6. On redeem success, record the grant in memory and shut the listener down. `IcTools` now + serves tools over stdio, minting per-app delegations on demand against mainnet. + +Sessions are **in-memory** (re-login per run), matching today's model and the roadmap. +Optional future work: persist the session seed `S` to an OS keychain to survive restarts — +but `S` is a live capability to the user's real anchor, so never plaintext. + +## 7. Tool / session seam + +Today a tool gets its `session_id` via bearer → `require_token` → `AuthedSession` injected in +the request extensions → `authed_session(ctx)` (`tools.rs:1475`). Under stdio there is one +user and one connection, so this collapses to a **singleton** session id set at login. + +Minimal, verified seam (no tool-signature changes, no `identities.rs` changes): +- add `session: SessionSource { Bearer, Singleton(String) }` to `IcTools` (`tools.rs:52`); +- one `current_session_id(&self, &ctx) -> Option` method replacing the free fn; +- rewrite the **13** lookup call-sites (`tools.rs:353,798,853,1286,1306,1326,1347,1368,1389, + 1410,1428,1446,1464`) to call it; the `.ok_or(…)` handling is unchanged; +- keep `authed_session` + the `auth` import behind the hosted arm only. + +To avoid dragging axum into core, read `http::request::Parts` (the `http` crate) rather than +`axum::http::request::Parts` in the Bearer arm — axum merely re-exports it. Hosted constructs +`IcTools` with `Bearer`; local with `Singleton(sid)`. + +Tools that are already session-free work unchanged locally: `get_canister_candid`, +`get_canister_api_doc`, `open_app`, `resolve_app`, `discover_app_canisters`, the skills/lookup +tools, and the anonymous path of `canister_query`. + +## 8. Security model & open decisions + +**Trust boundary.** Dropping the bearer gate is sound *only because* the transport is stdio: +a stdio server has no listening socket — it is reachable only by the parent process holding +its stdin/stdout, i.e. the client that launched it. But that client then wields the user's +**real production II accounts** on mainnet (canister create/install/start/stop/delete, any +update call / cycles spend, per-app delegations for every origin). This must be stated plainly: +**treat the binary and its client config like a wallet.** There is no revocable token — only +the II grant (reconnect/expiry). + +**Loopback hardening for the login listener:** bind `127.0.0.1` explicitly (never `0.0.0.0`); +validate the `Host` header (anti-DNS-rebinding, `lib.rs:385` pattern); up only for the +handshake. Even so, a rebinding attacker is largely inert: `/redeem` only advances the connect +*this* process started (`state` match) and `registration_identity` rejects any chain not +targeting our freshly-minted `X`. + +Decisions for the implementation PR (recommendations first): +1. **Loopback listener: hand-roll vs reuse axum.** *Recommend hand-roll* (3 routes, one CORS + header) so the local crate drops `axum`/`tower-http` — the security-sensitive page/CSP is a + static asset reused from core, not re-derived. Reuse-axum is the lower-effort fallback. +2. **Browser open:** `open`/`webbrowser` crate (convenience) vs `std::process::Command` + (zero-dep). *Recommend* always print the URL + best-effort auto-open, flow never depends on + auto-open succeeding. +3. **Login timing:** eager at startup vs lazy on first authenticated tool call. +4. **Session persistence:** in-memory only (recommend for v1) vs keychain-backed `S`. + +## 9. Risks to verify against PRODUCTION II + +The single biggest external dependency: the in-repo II contract was verified only against +**beta** II (`fgte5-…`, `identities.rs:814`, `auth.rs:76`), but this binary targets +**production** II (`rdmx6-…`). Per the README, `/mcp-prod` "only completes once the production +II carries the `#4086` MCP feature set" (`README.md:724`). Verify against live `id.ai`: +1. that production II implements the connect handshake — `/mcp` link, `mcp_register_v2` + (the `variant { Ok: record { expiration; permissions }; Err }` shape) and the `#4091` + well-known validation; +2. **mixed content:** II's https document must `fetch()` `http://127.0.0.1:/.well-known/…`. + Loopback is "potentially trustworthy" (W3C Secure Contexts), so Chrome/Firefox allow + https→http-loopback — prefer the `127.0.0.1` literal over the `localhost` name; Safari and + enterprise policies are the unknowns; +3. **CORS:** the well-known response needs `Access-Control-Allow-Origin` (II fetches it + cross-origin; `*` is fine with `credentials: omit`). + +Mitigation while prod II catches up: the existing env overrides let the binary point at beta +II, which is verified end-to-end today. + +## 10. Work breakdown + +**Phase 1 — workspace split (no behavior change to hosted).** Create `imcp-core` + move +`identities/calls/discover/management/skills/tools` and `static/` + connect assets into it; +extract `iiconnect` from `auth.rs`; leave the OAuth AS + `McpServer`/`main.rs` in +`imcp-hosted`. Apply the `SessionSource` seam (§7) and drop the 4 dead deps + `schemars 0.8`. +*Exit:* hosted builds and its tests pass unchanged; local closure compiles. + +**Phase 2 — the local binary.** `imcp-local`: stdio `IcTools` server + the browser-handshake +login driver + the loopback callback listener. *Exit:* `cargo build -p imcp-local`; a user +logs in against II and runs read/write tools as their accounts. + +**Phase 3 — polish.** Docs (how to add the binary to an MCP client config; the wallet-grade +trust note), the production-II verification (§9), optional session persistence. + +## 11. Evidence index + +- Dead deps (0 refs): `ed25519-dalek`/`p256`/`ic-signature-verification`/ + `ic-representation-independent-hash`; Ed25519 via `ic-agent` `identities.rs:1044`; replica + verifies chains `identities.rs:1064`. `schemars` via `use rmcp::schemars` (all modules). +- rmcp features + streamable-HTTP wiring: `Cargo.toml:12`, `lib.rs:83,194-208`. +- OAuth AS vs II-connect split: `auth.rs:24-60` (module docs), handlers `auth.rs:566/1440/1570`, + connect subset `auth.rs:750/808-978/1207-1245/1302`, `#4091` `auth.rs:770/785`. +- Session seam: `auth.rs:1664/1691`, `tools.rs:1475`, 13 call-sites listed in §7. +- II login primitives: `identities.rs:501/821`, `ii_mcp_url` `auth.rs:750`. +- Prod-vs-beta verification caveat: `identities.rs:79/814`, `README.md:724`. From 286b34fe305d920cba192124b9b39a721d9ac95c Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 24 Jul 2026 13:10:44 +0000 Subject: [PATCH 03/23] Keep the crate named imcp2 in the layout 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 Claude-Session: https://claude.ai/code/session_01AqNkpMQiQHzYxC2djTfvBK --- docs/scoping-local-deployment.md | 88 ++++++++++++++++++++------------ 1 file changed, 54 insertions(+), 34 deletions(-) diff --git a/docs/scoping-local-deployment.md b/docs/scoping-local-deployment.md index 5a43906..163eca4 100644 --- a/docs/scoping-local-deployment.md +++ b/docs/scoping-local-deployment.md @@ -36,32 +36,49 @@ Design decisions locked with the requester: All findings below were verified against the current code; `file:line` references point at it. -## 2. Design overview — a 3-crate workspace - -Split the crate so the local binary's dependency closure never includes the OAuth/HTTP -machinery. Feature flags are rejected: they are additive and unify per resolved graph, so a -`--features local` build would still compile the OAuth AS + axum closure into the shipped -artifact. `cargo build -p imcp-local` compiles only the local closure. - -- **`imcp-core`** (lib) — everything both binaries share, all transport/OAuth-agnostic: - - `identities.rs` **kept verbatim** (the II session-key grant + on-demand per-app account - delegations against production II — `IiInstance::prod()` at `identities.rs:126`, - `registration_pubkey_b64` `:501`, `redeem_registration_delegation` `:821`, - `delegated_identity_for` `:908`, `list_accounts` `:758`). - - `calls.rs` (Candid textual↔binary codec, `raw_call`), `discover.rs` (discovery + SSRF - guard), `management.rs`, `skills.rs`. - - `tools.rs` — the **entire** MCP tool surface. The `#[tool_router]` / 26×`#[tool]` / - `#[tool_handler] impl ServerHandler for IcTools` macros expand into one impl on `IcTools` - and **must stay co-located** in one crate; both binaries construct `IcTools` and differ - only in transport + session source. - - **new `iiconnect` module** — the II connect-handshake primitives lifted out of `auth.rs` - (see §5), re-parameterised to plain values so they carry no `AuthStore`/OAuth state. - - rmcp features: `["server", "macros"]` only (no transport). -- **`imcp-hosted`** (bin) — today's streamable-HTTP + OAuth 2.1 server, behavior unchanged. - Adds the OAuth AS *wrapper* around `iiconnect`, the bearer gate, `McpServer`/routers - (`lib.rs`), the landing page (`main.rs`), and `tests/routers.rs`. -- **`imcp-local`** (bin, new) — a few hundred lines: serve `IcTools` over rmcp's stdio - transport; a browser-handshake login driver; a transient loopback callback listener. +## 2. Design overview — the `imcp2` crate + a minimal `imcp2-local` binary + +The existing crate stays **`imcp2`** (its published, embeddable identity — `Cargo.toml:2`), +and its default binary stays **`imcp2`** (the Dockerfile `CMD ["imcp2"]`, `imcp2.service`, and +the deploy scripts all build/run a binary named `imcp2` — `Dockerfile:23,29`, +`deploy/native/*`, so renaming it would churn every deploy config). The local binary is a +**separate, minimal crate** so its dependency closure never includes the OAuth/HTTP machinery. + +- **`imcp2`** (lib + the hosted `imcp2` binary): + - The **library** is the shared, transport/OAuth-agnostic core both binaries build on: + - `identities.rs` **kept verbatim** (the II session-key grant + on-demand per-app account + delegations against production II — `IiInstance::prod()` at `identities.rs:126`, + `registration_pubkey_b64` `:501`, `redeem_registration_delegation` `:821`, + `delegated_identity_for` `:908`, `list_accounts` `:758`). + - `calls.rs` (Candid textual↔binary codec, `raw_call`), `discover.rs` (discovery + SSRF + guard), `management.rs`, `skills.rs`. + - `tools.rs` — the **entire** MCP tool surface. The `#[tool_router]` / 26×`#[tool]` / + `#[tool_handler] impl ServerHandler for IcTools` macros expand into one impl on + `IcTools` and **must stay co-located**; both binaries construct `IcTools` and differ + only in transport + session source. + - **new `iiconnect` module** — the II connect-handshake primitives lifted out of + `auth.rs` (see §5), re-parameterised to plain values so they carry no + `AuthStore`/OAuth state. + - The **hosted binary** (`[[bin]] name = "imcp2"`, today's `main.rs`) and the OAuth 2.1 + layer (`auth.rs`, `McpServer`/routers in `lib.rs`, the landing page, `tests/routers.rs`) + sit behind a **default-on `hosted` feature** that pulls `axum`/`tower-http` as *optional* + dependencies (`required-features = ["hosted"]` on the bin). `cargo build` here produces + the `imcp2` server exactly as today, deploy configs unchanged. +- **`imcp2-local`** (bin, new) — a few hundred lines that depend on + `imcp2 = { default-features = false }`, so the `hosted` optional deps (`axum`/`tower-http`/ + the OAuth modules, `#[cfg(feature = "hosted")]`) are **not compiled**. It serves `IcTools` + over rmcp's stdio transport, plus a browser-handshake login driver and a transient loopback + callback listener. rmcp features here: `["server", "macros", "transport-io"]`. + +`cargo build -p imcp2-local` compiles only the minimal closure. (Caveat: `cargo build +--workspace` unifies features, so it would build the shared `imcp2` lib with `hosted` on; +build the local binary with `-p imcp2-local` — or keep it out of the default workspace +members — to ship the genuinely minimal artifact.) + +*Alternative (stricter):* a three-crate split — `imcp2` (core lib, no bins), `imcp2-hosted` +(bin), `imcp2-local` (bin) — isolates dependencies regardless of build invocation, at the +cost of renaming the deployed binary to `imcp2-hosted` (deploy churn). The library changes +below are identical either way. ## 3. Dependency stripping (verified) @@ -100,7 +117,8 @@ sessions; the reaper can be managed without a token), dev-deps `tower` + `http-b **hand-roll** that 3-route loopback listener so the local crate drops `axum`/`tower-http` entirely; reusing axum is the lower-effort fallback (see §8 decision). -Net minimal local deps: `imcp-core` + `rmcp{server,macros,transport-io}` + `tokio` + +Net minimal local deps: `imcp2` (with `default-features = false`) + +`rmcp{server,macros,transport-io}` + `tokio` + `anyhow` + `serde_json` + `url`/`urlencoding` + `tracing`/`tracing-subscriber` + a browser-opener (`open` crate, or `std::process::Command`). @@ -240,14 +258,16 @@ II, which is verified end-to-end today. ## 10. Work breakdown -**Phase 1 — workspace split (no behavior change to hosted).** Create `imcp-core` + move -`identities/calls/discover/management/skills/tools` and `static/` + connect assets into it; -extract `iiconnect` from `auth.rs`; leave the OAuth AS + `McpServer`/`main.rs` in -`imcp-hosted`. Apply the `SessionSource` seam (§7) and drop the 4 dead deps + `schemars 0.8`. -*Exit:* hosted builds and its tests pass unchanged; local closure compiles. +**Phase 1 — carve out the core (no behavior change to the `imcp2` binary).** Make the +`imcp2` library the transport/OAuth-agnostic core: keep `identities/calls/discover/management/ +skills/tools` + `static/` + connect assets, extract `iiconnect` from `auth.rs`, and put the +OAuth AS + `McpServer`/`main.rs` behind the default-on `hosted` feature (optional +`axum`/`tower-http`). Apply the `SessionSource` seam (§7) and drop the 4 dead deps + +`schemars 0.8`. *Exit:* the `imcp2` binary builds and its tests pass unchanged; `imcp2` with +`default-features = false` compiles a minimal closure. -**Phase 2 — the local binary.** `imcp-local`: stdio `IcTools` server + the browser-handshake -login driver + the loopback callback listener. *Exit:* `cargo build -p imcp-local`; a user +**Phase 2 — the local binary.** `imcp2-local`: stdio `IcTools` server + the browser-handshake +login driver + the loopback callback listener. *Exit:* `cargo build -p imcp2-local`; a user logs in against II and runs read/write tools as their accounts. **Phase 3 — polish.** Docs (how to add the binary to an MCP client config; the wallet-grade From ffbfaba6f3a18f79d61c1350c0803926c955f32e Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 27 Jul 2026 14:00:49 +0000 Subject: [PATCH 04/23] Add "Working with AI tool clients" section MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01AqNkpMQiQHzYxC2djTfvBK --- docs/scoping-local-deployment.md | 116 ++++++++++++++++++++++++++----- 1 file changed, 100 insertions(+), 16 deletions(-) diff --git a/docs/scoping-local-deployment.md b/docs/scoping-local-deployment.md index 163eca4..c042249 100644 --- a/docs/scoping-local-deployment.md +++ b/docs/scoping-local-deployment.md @@ -115,7 +115,7 @@ sessions; the reaper can be managed without a token), dev-deps `tower` + `http-b **`axum`** shrinks to at most the transient login callback (§6). The recommendation is to **hand-roll** that 3-route loopback listener so the local crate drops `axum`/`tower-http` -entirely; reusing axum is the lower-effort fallback (see §8 decision). +entirely; reusing axum is the lower-effort fallback (see §9 decision). Net minimal local deps: `imcp2` (with `default-features = false`) + `rmcp{server,macros,transport-io}` + `tokio` + @@ -131,7 +131,7 @@ only used as the management-identity derivation origin (`identities.rs:746`) and fixed local value; it need not be a reachable server. `II_URL`/`II_CANISTER_ID` remain env-overridable (`identities.rs:120`), so the binary can be -pointed at **beta** II for testing (see §9 — beta is the only instance verified end-to-end +pointed at **beta** II for testing (see §10 — beta is the only instance verified end-to-end today) while defaulting to production per the locked decision. ## 5. Dropping OAuth 2.1, keeping the II handshake @@ -176,9 +176,11 @@ callback↔connect correlator. 3. Bind a transient listener on `127.0.0.1:0`; the callback origin is `http://127.0.0.1:`. Both the II link's `callback` and the well-known entry derive from this one value, so they cannot drift (II matches by exact string equality). -4. Build the II link (`iiconnect::ii_mcp_url`) against `https://id.ai`, open the browser - (print the URL to **stderr** always — stdout is the JSON-RPC channel — plus best-effort - `open`/`webbrowser`). +4. Build the II link (`iiconnect::ii_mcp_url`) against `https://id.ai` and surface it to the + user **in-band** — as the text result of an `authenticate` MCP tool (§7) — plus a + best-effort server-side browser auto-open. Do **not** rely on **stderr** for the URL: every + client routes a stdio server's stderr to a log file/panel, never the chat (§7). stdout is + the JSON-RPC channel, so all logging stays on stderr. 5. Serve exactly three loopback routes: `GET /callback` (the pinned fragment-reading page), `POST /redeem` (slim redeem → `redeem_registration_delegation`), and `GET /.well-known/ii-auth-callbacks` (the `#4091` allow-list, one `Access-Control-Allow-Origin: *` @@ -186,11 +188,91 @@ callback↔connect correlator. 6. On redeem success, record the grant in memory and shut the listener down. `IcTools` now serves tools over stdio, minting per-app delegations on demand against mainnet. +Login is **lazy and non-blocking**: it runs as an MCP tool the agent calls on the first +authenticated action — not at startup (most clients require the user to approve the first tool +call, and some cap `initialize` at ~10 s) — and it returns the URL immediately rather than +blocking on the callback (Codex times out a tool call at 60 s; Claude Code auto-backgrounds +calls over 2 min). A follow-up `auth_status` tool (or simply the next tool call) confirms the +grant landed. §7 covers the per-client specifics. + Sessions are **in-memory** (re-login per run), matching today's model and the roadmap. Optional future work: persist the session seed `S` to an OS keychain to survive restarts — but `S` is a live capability to the user's real anchor, so never plaintext. -## 7. Tool / session seam +## 7. Working with AI tool clients + +The local binary is a **stdio** MCP server, so it is reachable by any client that can **spawn a +local subprocess**, and unreachable by one that only connects to a remote **URL**. The five +requested clients split cleanly along that line (verified against current docs, mid-2026): + +| Client / surface | Local stdio? | Where you register it | Reaches `imcp2-local`? | +|---|---|---|---| +| **Claude Desktop** (mac/Win) | yes | `claude_desktop_config.json` → `mcpServers`; or a `.mcpb` bundle (one-click install) | ✅ | +| **Claude Code** (CLI) | yes | `claude mcp add --transport stdio … -- `; `.mcp.json` / `~/.claude.json` | ✅ | +| claude.ai web / mobile / Cowork | no | remote connectors (OAuth) only | ❌ → hosted | +| **Codex** CLI / IDE ext / desktop | yes | `~/.codex/config.toml` → `[mcp_servers.]` | ✅ | +| Codex Cloud | no | HTTP MCP only | ❌ → hosted | +| **Cursor** | yes | `~/.cursor/mcp.json` or `.cursor/mcp.json` → `mcpServers` | ✅ (≤40 tools total; we expose ~26) | +| **Perplexity** macOS app | yes (via a `PerplexityXPC` helper) | Settings → Connectors → Add → Advanced JSON | ✅ macOS only | +| Perplexity web / Windows / remote | no | remote HTTPS URL + OAuth 2.1 + DCR + `/.well-known/mcp-connector.json` | ❌ → hosted | +| **Antigravity** IDE / CLI / 2.0 | yes | `~/.gemini/config/mcp_config.json` or `.agents/mcp_config.json` → `mcpServers` | ✅ | + +**Two classes, two binaries.** Every desktop/CLI/IDE surface — Claude Desktop, Claude Code, +Codex (CLI/IDE/desktop), Cursor, Antigravity, and the **Perplexity macOS app** — runs +`imcp2-local` directly. The cloud/remote-only surfaces — claude.ai web/mobile, Perplexity +web/Windows, Codex Cloud — cannot reach `localhost`; they need the **hosted `imcp2`** server +(the OAuth path kept in §5). This is precisely why the OAuth layer stays in `imcp2` rather than +being deleted: it is the only way to serve the cloud clients. + +**Registration** (absolute binary path everywhere; `imcp2-local` needs no args): + +- Claude Desktop / Cursor / Antigravity all use a `mcpServers` JSON object: + ```json + { "mcpServers": { "imcp2": { "command": "/usr/local/bin/imcp2-local" } } } + ``` + (Antigravity: `~/.gemini/config/mcp_config.json`; Cursor: `~/.cursor/mcp.json`; Claude + Desktop: `claude_desktop_config.json`, or ship a `.mcpb` bundle for one-click install.) +- Claude Code: `claude mcp add --transport stdio imcp2 -- /usr/local/bin/imcp2-local` +- Codex (`~/.codex/config.toml`): + ```toml + [mcp_servers.imcp2] + command = "/usr/local/bin/imcp2-local" + ``` +- Perplexity macOS: Settings → Connectors → Add Connector → Advanced (needs the PerplexityXPC + helper): `{ "command": "/usr/local/bin/imcp2-local", "args": [], "env": {} }` + +**Cross-client login invariants** (every subprocess-capable client agreed): +1. **Host OAuth never touches a stdio server.** All five drive OAuth only for *remote* servers; + for a local stdio server the host just pipes stdin/stdout. So the II login is entirely the + binary's own browser handshake (§6) — which also sidesteps Antigravity's known-buggy remote + MCP-OAuth. +2. **stderr is not shown in chat — anywhere.** Claude, Codex, Cursor, and Antigravity all route + a stdio server's stderr to a log file/panel, and stdout is reserved for JSON-RPC. So the + login URL is surfaced **in-band**: the `authenticate` tool returns it as text (the model + relays it; Claude Desktop linkifies `http(s)`), backed by a best-effort browser auto-open. + Use a plain `http(s)://…` URL — custom URI schemes are not reliably opened. +3. **First tool call needs approval.** Cursor/Antigravity default to "Ask", Codex to its + approval policy — so login cannot run silently at startup; it triggers lazily on the first + authenticated tool. +4. **Don't block on the callback** (Codex 60 s tool / 10 s `initialize`; Claude Code + auto-backgrounds > 2 min): `authenticate` returns the URL and starts the listener + immediately; a follow-up `auth_status` (or the next tool call) confirms completion. +5. **Absolute paths; stdout = JSON-RPC only** (all diagnostics to stderr). A self-contained + native binary avoids the frequent wrong-runtime/path failures Node-based servers hit. + +*(Implementation: `authenticate`/`auth_status` are **local-only** tools — define them in the +`imcp2` library gated to the local build so they never appear on the hosted server, which logs +in via OAuth instead. Cursor's ~40-tool cap is comfortable: `imcp2` exposes ~26 plus these.)* + +**Serving the cloud clients (hosted `imcp2`).** claude.ai web/mobile and Perplexity-web reach +only a public HTTPS MCP endpoint with OAuth 2.1 — which hosted `imcp2` already is. Two +Perplexity-specific gaps to verify before claiming support there: it expects a +`/.well-known/mcp-connector.json` discovery document (not currently served), and its remote +OAuth has an open DCR bug that rejects RFC 7591 public-client registrations lacking a +`client_secret` (the same registrations that work on Claude/ChatGPT/Grok). Neither affects the +local binary. + +## 8. Tool / session seam Today a tool gets its `session_id` via bearer → `require_token` → `AuthedSession` injected in the request extensions → `authed_session(ctx)` (`tools.rs:1475`). Under stdio there is one @@ -211,7 +293,7 @@ Tools that are already session-free work unchanged locally: `get_canister_candid `get_canister_api_doc`, `open_app`, `resolve_app`, `discover_app_canisters`, the skills/lookup tools, and the anonymous path of `canister_query`. -## 8. Security model & open decisions +## 9. Security model & open decisions **Trust boundary.** Dropping the bearer gate is sound *only because* the transport is stdio: a stdio server has no listening socket — it is reachable only by the parent process holding @@ -232,12 +314,14 @@ Decisions for the implementation PR (recommendations first): header) so the local crate drops `axum`/`tower-http` — the security-sensitive page/CSP is a static asset reused from core, not re-derived. Reuse-axum is the lower-effort fallback. 2. **Browser open:** `open`/`webbrowser` crate (convenience) vs `std::process::Command` - (zero-dep). *Recommend* always print the URL + best-effort auto-open, flow never depends on - auto-open succeeding. -3. **Login timing:** eager at startup vs lazy on first authenticated tool call. + (zero-dep). *Recommend* always return the URL in-band (the `authenticate` tool result) + + best-effort auto-open; the flow never depends on auto-open succeeding. +3. **Login timing:** *resolved by the client research (§7)* — lazy on the first authenticated + tool call, never at startup (clients gate the first call on approval and cap `initialize`), + and non-blocking. 4. **Session persistence:** in-memory only (recommend for v1) vs keychain-backed `S`. -## 9. Risks to verify against PRODUCTION II +## 10. Risks to verify against PRODUCTION II The single biggest external dependency: the in-repo II contract was verified only against **beta** II (`fgte5-…`, `identities.rs:814`, `auth.rs:76`), but this binary targets @@ -256,13 +340,13 @@ II carries the `#4086` MCP feature set" (`README.md:724`). Verify against live ` Mitigation while prod II catches up: the existing env overrides let the binary point at beta II, which is verified end-to-end today. -## 10. Work breakdown +## 11. Work breakdown **Phase 1 — carve out the core (no behavior change to the `imcp2` binary).** Make the `imcp2` library the transport/OAuth-agnostic core: keep `identities/calls/discover/management/ skills/tools` + `static/` + connect assets, extract `iiconnect` from `auth.rs`, and put the OAuth AS + `McpServer`/`main.rs` behind the default-on `hosted` feature (optional -`axum`/`tower-http`). Apply the `SessionSource` seam (§7) and drop the 4 dead deps + +`axum`/`tower-http`). Apply the `SessionSource` seam (§8) and drop the 4 dead deps + `schemars 0.8`. *Exit:* the `imcp2` binary builds and its tests pass unchanged; `imcp2` with `default-features = false` compiles a minimal closure. @@ -271,9 +355,9 @@ login driver + the loopback callback listener. *Exit:* `cargo build -p imcp2-loc logs in against II and runs read/write tools as their accounts. **Phase 3 — polish.** Docs (how to add the binary to an MCP client config; the wallet-grade -trust note), the production-II verification (§9), optional session persistence. +trust note), the production-II verification (§10), optional session persistence. -## 11. Evidence index +## 12. Evidence index - Dead deps (0 refs): `ed25519-dalek`/`p256`/`ic-signature-verification`/ `ic-representation-independent-hash`; Ed25519 via `ic-agent` `identities.rs:1044`; replica @@ -281,6 +365,6 @@ trust note), the production-II verification (§9), optional session persistence. - rmcp features + streamable-HTTP wiring: `Cargo.toml:12`, `lib.rs:83,194-208`. - OAuth AS vs II-connect split: `auth.rs:24-60` (module docs), handlers `auth.rs:566/1440/1570`, connect subset `auth.rs:750/808-978/1207-1245/1302`, `#4091` `auth.rs:770/785`. -- Session seam: `auth.rs:1664/1691`, `tools.rs:1475`, 13 call-sites listed in §7. +- Session seam: `auth.rs:1664/1691`, `tools.rs:1475`, 13 call-sites listed in §8. - II login primitives: `identities.rs:501/821`, `ii_mcp_url` `auth.rs:750`. - Prod-vs-beta verification caveat: `identities.rs:79/814`, `README.md:724`. From c7c13e01b746a4807ef78a7e938bbd8fb4d9ef16 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 20 Aug 2026 15:28:29 +0000 Subject: [PATCH 05/23] Update the scoping doc for the 63-commit rebase onto v0.2.0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01AqNkpMQiQHzYxC2djTfvBK --- docs/scoping-local-deployment.md | 207 ++++++++++++++++++++----------- 1 file changed, 134 insertions(+), 73 deletions(-) diff --git a/docs/scoping-local-deployment.md b/docs/scoping-local-deployment.md index c042249..1e85adb 100644 --- a/docs/scoping-local-deployment.md +++ b/docs/scoping-local-deployment.md @@ -40,16 +40,16 @@ All findings below were verified against the current code; `file:line` reference The existing crate stays **`imcp2`** (its published, embeddable identity — `Cargo.toml:2`), and its default binary stays **`imcp2`** (the Dockerfile `CMD ["imcp2"]`, `imcp2.service`, and -the deploy scripts all build/run a binary named `imcp2` — `Dockerfile:23,29`, +the deploy scripts all build/run a binary named `imcp2` — `Dockerfile:23,31`, `deploy/native/*`, so renaming it would churn every deploy config). The local binary is a **separate, minimal crate** so its dependency closure never includes the OAuth/HTTP machinery. - **`imcp2`** (lib + the hosted `imcp2` binary): - The **library** is the shared, transport/OAuth-agnostic core both binaries build on: - `identities.rs` **kept verbatim** (the II session-key grant + on-demand per-app account - delegations against production II — `IiInstance::prod()` at `identities.rs:126`, - `registration_pubkey_b64` `:501`, `redeem_registration_delegation` `:821`, - `delegated_identity_for` `:908`, `list_accounts` `:758`). + delegations against production II — `IiInstance::prod()` at `identities.rs:185`, + `registration_pubkey_b64` `:595`, `redeem_registration_delegation` `:936`, + `delegated_identity_for` `:1030`, `list_accounts` `:873`). - `calls.rs` (Candid textual↔binary codec, `raw_call`), `discover.rs` (discovery + SSRF guard), `management.rs`, `skills.rs`. - `tools.rs` — the **entire** MCP tool surface. The `#[tool_router]` / 26×`#[tool]` / @@ -61,9 +61,14 @@ the deploy scripts all build/run a binary named `imcp2` — `Dockerfile:23,29`, `AuthStore`/OAuth state. - The **hosted binary** (`[[bin]] name = "imcp2"`, today's `main.rs`) and the OAuth 2.1 layer (`auth.rs`, `McpServer`/routers in `lib.rs`, the landing page, `tests/routers.rs`) - sit behind a **default-on `hosted` feature** that pulls `axum`/`tower-http` as *optional* - dependencies (`required-features = ["hosted"]` on the bin). `cargo build` here produces - the `imcp2` server exactly as today, deploy configs unchanged. + sit behind a **default-on `hosted` feature** that pulls `axum`/`tower-http`/`prometheus` + as *optional* dependencies (`required-features = ["hosted"]` on the bin). The gate also + covers `pub mod metrics` (the `/metrics` Prometheus exposition — axum-typed middleware, + new on main) and `McpConfig`'s `state_dir`/`clients`/`require_resource` (the persisted + DCR client store and RFC 8707 strict mode — all OAuth/HTTP-deployment concerns). + `cargo build` here produces the `imcp2` server exactly as today, deploy configs unchanged. + The existing `e2e` feature (`pocket-ic`, `src/e2e_handshake.rs`) keeps compiling — its + optional dep never enters any binary's closure. - **`imcp2-local`** (bin, new) — a few hundred lines that depend on `imcp2 = { default-features = false }`, so the `hosted` optional deps (`axum`/`tower-http`/ the OAuth modules, `#[cfg(feature = "hosted")]`) are **not compiled**. It serves `IcTools` @@ -75,6 +80,16 @@ the deploy scripts all build/run a binary named `imcp2` — `Dockerfile:23,29`, build the local binary with `-p imcp2-local` — or keep it out of the default workspace members — to ship the genuinely minimal artifact.) +**Published crate.** `imcp2` is now on crates.io (v0.2.0), released from `v*` tags via +trusted publishing with a tag-equals-version guard (`.github/workflows/publish-crate.yml`). +Two consequences: the restructure is a semver-visible change to a crate with external +embedders (the default-on `hosted` feature and `default-features = false` become supported +public API → minor version bump), and the publish workflow must be generalised if +`imcp2-local` becomes a second published crate (publish `imcp2` before the dependent crate, +per-crate version guards). The `.crate` excludes `Dockerfile`/`deploy/`/`docs/`/`monitoring/` +(repo-only), while `src/assets` and `static/` deliberately ship because they are pulled in +via `include_str!` — which supports keeping those assets in the core crate. + *Alternative (stricter):* a three-crate split — `imcp2` (core lib, no bins), `imcp2-hosted` (bin), `imcp2-local` (bin) — isolates dependencies regardless of build invocation, at the cost of renaming the deployed binary to `imcp2-hosted` (deploy churn). The library changes @@ -82,17 +97,17 @@ below are identical either way. ## 3. Dependency stripping (verified) -**Drop outright — zero references in `src/`** (verified by grep). These are pure dead weight -today, independent of the local work, but the local crate must not carry them: - -- `ed25519-dalek` — all Ed25519 keygen/signing goes through `ic-agent`'s - `BasicIdentity::from_raw_key` (`identities.rs:1044`), never this crate. -- `p256`, `ic-signature-verification` — the server never verifies delegation signatures - itself; the replica verifies every hop at redeem (`identities.rs:1064`). -- `ic-representation-independent-hash` — unreferenced. -- top-level `schemars = "0.8"` — **vestigial**: every `schemars::JsonSchema` derive resolves - through `use rmcp::schemars` (rmcp's re-exported 1.x), verified across all modules. rmcp's - own schemars re-export must stay enabled; the direct dep is removed. +**Already removed on main (#100).** The five vestigial direct deps this doc originally +proposed dropping — `ed25519-dalek`, `p256`, `ic-signature-verification`, +`ic-representation-independent-hash`, and the top-level `schemars = "0.8"` — were removed on +main (commit `0e52fe9`); none appears in `Cargo.toml` at v0.2.0, so no stripping work +remains for them. The supporting facts still hold: all Ed25519 keygen/signing goes through +`ic-agent`'s `BasicIdentity::from_raw_key` (`fresh_ed25519`, `identities.rs:1252`), every +`schemars::JsonSchema` derive resolves through `use rmcp::schemars` (rmcp's re-exported 1.x), +and delegation chains are verified inside `ic-agent` — now both client-side against the +injected agent's root key (`DelegatedIdentity::new_with_root_key`, `identities.rs:1285-1303`) +and again by the replica at redeem — never by those crates. (`ed25519-dalek`/`p256` remain +*transitive* deps via `ic-agent`; #100 was direct-dep hygiene, not closure reduction.) **rmcp features:** keep `server` + `macros`; **swap** `transport-streamable-http-server` → the stdio/io transport (`transport-io` in rmcp 1.x — provides `rmcp::transport::stdio()` over @@ -101,17 +116,27 @@ gate is hand-rolled in `auth.rs`, and drops with the OAuth AS). **Stays, but not for OAuth** (so the local crate keeps them): - discovery: `reqwest` (SSRF-pinned client, `discover.rs`), `url`, `regex`. -- canister management: `sha2` (Wasm hash + ledger AccountIdentifier), `crc32fast`, `base64`, - `hex` (`management.rs`). +- canister management: `sha2` (Wasm hash + ledger AccountIdentifier; also the OAuth PKCE + S256 check in `auth.rs`, which drops — management keeps it either way), `crc32fast`, + `base64`, `hex` (`management.rs`). - II connect/delegation: `getrandom` (key seeds + CSP nonce), `urlencoding` (II link), `uuid` (connect `state`; replaceable by `getrandom`), `base64`/`hex` (delegation chain). - core: `ic-agent`, `candid` (`value`), `candid_parser`, `tokio`, `serde`, `serde_json`, `tracing`, `tracing-subscriber`, `anyhow`. -**Drops with the HTTP surface:** `tower-http` (CORS — only the `#4091` well-known needs one -`Access-Control-Allow-Origin`, hand-settable), `tokio-util` (only cancels the streamable-HTTP -sessions; the reaper can be managed without a token), dev-deps `tower` + `http-body-util` -(HTTP router tests), and `tokio`'s `signal` feature (graceful HTTP drain). +**Drops with the HTTP surface:** `tower-http` (CORS — it now backs two hosted surfaces, the +MCP endpoint's browser-client layer and the OAuth/well-known permissive layer; locally only +the `#4091` well-known needs one hand-set `Access-Control-Allow-Origin`), **`prometheus` + +`src/metrics.rs`** (new on main: the `/metrics` Prometheus exposition behind +`MCP_SERVE_METRICS` — axum-typed middleware coupled to `McpServer`, hosted-only; it must move +behind the `hosted` feature or the local crate carries the metrics stack for nothing — the +`SessionGauges` counters in `identities.rs` are plain integers and stay in core), `tokio-util` +(only cancels the streamable-HTTP sessions; the reaper can be managed without a token), +dev-deps `tower` + `http-body-util` (HTTP router tests), and `tokio`'s `signal` feature +(graceful HTTP drain) plus its `time` feature (used only by the session-reaper tick and the +OAuth persist throttle — subject to what rmcp's stdio transport itself requires). +`pocket-ic` (new on main, optional behind the `e2e` feature for `src/e2e_handshake.rs`) +never enters any binary's closure — no action needed. **`axum`** shrinks to at most the transient login callback (§6). The recommendation is to **hand-roll** that 3-route loopback listener so the local crate drops `axum`/`tower-http` @@ -127,40 +152,50 @@ browser-opener (`open` crate, or `std::process::Command`). No replica changes: the local agent is `Agent::builder().with_url(IC_URL).build()` with `IC_URL = "https://icp-api.io"` and **no** `fetch_root_key` (mainnet root key is baked in). `Identities::new(IiInstance::prod()?, public_url, agent)` — production II. `public_url` is -only used as the management-identity derivation origin (`identities.rs:746`) and can be a +only used as the management-identity derivation origin (`identities.rs:861-863`) and can be a fixed local value; it need not be a reachable server. -`II_URL`/`II_CANISTER_ID` remain env-overridable (`identities.rs:120`), so the binary can be -pointed at **beta** II for testing (see §10 — beta is the only instance verified end-to-end -today) while defaulting to production per the locked decision. +The prod instance is env-overridable via `II_URL_PROD`/`II_CANISTER_ID_PROD` +(`identities.rs:185-190`; the plain `II_URL`/`II_CANISTER_ID` pair now overrides only +`IiInstance::beta()`), so the binary can still be pointed at beta II for testing. Since #92 +the hosted deployment itself serves **production II at `/mcp`** by default (beta is the +opt-in `/mcp-beta` staging instance), so prod II is no longer the unverified path (see §10). ## 5. Dropping OAuth 2.1, keeping the II handshake `auth.rs` splits along the boundary its own module docs already draw (`auth.rs:24-60`). **Kept for local (the de-OAuth'd II browser handshake):** -- `ii_mcp_url` (`auth.rs:750`) — builds II's `/mcp#callback=…&state=…&ttl=…®istration_key=…` +- `ii_mcp_url` (`auth.rs:1284`) — builds II's `/mcp#callback=…&state=…&ttl=…®istration_key=…` link. Re-parameterise from `&AuthStore` to plain values. - the pinned callback page `connect_callback_page`/`pinned_callback_page` + assets + CSP nonce - (`auth.rs:808-978`). II delivers the delegation in the URL **fragment**, so the callback + (`auth.rs:1342-1515`). II delivers the delegation in the URL **fragment**, so the callback *must* be an HTML page that reads `location.hash` client-side and POSTs it back. - `parse_registration_delegation` + the `Json*` chain types + the 64 KB pre-parse bound - (`auth.rs:1170-1245`). -- a slimmed `connect_redeem` (`auth.rs:1302`): shape-check the fragment, single-flight, call + (`auth.rs:1673-1782`). +- a slimmed `connect_redeem` (`auth.rs:1839`): shape-check the fragment, single-flight, call `Identities::redeem_registration_delegation`, signal completion — **minus** the PKCE/code/ token/cookie/redirect tail. -- the **`#4091` allow-list** `/.well-known/ii-auth-callbacks` (`auth.rs:770,785`) — still +- the **`#4091` allow-list** `/.well-known/ii-auth-callbacks` (`auth.rs:1304,1319`) — still mandatory: II fail-closed-fetches it before honoring the callback. -**Dropped (the entire OAuth 2.1 AS):** `/authorize` (`auth.rs:566`), `/token` + PKCE + tokens -(`auth.rs:1427-1528`), `/register` + DCR + `SharedClients` persistence (`auth.rs:1532-1616`, -`465-474`), the hosted-redirect allow-list (`auth.rs:198-390`), AS/PR discovery metadata -(`auth.rs:1627-1652`), the `require_token` bearer gate + `bearer_challenge` (`auth.rs:1664`), -and the front-channel HTML error-screen machinery. `AuthStore` slims to -`{identities, public_url, authz}` (loses `clients`/`tokens`/`codes`). +**Dropped (the entire OAuth 2.1 AS):** `/authorize` (`auth.rs:1053`), `/token` + PKCE + tokens +(`auth.rs:1969-2137`), `/register` + DCR + the persisted client store (`auth.rs:2139-2221`; +`SharedClients` now wraps a bounded `ClientStore` persisting `{state_dir}/oauth-clients.json` +per `McpConfig::state_dir`, `auth.rs:799-820`), the hosted-redirect allow-list +(`auth.rs:434-687`), AS/PR discovery metadata (`auth.rs:2223-2262`), the `require_token` +bearer gate + `bearer_challenge` (`auth.rs:2264,2313`), the front-channel HTML error-screen +machinery, and the OAuth hardening added since this doc's first draft — RFC 8707 +resource-indicator enforcement (the `require_resource` flag, #127), RFC 9207 `iss` emission +on redirects (#125), and the bounded/atomically-persisted DCR store (#137) — all on the +OAuth side, so the partition is unchanged. `AuthStore` slims to +`{identities, public_url, authz}` (loses `clients`/`tokens`/`codes`, plus the hosted-only +`mcp_path` route prefix — the local build replaces it with a plain callback-base value — and +`require_resource`). With `clients` gone the local binary needs **no `state_dir` at all**: +it writes no files, and all its state is the in-memory `Identities` session map. **Consent-Bound Completion / the initiator cookie can be dropped locally.** The `sid` cookie -(`auth.rs:129`, checked `:1332`) defends a *split-browser confused-deputy* that requires a +(`auth.rs:170`, checked `:1869`) defends a *split-browser confused-deputy* that requires a public, multi-tenant initiate endpoint an attacker can start a connect on. Locally there is **no HTTP initiate endpoint**: the binary itself mints `X`, `priv(X)` never leaves the process, and `/redeem` is loopback-only and single-user. The consenter proof alone — the @@ -172,7 +207,9 @@ callback↔connect correlator. 1. Build the mainnet agent + `Identities` (prod II) once. 2. Mint the session: `registration_pubkey_b64(&session_id)` → in-memory Ed25519 `S` + the - registration key `X`, returns base64url `pub(X)`. + registration key `X`, returns base64url `pub(X)`. (Now fallible — it returns `Result`, + refused only when the session map hits its CWE-770 capacity bound, which a single-user + binary never does.) 3. Bind a transient listener on `127.0.0.1:0`; the callback origin is `http://127.0.0.1:`. Both the II link's `callback` and the well-known entry derive from this one value, so they cannot drift (II matches by exact string equality). @@ -275,14 +312,14 @@ local binary. ## 8. Tool / session seam Today a tool gets its `session_id` via bearer → `require_token` → `AuthedSession` injected in -the request extensions → `authed_session(ctx)` (`tools.rs:1475`). Under stdio there is one +the request extensions → `authed_session(ctx)` (`tools.rs:1493`). Under stdio there is one user and one connection, so this collapses to a **singleton** session id set at login. Minimal, verified seam (no tool-signature changes, no `identities.rs` changes): - add `session: SessionSource { Bearer, Singleton(String) }` to `IcTools` (`tools.rs:52`); - one `current_session_id(&self, &ctx) -> Option` method replacing the free fn; -- rewrite the **13** lookup call-sites (`tools.rs:353,798,853,1286,1306,1326,1347,1368,1389, - 1410,1428,1446,1464`) to call it; the `.ok_or(…)` handling is unchanged; +- rewrite the **13** lookup call-sites (`tools.rs:351,809,865,1298,1321,1344,1365,1386,1407, + 1428,1446,1464,1482`) to call it; the `.ok_or(…)` handling is unchanged; - keep `authed_session` + the `auth` import behind the hosted arm only. To avoid dragging axum into core, read `http::request::Parts` (the `http` crate) rather than @@ -304,7 +341,9 @@ update call / cycles spend, per-app delegations for every origin). This must be the II grant (reconnect/expiry). **Loopback hardening for the login listener:** bind `127.0.0.1` explicitly (never `0.0.0.0`); -validate the `Host` header (anti-DNS-rebinding, `lib.rs:385` pattern); up only for the +validate the `Host` header (anti-DNS-rebinding — the `allowed_hosts_for` allow-list pattern, +`lib.rs:463-495`, which the hosted server feeds to rmcp via `with_allowed_hosts`, +`lib.rs:274`); up only for the handshake. Even so, a rebinding attacker is largely inert: `/redeem` only advances the connect *this* process started (`state` match) and `registration_identity` rejects any chain not targeting our freshly-minted `X`. @@ -323,48 +362,70 @@ Decisions for the implementation PR (recommendations first): ## 10. Risks to verify against PRODUCTION II -The single biggest external dependency: the in-repo II contract was verified only against -**beta** II (`fgte5-…`, `identities.rs:814`, `auth.rs:76`), but this binary targets -**production** II (`rdmx6-…`). Per the README, `/mcp-prod` "only completes once the production -II carries the `#4086` MCP feature set" (`README.md:724`). Verify against live `id.ai`: -1. that production II implements the connect handshake — `/mcp` link, `mcp_register_v2` - (the `variant { Ok: record { expiration; permissions }; Err }` shape) and the `#4091` - well-known validation; -2. **mixed content:** II's https document must `fetch()` `http://127.0.0.1:/.well-known/…`. - Loopback is "potentially trustworthy" (W3C Secure Contexts), so Chrome/Firefox allow - https→http-loopback — prefer the `127.0.0.1` literal over the `localhost` name; Safari and - enterprise policies are the unknowns; -3. **CORS:** the well-known response needs `Access-Control-Allow-Origin` (II fetches it +This section's original premise — that only beta II was verified — is resolved on main: +since #92 the hosted deployment serves **production II at `/mcp` on every deploy** +(`main.rs:376-385`; beta is the opt-in `/mcp-beta` staging instance), CI probes production +health on a schedule (#113), and the crate gained a hermetic end-to-end test of the full +connect contract — `src/e2e_handshake.rs` (`--features e2e`, optional `pocket-ic` dep, +needs `II_WASM` + `POCKET_IC_BIN`) drives register → authorize → the II +registration-delegation ceremony → redeem → a real `mcp_register_v2` → token against a real +Internet Identity release build in PocketIC. So "does prod II implement the handshake" is +answered in production. (Chains are also now verified client-side against the injected +agent's root key — `new_with_root_key`, `identities.rs:1285-1303` — so a binary pointed at a +test network can complete redemption after `fetch_root_key`, which is what makes a +PocketIC-based test of the *local* flow possible.) + +What remains open is specific to the **local binary's `http://127.0.0.1` callback**, which +the hosted https deployment never exercises: +1. **Mixed content:** II's https document must `fetch()` + `http://127.0.0.1:/.well-known/…`. Loopback is "potentially trustworthy" (W3C Secure + Contexts), so Chrome/Firefox allow https→http-loopback — prefer the `127.0.0.1` literal + over the `localhost` name; Safari and enterprise policies are the unknowns. +2. **CORS:** the well-known response needs `Access-Control-Allow-Origin` (II fetches it cross-origin; `*` is fine with `credentials: omit`). -Mitigation while prod II catches up: the existing env overrides let the binary point at beta -II, which is verified end-to-end today. +Verification plan: extend the PocketIC e2e harness to drive the local browser-handshake flow +(loopback listener + slim redeem) with no live network; the +`II_URL_PROD`/`II_CANISTER_ID_PROD` overrides remain available to point a binary at beta II. ## 11. Work breakdown **Phase 1 — carve out the core (no behavior change to the `imcp2` binary).** Make the `imcp2` library the transport/OAuth-agnostic core: keep `identities/calls/discover/management/ skills/tools` + `static/` + connect assets, extract `iiconnect` from `auth.rs`, and put the -OAuth AS + `McpServer`/`main.rs` behind the default-on `hosted` feature (optional -`axum`/`tower-http`). Apply the `SessionSource` seam (§8) and drop the 4 dead deps + -`schemars 0.8`. *Exit:* the `imcp2` binary builds and its tests pass unchanged; `imcp2` with -`default-features = false` compiles a minimal closure. +OAuth AS + `McpServer`/`main.rs` + `pub mod metrics` behind the default-on `hosted` feature +(optional `axum`/`tower-http`/`prometheus`), threading `McpConfig`'s +`state_dir`/`clients`/`require_resource` hosted-side and keeping the `e2e` feature compiling. +(The vestigial-dep cleanup this phase originally included was already done on main, #100.) +Apply the `SessionSource` seam (§8). *Exit:* the `imcp2` binary builds and its tests pass +unchanged; `imcp2` with `default-features = false` compiles a minimal closure. **Phase 2 — the local binary.** `imcp2-local`: stdio `IcTools` server + the browser-handshake login driver + the loopback callback listener. *Exit:* `cargo build -p imcp2-local`; a user logs in against II and runs read/write tools as their accounts. **Phase 3 — polish.** Docs (how to add the binary to an MCP client config; the wallet-grade -trust note), the production-II verification (§10), optional session persistence. +trust note), extend the PocketIC e2e harness to the local login flow (§10), optional session +persistence. ## 12. Evidence index -- Dead deps (0 refs): `ed25519-dalek`/`p256`/`ic-signature-verification`/ - `ic-representation-independent-hash`; Ed25519 via `ic-agent` `identities.rs:1044`; replica - verifies chains `identities.rs:1064`. `schemars` via `use rmcp::schemars` (all modules). -- rmcp features + streamable-HTTP wiring: `Cargo.toml:12`, `lib.rs:83,194-208`. -- OAuth AS vs II-connect split: `auth.rs:24-60` (module docs), handlers `auth.rs:566/1440/1570`, - connect subset `auth.rs:750/808-978/1207-1245/1302`, `#4091` `auth.rs:770/785`. -- Session seam: `auth.rs:1664/1691`, `tools.rs:1475`, 13 call-sites listed in §8. -- II login primitives: `identities.rs:501/821`, `ii_mcp_url` `auth.rs:750`. -- Prod-vs-beta verification caveat: `identities.rs:79/814`, `README.md:724`. +(Line refs re-stamped against the v0.2.0 tree after the rebase onto `189fabd`.) + +- Vestigial deps: already removed on main in #100 (`0e52fe9`); Ed25519 via `ic-agent` + (`fresh_ed25519`, `identities.rs:1252`); chains verified by `ic-agent` client-side + (`new_with_root_key`, `identities.rs:1285-1303`) and by the replica. `schemars` via + `use rmcp::schemars` (all modules). +- rmcp features + streamable-HTTP wiring: `Cargo.toml:35-40`, `lib.rs:102-105,258-276`. +- New hosted-only surfaces: `prometheus`/`src/metrics.rs` (`lib.rs:76-81`, `Cargo.toml:64-69`, + `MCP_SERVE_METRICS`); `McpConfig.state_dir`/`require_resource` (`lib.rs:144-158`, + `IMCP2_STATE_DIR`/`OAUTH_REQUIRE_RESOURCE` in `main.rs`). +- OAuth AS vs II-connect split: `auth.rs:24-60` (module docs), handlers + `auth.rs:1053/1987/2139`, connect subset `auth.rs:1284/1342-1515/1673-1782/1839`, + `#4091` `auth.rs:1304/1319`. +- Session seam: `auth.rs:2264/2292`, `tools.rs:1493`, 13 call-sites listed in §8. +- II login primitives: `identities.rs:595/936`, `ii_mcp_url` `auth.rs:1284`. +- Production II served at `/mcp` since #92 (`main.rs:376-385`); PocketIC e2e handshake test + `src/e2e_handshake.rs` (`e2e` feature); beta II constants `identities.rs:121-128`, candid + verification note `identities.rs:929-935`, generic live-round-trip caveat + `README.md:993-999`. From 438fef7cfba2a28335d97c53b206e2b527d8731e Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 20 Aug 2026 16:17:29 +0000 Subject: [PATCH 06/23] Restructure the design doc to the standard feature-doc shape 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 Claude-Session: https://claude.ai/code/session_01AqNkpMQiQHzYxC2djTfvBK --- docs/scoping-local-deployment.md | 244 +++++++++++++++++++------------ 1 file changed, 150 insertions(+), 94 deletions(-) diff --git a/docs/scoping-local-deployment.md b/docs/scoping-local-deployment.md index 1e85adb..45d9320 100644 --- a/docs/scoping-local-deployment.md +++ b/docs/scoping-local-deployment.md @@ -1,42 +1,93 @@ -# Scoping: a minimal local MCP server binary (stdio, no OAuth) - -Status: **draft / scoping** — analysis and design only, no code changes here. - -## 1. What "local deployment" means - -A second, **separate binary** that a user runs **on their own machine** (run-it-yourself), -reached by a co-located MCP client (Claude Desktop, a local Claude Code, …). It still -talks to **mainnet IC** (`https://icp-api.io`) and **production Internet Identity** -(`https://id.ai`, `rdmx6-jaaaa-aaaaa-aaadq-cai`) — it is **not** a local `dfx` replica. - -The distinction from the hosted server is the **deployment axis**, not the network: - -| | Hosted `imcp2` (today) | Local binary (this scope) | +# IMCP2 local deployment: a minimal stdio binary + +Status: **draft** — design only, no code changes in this PR. + +## Summary + +We will ship **`imcp2-local`**: a separate, minimal binary that a user runs on their own +machine and connects to their AI tool of choice. It speaks MCP over **stdio**, talks to +**mainnet IC** (`https://icp-api.io`) and **production Internet Identity** (`https://id.ai`), +keeps the full Internet Identity session model (login with the user's real anchor, on-demand +per-app account delegations), and **drops the entire OAuth 2.1 authorization-server layer**. +Login runs as a **built-in browser handshake**: the binary opens the user's browser to II and +receives the delegation on a transient localhost callback. + +The existing crate keeps its name and role: **`imcp2`** remains the shared library (published +on crates.io) and the hosted server binary; the hosted-only surfaces move behind a default-on +`hosted` cargo feature so `imcp2-local` compiles a genuinely minimal dependency closure. + +## Problem + +Today the only way to use the IMCP2 tools is the **hosted** server: a public, multi-tenant +streamable-HTTP endpoint gated by an OAuth 2.1 authorization server. That shape is right for +cloud AI surfaces, but it forces two costs on a user who works from a desktop/CLI/IDE tool: + +- **Trust.** The user's Internet Identity login — and every per-app account delegation minted + from it — runs through a third-party server. A user acting on their real mainnet accounts + (balances, canisters, cycles) may reasonably want the bridge to run on their own machine, + where the session keys never leave a process they control. +- **Unnecessary machinery.** The OAuth 2.1 layer (bearer tokens, PKCE, dynamic client + registration, redirect allow-lists, discovery documents) exists so *remote, third-party* + clients can authenticate to a *public* endpoint. A single-user server reached over a stdio + pipe by the client that spawned it needs none of it — carrying it anyway means more + dependencies, more attack surface, and an operational state directory the local case + doesn't want. + +Meanwhile, the AI tools users actually run — Claude Desktop/Code, Codex, Cursor, Antigravity, +Perplexity's macOS app — all support spawning **local stdio MCP servers**, and their built-in +OAuth support applies only to *remote* servers. There is currently no IMCP2 artifact that +fits that slot. + +## Non-goals + +- **Not a local `dfx` replica bridge.** The local binary targets mainnet + production II. + There is no `fetch_root_key`, no SSRF-guard relaxation, no local II. (A replica-targeting + mode was scoped earlier and explicitly rejected.) +- **No change to the hosted server's behavior, name, or deployment.** The `imcp2` binary, + Dockerfile (`CMD ["imcp2"]`, `Dockerfile:23,31`), `imcp2.service`, and deploy scripts stay + as they are. +- **No session persistence in v1.** Sessions are in-memory (re-login per run), matching the + hosted model and the existing roadmap; keychain-backed persistence is future work. +- **Not a path for cloud-only AI surfaces.** claude.ai web/mobile, Perplexity web/Windows, + and Codex Cloud cannot reach `localhost`; they keep using the hosted server (which is why + the OAuth layer stays in `imcp2` rather than being deleted). +- **No new auth scheme.** The local binary adds no API keys or tokens of its own; the OS + process boundary replaces the bearer gate, and Internet Identity remains the only login. + +## Approach + +Hosted-vs-local is a **deployment axis, not a network one** — both talk to the same mainnet +and the same production II. What changes is who can reach the server and therefore what +machinery is needed: + +| | Hosted `imcp2` (today) | `imcp2-local` (this design) | |---|---|---| | Where it runs | a public server | the user's own machine | | MCP transport | streamable-HTTP | **stdio** | | Reached by | remote, third-party MCP clients | the co-located client that spawned it | | Client auth | **OAuth 2.1** (bearer tokens, PKCE, DCR) | **none** (process boundary) | | II login | OAuth-wrapped connect handshake | **built-in browser handshake** | -| IC / II target | mainnet + beta/prod II | **mainnet + production II** | +| IC / II target | mainnet + prod II (beta opt-in) | mainnet + production II | -Because it is single-user and reachable only through the pipe of the client that launched -it, the entire OAuth 2.1 authorization-server layer — which exists so *remote* clients can -authenticate to a *public* endpoint — is unnecessary. Internet Identity itself is **kept**: -the user still logs in with their real anchor and acts as their real II accounts on real -mainnet apps, from a bridge they run themselves rather than trusting a hosted third party. - -Design decisions locked with the requester: -- **Separate binary**, minimal dependencies (Cargo workspace split, not a runtime flag). +Decisions locked with the requester: +- **Separate binary**, minimal dependencies (a second crate, not a runtime flag). - **stdio** MCP transport. - **No OAuth 2.1** authorization server. - **Built-in browser II handshake**: the binary mints the session key, opens the browser to production II, receives the delegation on a transient localhost callback, redeems it, and holds the session in memory. -All findings below were verified against the current code; `file:line` references point at it. +Because it is single-user and reachable only through the pipe of the client that launched it, +dropping OAuth removes no security the local case needs — while keeping Internet Identity +preserves exactly what matters: the user logs in with their real anchor and acts as their +real II accounts on real mainnet apps, from a bridge they run themselves. + +Every claim below was verified against the current code (v0.2.0 tree); `file:line` +references point at it. + +## Design components -## 2. Design overview — the `imcp2` crate + a minimal `imcp2-local` binary +### 1. Crate layout — `imcp2` + a minimal `imcp2-local` The existing crate stays **`imcp2`** (its published, embeddable identity — `Cargo.toml:2`), and its default binary stays **`imcp2`** (the Dockerfile `CMD ["imcp2"]`, `imcp2.service`, and @@ -57,7 +108,7 @@ the deploy scripts all build/run a binary named `imcp2` — `Dockerfile:23,31`, `IcTools` and **must stay co-located**; both binaries construct `IcTools` and differ only in transport + session source. - **new `iiconnect` module** — the II connect-handshake primitives lifted out of - `auth.rs` (see §5), re-parameterised to plain values so they carry no + `auth.rs` (component 4), re-parameterised to plain values so they carry no `AuthStore`/OAuth state. - The **hosted binary** (`[[bin]] name = "imcp2"`, today's `main.rs`) and the OAuth 2.1 layer (`auth.rs`, `McpServer`/routers in `lib.rs`, the landing page, `tests/routers.rs`) @@ -95,9 +146,9 @@ via `include_str!` — which supports keeping those assets in the core crate. cost of renaming the deployed binary to `imcp2-hosted` (deploy churn). The library changes below are identical either way. -## 3. Dependency stripping (verified) +### 2. Dependency profile -**Already removed on main (#100).** The five vestigial direct deps this doc originally +**Already removed on main (#100).** The five vestigial direct deps this design originally proposed dropping — `ed25519-dalek`, `p256`, `ic-signature-verification`, `ic-representation-independent-hash`, and the top-level `schemars = "0.8"` — were removed on main (commit `0e52fe9`); none appears in `Cargo.toml` at v0.2.0, so no stripping work @@ -138,16 +189,17 @@ OAuth persist throttle — subject to what rmcp's stdio transport itself require `pocket-ic` (new on main, optional behind the `e2e` feature for `src/e2e_handshake.rs`) never enters any binary's closure — no action needed. -**`axum`** shrinks to at most the transient login callback (§6). The recommendation is to -**hand-roll** that 3-route loopback listener so the local crate drops `axum`/`tower-http` -entirely; reusing axum is the lower-effort fallback (see §9 decision). +**`axum`** shrinks to at most the transient login callback (component 5). The recommendation +is to **hand-roll** that 3-route loopback listener so the local crate drops +`axum`/`tower-http` entirely; reusing axum is the lower-effort fallback (an open decision +under Implementation Stages). Net minimal local deps: `imcp2` (with `default-features = false`) + `rmcp{server,macros,transport-io}` + `tokio` + `anyhow` + `serde_json` + `url`/`urlencoding` + `tracing`/`tracing-subscriber` + a browser-opener (`open` crate, or `std::process::Command`). -## 4. Talking to mainnet + production II +### 3. Mainnet and production II wiring No replica changes: the local agent is `Agent::builder().with_url(IC_URL).build()` with `IC_URL = "https://icp-api.io"` and **no** `fetch_root_key` (mainnet root key is baked in). @@ -159,9 +211,10 @@ The prod instance is env-overridable via `II_URL_PROD`/`II_CANISTER_ID_PROD` (`identities.rs:185-190`; the plain `II_URL`/`II_CANISTER_ID` pair now overrides only `IiInstance::beta()`), so the binary can still be pointed at beta II for testing. Since #92 the hosted deployment itself serves **production II at `/mcp`** by default (beta is the -opt-in `/mcp-beta` staging instance), so prod II is no longer the unverified path (see §10). +opt-in `/mcp-beta` staging instance), so prod II is no longer the unverified path (see +Verification under Implementation Stages). -## 5. Dropping OAuth 2.1, keeping the II handshake +### 4. Dropping OAuth 2.1, keeping the II handshake `auth.rs` splits along the boundary its own module docs already draw (`auth.rs:24-60`). @@ -185,7 +238,7 @@ opt-in `/mcp-beta` staging instance), so prod II is no longer the unverified pat per `McpConfig::state_dir`, `auth.rs:799-820`), the hosted-redirect allow-list (`auth.rs:434-687`), AS/PR discovery metadata (`auth.rs:2223-2262`), the `require_token` bearer gate + `bearer_challenge` (`auth.rs:2264,2313`), the front-channel HTML error-screen -machinery, and the OAuth hardening added since this doc's first draft — RFC 8707 +machinery, and the OAuth hardening added since this design's first draft — RFC 8707 resource-indicator enforcement (the `require_resource` flag, #127), RFC 9207 `iss` emission on redirects (#125), and the bounded/atomically-persisted DCR store (#137) — all on the OAuth side, so the partition is unchanged. `AuthStore` slims to @@ -200,10 +253,10 @@ public, multi-tenant initiate endpoint an attacker can start a connect on. Local **no HTTP initiate endpoint**: the binary itself mints `X`, `priv(X)` never leaves the process, and `/redeem` is loopback-only and single-user. The consenter proof alone — the delegation's final hop must target the in-memory `X`, replica-verified at redeem -(`registration_identity`, `identities.rs:1066`) — suffices; keep the random `state` as the +(`registration_identity`, `identities.rs:1285`) — suffices; keep the random `state` as the callback↔connect correlator. -## 6. The built-in browser II login +### 5. The built-in browser II login 1. Build the mainnet agent + `Identities` (prod II) once. 2. Mint the session: `registration_pubkey_b64(&session_id)` → in-memory Ed25519 `S` + the @@ -214,10 +267,10 @@ callback↔connect correlator. `http://127.0.0.1:`. Both the II link's `callback` and the well-known entry derive from this one value, so they cannot drift (II matches by exact string equality). 4. Build the II link (`iiconnect::ii_mcp_url`) against `https://id.ai` and surface it to the - user **in-band** — as the text result of an `authenticate` MCP tool (§7) — plus a + user **in-band** — as the text result of an `authenticate` MCP tool (component 6) — plus a best-effort server-side browser auto-open. Do **not** rely on **stderr** for the URL: every - client routes a stdio server's stderr to a log file/panel, never the chat (§7). stdout is - the JSON-RPC channel, so all logging stays on stderr. + client routes a stdio server's stderr to a log file/panel, never the chat (component 6). + stdout is the JSON-RPC channel, so all logging stays on stderr. 5. Serve exactly three loopback routes: `GET /callback` (the pinned fragment-reading page), `POST /redeem` (slim redeem → `redeem_registration_delegation`), and `GET /.well-known/ii-auth-callbacks` (the `#4091` allow-list, one `Access-Control-Allow-Origin: *` @@ -230,13 +283,13 @@ authenticated action — not at startup (most clients require the user to approv call, and some cap `initialize` at ~10 s) — and it returns the URL immediately rather than blocking on the callback (Codex times out a tool call at 60 s; Claude Code auto-backgrounds calls over 2 min). A follow-up `auth_status` tool (or simply the next tool call) confirms the -grant landed. §7 covers the per-client specifics. +grant landed. Component 6 covers the per-client specifics. Sessions are **in-memory** (re-login per run), matching today's model and the roadmap. Optional future work: persist the session seed `S` to an OS keychain to survive restarts — but `S` is a live capability to the user's real anchor, so never plaintext. -## 7. Working with AI tool clients +### 6. Working with AI tool clients The local binary is a **stdio** MCP server, so it is reachable by any client that can **spawn a local subprocess**, and unreachable by one that only connects to a remote **URL**. The five @@ -258,8 +311,8 @@ requested clients split cleanly along that line (verified against current docs, Codex (CLI/IDE/desktop), Cursor, Antigravity, and the **Perplexity macOS app** — runs `imcp2-local` directly. The cloud/remote-only surfaces — claude.ai web/mobile, Perplexity web/Windows, Codex Cloud — cannot reach `localhost`; they need the **hosted `imcp2`** server -(the OAuth path kept in §5). This is precisely why the OAuth layer stays in `imcp2` rather than -being deleted: it is the only way to serve the cloud clients. +(the OAuth path kept in component 4). This is precisely why the OAuth layer stays in `imcp2` +rather than being deleted: it is the only way to serve the cloud clients. **Registration** (absolute binary path everywhere; `imcp2-local` needs no args): @@ -281,8 +334,8 @@ being deleted: it is the only way to serve the cloud clients. **Cross-client login invariants** (every subprocess-capable client agreed): 1. **Host OAuth never touches a stdio server.** All five drive OAuth only for *remote* servers; for a local stdio server the host just pipes stdin/stdout. So the II login is entirely the - binary's own browser handshake (§6) — which also sidesteps Antigravity's known-buggy remote - MCP-OAuth. + binary's own browser handshake (component 5) — which also sidesteps Antigravity's + known-buggy remote MCP-OAuth. 2. **stderr is not shown in chat — anywhere.** Claude, Codex, Cursor, and Antigravity all route a stdio server's stderr to a log file/panel, and stdout is reserved for JSON-RPC. So the login URL is surfaced **in-band**: the `authenticate` tool returns it as text (the model @@ -309,7 +362,7 @@ OAuth has an open DCR bug that rejects RFC 7591 public-client registrations lack `client_secret` (the same registrations that work on Claude/ChatGPT/Grok). Neither affects the local binary. -## 8. Tool / session seam +### 7. Tool / session seam Today a tool gets its `session_id` via bearer → `require_token` → `AuthedSession` injected in the request extensions → `authed_session(ctx)` (`tools.rs:1493`). Under stdio there is one @@ -330,7 +383,7 @@ Tools that are already session-free work unchanged locally: `get_canister_candid `get_canister_api_doc`, `open_app`, `resolve_app`, `discover_app_canisters`, the skills/lookup tools, and the anonymous path of `canister_query`. -## 9. Security model & open decisions +### 8. Security model **Trust boundary.** Dropping the bearer gate is sound *only because* the transport is stdio: a stdio server has no listening socket — it is reachable only by the parent process holding @@ -348,32 +401,40 @@ handshake. Even so, a rebinding attacker is largely inert: `/redeem` only advanc *this* process started (`state` match) and `registration_identity` rejects any chain not targeting our freshly-minted `X`. -Decisions for the implementation PR (recommendations first): -1. **Loopback listener: hand-roll vs reuse axum.** *Recommend hand-roll* (3 routes, one CORS - header) so the local crate drops `axum`/`tower-http` — the security-sensitive page/CSP is a - static asset reused from core, not re-derived. Reuse-axum is the lower-effort fallback. -2. **Browser open:** `open`/`webbrowser` crate (convenience) vs `std::process::Command` - (zero-dep). *Recommend* always return the URL in-band (the `authenticate` tool result) + - best-effort auto-open; the flow never depends on auto-open succeeding. -3. **Login timing:** *resolved by the client research (§7)* — lazy on the first authenticated - tool call, never at startup (clients gate the first call on approval and cap `initialize`), - and non-blocking. -4. **Session persistence:** in-memory only (recommend for v1) vs keychain-backed `S`. - -## 10. Risks to verify against PRODUCTION II - -This section's original premise — that only beta II was verified — is resolved on main: -since #92 the hosted deployment serves **production II at `/mcp` on every deploy** -(`main.rs:376-385`; beta is the opt-in `/mcp-beta` staging instance), CI probes production -health on a schedule (#113), and the crate gained a hermetic end-to-end test of the full -connect contract — `src/e2e_handshake.rs` (`--features e2e`, optional `pocket-ic` dep, -needs `II_WASM` + `POCKET_IC_BIN`) drives register → authorize → the II -registration-delegation ceremony → redeem → a real `mcp_register_v2` → token against a real -Internet Identity release build in PocketIC. So "does prod II implement the handshake" is -answered in production. (Chains are also now verified client-side against the injected -agent's root key — `new_with_root_key`, `identities.rs:1285-1303` — so a binary pointed at a -test network can complete redemption after `fetch_root_key`, which is what makes a -PocketIC-based test of the *local* flow possible.) +## Implementation Stages + +**Stage 1 — carve out the core (no behavior change to the `imcp2` binary).** Make the +`imcp2` library the transport/OAuth-agnostic core: keep `identities/calls/discover/management/ +skills/tools` + `static/` + connect assets, extract `iiconnect` from `auth.rs`, and put the +OAuth AS + `McpServer`/`main.rs` + `pub mod metrics` behind the default-on `hosted` feature +(optional `axum`/`tower-http`/`prometheus`), threading `McpConfig`'s +`state_dir`/`clients`/`require_resource` hosted-side and keeping the `e2e` feature compiling. +(The vestigial-dep cleanup this stage originally included was already done on main, #100.) +Apply the `SessionSource` seam (component 7). *Exit:* the `imcp2` binary builds and its tests +pass unchanged; `imcp2` with `default-features = false` compiles a minimal closure. + +**Stage 2 — the local binary.** `imcp2-local`: stdio `IcTools` server + the browser-handshake +login driver + the loopback callback listener. *Exit:* `cargo build -p imcp2-local`; a user +logs in against II and runs read/write tools as their accounts. + +**Stage 3 — polish.** Docs (how to add the binary to an MCP client config; the wallet-grade +trust note), extend the PocketIC e2e harness to the local login flow (see Verification), +optional session persistence. + +### Verification against production II + +The original concern here — that only beta II was verified — is resolved on main: since #92 +the hosted deployment serves **production II at `/mcp` on every deploy** (`main.rs:376-385`; +beta is the opt-in `/mcp-beta` staging instance), CI probes production health on a schedule +(#113), and the crate gained a hermetic end-to-end test of the full connect contract — +`src/e2e_handshake.rs` (`--features e2e`, optional `pocket-ic` dep, needs `II_WASM` + +`POCKET_IC_BIN`) drives register → authorize → the II registration-delegation ceremony → +redeem → a real `mcp_register_v2` → token against a real Internet Identity release build in +PocketIC. So "does prod II implement the handshake" is answered in production. (Chains are +also now verified client-side against the injected agent's root key — `new_with_root_key`, +`identities.rs:1285-1303` — so a binary pointed at a test network can complete redemption +after `fetch_root_key`, which is what makes a PocketIC-based test of the *local* flow +possible.) What remains open is specific to the **local binary's `http://127.0.0.1` callback**, which the hosted https deployment never exercises: @@ -388,29 +449,24 @@ Verification plan: extend the PocketIC e2e harness to drive the local browser-ha (loopback listener + slim redeem) with no live network; the `II_URL_PROD`/`II_CANISTER_ID_PROD` overrides remain available to point a binary at beta II. -## 11. Work breakdown +### Open decisions -**Phase 1 — carve out the core (no behavior change to the `imcp2` binary).** Make the -`imcp2` library the transport/OAuth-agnostic core: keep `identities/calls/discover/management/ -skills/tools` + `static/` + connect assets, extract `iiconnect` from `auth.rs`, and put the -OAuth AS + `McpServer`/`main.rs` + `pub mod metrics` behind the default-on `hosted` feature -(optional `axum`/`tower-http`/`prometheus`), threading `McpConfig`'s -`state_dir`/`clients`/`require_resource` hosted-side and keeping the `e2e` feature compiling. -(The vestigial-dep cleanup this phase originally included was already done on main, #100.) -Apply the `SessionSource` seam (§8). *Exit:* the `imcp2` binary builds and its tests pass -unchanged; `imcp2` with `default-features = false` compiles a minimal closure. - -**Phase 2 — the local binary.** `imcp2-local`: stdio `IcTools` server + the browser-handshake -login driver + the loopback callback listener. *Exit:* `cargo build -p imcp2-local`; a user -logs in against II and runs read/write tools as their accounts. +1. **Loopback listener: hand-roll vs reuse axum.** *Recommend hand-roll* (3 routes, one CORS + header) so the local crate drops `axum`/`tower-http` — the security-sensitive page/CSP is a + static asset reused from core, not re-derived. Reuse-axum is the lower-effort fallback. +2. **Browser open:** `open`/`webbrowser` crate (convenience) vs `std::process::Command` + (zero-dep). *Recommend* always return the URL in-band (the `authenticate` tool result) + + best-effort auto-open; the flow never depends on auto-open succeeding. +3. **Session persistence:** in-memory only (recommend for v1) vs keychain-backed `S`. +4. **Crate layout:** `imcp2` + `imcp2-local` with a `hosted` feature (recommended, keeps the + `imcp2` binary name) vs the stricter three-crate split (component 1). -**Phase 3 — polish.** Docs (how to add the binary to an MCP client config; the wallet-grade -trust note), extend the PocketIC e2e harness to the local login flow (§10), optional session -persistence. +(Login timing was resolved by the client research — lazy on the first authenticated tool +call, non-blocking.) -## 12. Evidence index +## Appendix: evidence index -(Line refs re-stamped against the v0.2.0 tree after the rebase onto `189fabd`.) +(Line refs stamped against the v0.2.0 tree, rebased onto `189fabd`.) - Vestigial deps: already removed on main in #100 (`0e52fe9`); Ed25519 via `ic-agent` (`fresh_ed25519`, `identities.rs:1252`); chains verified by `ic-agent` client-side @@ -423,7 +479,7 @@ persistence. - OAuth AS vs II-connect split: `auth.rs:24-60` (module docs), handlers `auth.rs:1053/1987/2139`, connect subset `auth.rs:1284/1342-1515/1673-1782/1839`, `#4091` `auth.rs:1304/1319`. -- Session seam: `auth.rs:2264/2292`, `tools.rs:1493`, 13 call-sites listed in §8. +- Session seam: `auth.rs:2264/2292`, `tools.rs:1493`, 13 call-sites listed in component 7. - II login primitives: `identities.rs:595/936`, `ii_mcp_url` `auth.rs:1284`. - Production II served at `/mcp` since #92 (`main.rs:376-385`); PocketIC e2e handshake test `src/e2e_handshake.rs` (`e2e` feature); beta II constants `identities.rs:121-128`, candid From 0a88e9d389e93ab5d09b2433f3c944a9f8ca9b55 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 20 Aug 2026 16:25:41 +0000 Subject: [PATCH 07/23] Add a concise high-level companion to the local-deployment design 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 Claude-Session: https://claude.ai/code/session_01AqNkpMQiQHzYxC2djTfvBK --- docs/local-deployment.md | 88 ++++++++++++++++++++++++++++++++ docs/scoping-local-deployment.md | 4 +- 2 files changed, 91 insertions(+), 1 deletion(-) create mode 100644 docs/local-deployment.md diff --git a/docs/local-deployment.md b/docs/local-deployment.md new file mode 100644 index 0000000..2b87f26 --- /dev/null +++ b/docs/local-deployment.md @@ -0,0 +1,88 @@ +# IMCP2 local deployment — overview + +Status: **draft**. This is the concise, high-level doc; the full design with code-level +detail and evidence lives in [`scoping-local-deployment.md`](scoping-local-deployment.md). + +## Summary + +We will ship **`imcp2-local`**: a small binary users run on their own machine and register +with their AI tool (Claude, Codex, Cursor, …) as a local MCP server. It speaks MCP over +**stdio**, talks to **mainnet** and **production Internet Identity**, and keeps the full II +experience — the user logs in with their real anchor via a browser handshake, and the server +mints per-app account delegations on demand. What it drops is the entire **OAuth 2.1 +authorization server**: a single-user process reached over a pipe needs no bearer tokens. +The existing `imcp2` crate remains the shared library and the hosted server. + +## Problem + +Today IMCP2 exists only as a hosted, multi-tenant server: a public streamable-HTTP endpoint +gated by OAuth 2.1. For someone working from a desktop or IDE AI tool this has two costs: + +- **Trust.** Their Internet Identity login — and every delegation minted from it — runs + through a third-party server, even though they are acting on their own mainnet accounts. +- **Weight.** OAuth exists so *remote* clients can authenticate to a *public* endpoint. A + local single-user server inherits all of that machinery (tokens, PKCE, client + registration, discovery documents, a state directory) without needing any of it. + +At the same time, every major desktop AI tool can spawn **local stdio MCP servers** — and +there is no IMCP2 artifact that fits that slot. + +## Non-goals + +- **Not a local `dfx` replica bridge** — the binary targets mainnet and production II only. +- **No change to the hosted server** — its name, behavior, and deployments stay as they are. +- **No session persistence in v1** — sessions are in-memory; users re-login per run. +- **Not for cloud-only AI surfaces** — claude.ai web/mobile, Perplexity web, and Codex Cloud + cannot reach a local process; they continue to use the hosted server. + +## Approach + +Hosted-vs-local is a **deployment axis, not a network one**: both talk to the same mainnet +and the same production II. What changes is who can reach the server, and therefore what +machinery is needed. + +- **stdio transport.** The server has no listening socket; it is reachable only by the AI + tool that spawned it. The OS process boundary replaces the OAuth bearer gate. +- **Internet Identity stays.** On first authenticated use the binary opens the user's + browser to II, receives the delegation on a transient localhost callback, redeems it, and + holds the session in memory — the same handshake the hosted server runs, minus the OAuth + wrapper around it. +- **Login is an in-band tool.** No AI client shows a stdio server's logs in chat, so the + login URL is returned as the result of an `authenticate` tool (with best-effort browser + auto-open), lazily on first use and without blocking on the callback. +- **One library, two binaries.** `imcp2` stays the shared core (and the hosted binary, + behind a default-on `hosted` feature); `imcp2-local` is a new, small crate that compiles + none of the OAuth/HTTP machinery. + +## Design components + +1. **Crate layout** — `imcp2` (library + hosted binary; hosted-only surfaces behind the + `hosted` feature) and `imcp2-local` (new minimal binary). +2. **Dependency profile** — the local closure is the MCP stack (stdio), `ic-agent`/Candid, + and the discovery/management essentials; no axum, no CORS layer, no metrics stack, no + OAuth persistence. +3. **IC + II wiring** — a mainnet agent and production II, env-overridable for staging. +4. **Auth partition** — keep the II connect-handshake primitives (link builder, callback + page, delegation parser, the II callback allow-list, a slim redeem); drop the whole + OAuth authorization server. +5. **Browser login** — three transient loopback routes during the handshake; the grant then + lives in memory only. +6. **AI tool clients** — Claude Desktop, Claude Code, Codex, Cursor, Antigravity, and the + Perplexity macOS app run `imcp2-local` directly; cloud surfaces keep using hosted. +7. **Session seam** — the hosted bearer-token lookup collapses to a singleton session id; + a small, verified change with no impact on tool behavior. +8. **Security model** — whoever drives the binary acts as the user's real II accounts on + mainnet: treat the binary and its client config like a wallet. + +## Implementation Stages + +1. **Carve out the core.** Move the hosted-only surfaces behind the `hosted` feature and + introduce the session seam — no behavior change to the hosted server. +2. **Ship `imcp2-local`.** The stdio server, the browser-login driver, and the loopback + callback listener. Exit: a user logs in against II and uses the tools as their accounts. +3. **Polish.** Per-client setup docs and the wallet-grade trust note; extend the existing + PocketIC end-to-end harness to cover the local login flow; optional keychain-backed + session persistence. + +Open decisions (details in the full doc): hand-rolled vs axum-based loopback listener; how +to open the browser; v1 session persistence; two-crate vs three-crate layout. diff --git a/docs/scoping-local-deployment.md b/docs/scoping-local-deployment.md index 45d9320..610e30c 100644 --- a/docs/scoping-local-deployment.md +++ b/docs/scoping-local-deployment.md @@ -1,6 +1,8 @@ # IMCP2 local deployment: a minimal stdio binary -Status: **draft** — design only, no code changes in this PR. +Status: **draft** — design only, no code changes in this PR. This is the **full** design doc, +carrying code-level detail and evidence; a concise, high-level companion lives in +[`local-deployment.md`](local-deployment.md). ## Summary From fcaa62e9ce81356268cceb676d65eec5a39380d3 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 20 Aug 2026 16:30:38 +0000 Subject: [PATCH 08/23] Refer to ICP CLI-spawned local replicas, not dfx 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 Claude-Session: https://claude.ai/code/session_01AqNkpMQiQHzYxC2djTfvBK --- docs/local-deployment.md | 3 ++- docs/scoping-local-deployment.md | 7 ++++--- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/docs/local-deployment.md b/docs/local-deployment.md index 2b87f26..975735e 100644 --- a/docs/local-deployment.md +++ b/docs/local-deployment.md @@ -29,7 +29,8 @@ there is no IMCP2 artifact that fits that slot. ## Non-goals -- **Not a local `dfx` replica bridge** — the binary targets mainnet and production II only. +- **Not a local-replica bridge** — the binary targets mainnet and production II only, not + the local replicas developers spawn with the ICP CLI. - **No change to the hosted server** — its name, behavior, and deployments stay as they are. - **No session persistence in v1** — sessions are in-memory; users re-login per run. - **Not for cloud-only AI surfaces** — claude.ai web/mobile, Perplexity web, and Codex Cloud diff --git a/docs/scoping-local-deployment.md b/docs/scoping-local-deployment.md index 610e30c..86ceea2 100644 --- a/docs/scoping-local-deployment.md +++ b/docs/scoping-local-deployment.md @@ -42,9 +42,10 @@ fits that slot. ## Non-goals -- **Not a local `dfx` replica bridge.** The local binary targets mainnet + production II. - There is no `fetch_root_key`, no SSRF-guard relaxation, no local II. (A replica-targeting - mode was scoped earlier and explicitly rejected.) +- **Not a local-replica bridge.** The local binary targets mainnet + production II — not the + local replicas developers spawn with the ICP CLI. There is no `fetch_root_key`, no + SSRF-guard relaxation, no local II. (A replica-targeting mode was scoped earlier and + explicitly rejected.) - **No change to the hosted server's behavior, name, or deployment.** The `imcp2` binary, Dockerfile (`CMD ["imcp2"]`, `Dockerfile:23,31`), `imcp2.service`, and deploy scripts stay as they are. From 975181bb78b0604555e1ac273c0c4f0ab9afa0a1 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 20 Aug 2026 16:36:04 +0000 Subject: [PATCH 09/23] Make the local-replica mode a test configuration, not a non-goal 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 Claude-Session: https://claude.ai/code/session_01AqNkpMQiQHzYxC2djTfvBK --- docs/local-deployment.md | 13 +++++++----- docs/scoping-local-deployment.md | 35 ++++++++++++++++++++++++-------- 2 files changed, 34 insertions(+), 14 deletions(-) diff --git a/docs/local-deployment.md b/docs/local-deployment.md index 975735e..2361d88 100644 --- a/docs/local-deployment.md +++ b/docs/local-deployment.md @@ -29,8 +29,10 @@ there is no IMCP2 artifact that fits that slot. ## Non-goals -- **Not a local-replica bridge** — the binary targets mainnet and production II only, not - the local replicas developers spawn with the ICP CLI. +- **Not a local-replica product surface** — the shipped binary targets mainnet and + production II. A **test configuration** that talks to a local replica (as spawned by the + ICP CLI) is deliberately easy to compile and is how integration tests run — but it is not + a v1 user-facing mode. - **No change to the hosted server** — its name, behavior, and deployments stay as they are. - **No session persistence in v1** — sessions are in-memory; users re-login per run. - **Not for cloud-only AI surfaces** — claude.ai web/mobile, Perplexity web, and Codex Cloud @@ -81,9 +83,10 @@ machinery is needed. introduce the session seam — no behavior change to the hosted server. 2. **Ship `imcp2-local`.** The stdio server, the browser-login driver, and the loopback callback listener. Exit: a user logs in against II and uses the tools as their accounts. -3. **Polish.** Per-client setup docs and the wallet-grade trust note; extend the existing - PocketIC end-to-end harness to cover the local login flow; optional keychain-backed - session persistence. +3. **Polish.** Per-client setup docs and the wallet-grade trust note; integration tests that + run `imcp2-local` in its local-replica test configuration (against an ICP CLI-spawned + replica or PocketIC, with an II canister deployed there), extending the existing PocketIC + end-to-end harness to the local login flow; optional keychain-backed session persistence. Open decisions (details in the full doc): hand-rolled vs axum-based loopback listener; how to open the browser; v1 session persistence; two-crate vs three-crate layout. diff --git a/docs/scoping-local-deployment.md b/docs/scoping-local-deployment.md index 86ceea2..5e337bc 100644 --- a/docs/scoping-local-deployment.md +++ b/docs/scoping-local-deployment.md @@ -42,10 +42,12 @@ fits that slot. ## Non-goals -- **Not a local-replica bridge.** The local binary targets mainnet + production II — not the - local replicas developers spawn with the ICP CLI. There is no `fetch_root_key`, no - SSRF-guard relaxation, no local II. (A replica-targeting mode was scoped earlier and - explicitly rejected.) +- **Not a local-replica product surface.** The *shipped* binary targets mainnet + production + II: its default build has no `fetch_root_key`, no SSRF-guard relaxation, no local II. A + **test configuration** that talks to a local replica (as spawned by the ICP CLI) is a + deliberate nice-to-have — easy to compile, guarded so it can never run against mainnet, + and the vehicle for integration testing (see component 3 and Verification) — but it is not + a v1 user-facing mode. - **No change to the hosted server's behavior, name, or deployment.** The `imcp2` binary, Dockerfile (`CMD ["imcp2"]`, `Dockerfile:23,31`), `imcp2.service`, and deploy scripts stay as they are. @@ -217,6 +219,18 @@ the hosted deployment itself serves **production II at `/mcp`** by default (beta opt-in `/mcp-beta` staging instance), so prod II is no longer the unverified path (see Verification under Implementation Stages). +**Local-replica test configuration (nice-to-have; the integration-test vehicle).** The same +wiring supports a test build that talks to a **local replica spawned by the ICP CLI**: an +explicit IC-endpoint override for the agent, plus one `agent.fetch_root_key()` call so +certificate verification trusts the local replica's key, plus the existing II overrides +pointed at an II canister deployed on that replica. Guard: `fetch_root_key` is only ever +called when the endpoint override is explicitly set to a loopback target — refused otherwise +— so a mis-set environment can never make a binary trust a fetched root key against mainnet. +Everything downstream already works on a test network: delegation chains are verified against +the *injected agent's* root key (`new_with_root_key`, `identities.rs:1285-1303`), which is +the same property the PocketIC e2e harness relies on. This configuration is how integration +tests exercise the full local flow end-to-end (see Verification). + ### 4. Dropping OAuth 2.1, keeping the II handshake `auth.rs` splits along the boundary its own module docs already draw (`auth.rs:24-60`). @@ -421,8 +435,9 @@ login driver + the loopback callback listener. *Exit:* `cargo build -p imcp2-loc logs in against II and runs read/write tools as their accounts. **Stage 3 — polish.** Docs (how to add the binary to an MCP client config; the wallet-grade -trust note), extend the PocketIC e2e harness to the local login flow (see Verification), -optional session persistence. +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 +session persistence. ### Verification against production II @@ -448,9 +463,11 @@ the hosted https deployment never exercises: 2. **CORS:** the well-known response needs `Access-Control-Allow-Origin` (II fetches it cross-origin; `*` is fine with `credentials: omit`). -Verification plan: extend the PocketIC e2e harness to drive the local browser-handshake flow -(loopback listener + slim redeem) with no live network; the -`II_URL_PROD`/`II_CANISTER_ID_PROD` overrides remain available to point a binary at beta II. +Verification plan: integration tests run `imcp2-local` in its **local-replica test +configuration** (component 3) — against an ICP CLI-spawned replica or PocketIC carrying a +deployed II canister — extending the existing PocketIC e2e harness to drive the local +browser-handshake flow (loopback listener + slim redeem) with no live network. The +`II_URL_PROD`/`II_CANISTER_ID_PROD` overrides additionally allow pointing a binary at beta II. ### Open decisions From 3916d212d62701ae219182218546208796b4b435 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 20 Aug 2026 16:39:28 +0000 Subject: [PATCH 10/23] State the no-listening-socket claim precisely MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "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 Claude-Session: https://claude.ai/code/session_01AqNkpMQiQHzYxC2djTfvBK --- docs/local-deployment.md | 6 ++++-- docs/scoping-local-deployment.md | 21 ++++++++++++++++++--- 2 files changed, 22 insertions(+), 5 deletions(-) diff --git a/docs/local-deployment.md b/docs/local-deployment.md index 2361d88..d202d74 100644 --- a/docs/local-deployment.md +++ b/docs/local-deployment.md @@ -44,8 +44,10 @@ Hosted-vs-local is a **deployment axis, not a network one**: both talk to the sa and the same production II. What changes is who can reach the server, and therefore what machinery is needed. -- **stdio transport.** The server has no listening socket; it is reachable only by the AI - tool that spawned it. The OS process boundary replaces the OAuth bearer gate. +- **stdio transport.** The MCP tool surface has no listening socket: it is reachable only by + the AI tool that spawned it, over stdin/stdout, so the OS process boundary replaces the + OAuth bearer gate. The one socket the binary ever opens is the transient loopback listener + below — it serves only the login handshake, never the tools. - **Internet Identity stays.** On first authenticated use the binary opens the user's browser to II, receives the delegation on a transient localhost callback, redeems it, and holds the session in memory — the same handshake the hosted server runs, minus the OAuth diff --git a/docs/scoping-local-deployment.md b/docs/scoping-local-deployment.md index 5e337bc..6ca72eb 100644 --- a/docs/scoping-local-deployment.md +++ b/docs/scoping-local-deployment.md @@ -295,6 +295,17 @@ callback↔connect correlator. 6. On redeem success, record the grant in memory and shut the listener down. `IcTools` now serves tools over stdio, minting per-app delegations on demand against mainnet. +The loopback listener is the unavoidable minimum, not a design slip: II delivers the +delegation by *navigating the browser* to the callback (a URL fragment only a served page can +read and POST back), and the `#4091` check *fetches* `/.well-known/ii-auth-callbacks` from +the callback's **origin** before honoring it — both require a real HTTP origin. A custom URI +scheme has no origin for that fetch (and component 6's client research found custom schemes +unreliably opened), and II's MCP contract offers no device-grant-style manual alternative +(the RFC 8628 device grant was dropped from this server early on). This is the standard +native-app loopback redirect (RFC 8252 §7.3) — the same shape as `gh auth login` / +`gcloud auth login`. The listener is transient (up for the handshake, torn down on redeem or +timeout) and never serves the tool surface (component 8). + Login is **lazy and non-blocking**: it runs as an MCP tool the agent calls on the first authenticated action — not at startup (most clients require the user to approve the first tool call, and some cap `initialize` at ~10 s) — and it returns the URL immediately rather than @@ -402,9 +413,13 @@ tools, and the anonymous path of `canister_query`. ### 8. Security model -**Trust boundary.** Dropping the bearer gate is sound *only because* the transport is stdio: -a stdio server has no listening socket — it is reachable only by the parent process holding -its stdin/stdout, i.e. the client that launched it. But that client then wields the user's +**Trust boundary.** Dropping the bearer gate is sound *only because* the MCP tool surface +rides stdio: it has no listening socket — it is reachable only by the parent process holding +its stdin/stdout, i.e. the client that launched it. The one socket the binary ever opens is +the transient login listener (component 5), and reaching it confers nothing: the callback +page and the `#4091` well-known are static, and `/redeem` only advances the connect this +process started (`state` must match) with a chain targeting the in-process `X` — it can +neither invoke tools nor read the session. But the client then wields the user's **real production II accounts** on mainnet (canister create/install/start/stop/delete, any update call / cycles spend, per-app delegations for every origin). This must be stated plainly: **treat the binary and its client config like a wallet.** There is no revocable token — only From 12205c60143df2c653dec8e4d998f7464f388f23 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 20 Aug 2026 16:51:04 +0000 Subject: [PATCH 11/23] Recommend an imcp2-core crate over the hosted feature flag MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01AqNkpMQiQHzYxC2djTfvBK --- docs/local-deployment.md | 23 ++-- docs/scoping-local-deployment.md | 184 +++++++++++++++++-------------- 2 files changed, 115 insertions(+), 92 deletions(-) diff --git a/docs/local-deployment.md b/docs/local-deployment.md index d202d74..a64646e 100644 --- a/docs/local-deployment.md +++ b/docs/local-deployment.md @@ -11,7 +11,8 @@ with their AI tool (Claude, Codex, Cursor, …) as a local MCP server. It speaks experience — the user logs in with their real anchor via a browser handshake, and the server mints per-app account delegations on demand. What it drops is the entire **OAuth 2.1 authorization server**: a single-user process reached over a pipe needs no bearer tokens. -The existing `imcp2` crate remains the shared library and the hosted server. +The shared components move into a new `imcp2-core` library; the existing `imcp2` crate +remains the hosted server built on it. ## Problem @@ -55,14 +56,16 @@ machinery is needed. - **Login is an in-band tool.** No AI client shows a stdio server's logs in chat, so the login URL is returned as the result of an `authenticate` tool (with best-effort browser auto-open), lazily on first use and without blocking on the callback. -- **One library, two binaries.** `imcp2` stays the shared core (and the hosted binary, - behind a default-on `hosted` feature); `imcp2-local` is a new, small crate that compiles - none of the OAuth/HTTP machinery. +- **A component core, two thin binaries.** A new `imcp2-core` library holds the shared + components (the tools, II sessions, the connect handshake) from which either server is + composed; `imcp2` keeps its name as the hosted server built on it, and `imcp2-local` is a + new small binary built on it whose dependency graph never contains the OAuth/HTTP + machinery. ## Design components -1. **Crate layout** — `imcp2` (library + hosted binary; hosted-only surfaces behind the - `hosted` feature) and `imcp2-local` (new minimal binary). +1. **Crate layout** — `imcp2-core` (shared components), `imcp2` (hosted library + binary; + unchanged name and deployments), `imcp2-local` (new minimal binary). 2. **Dependency profile** — the local closure is the MCP stack (stdio), `ic-agent`/Candid, and the discovery/management essentials; no axum, no CORS layer, no metrics stack, no OAuth persistence. @@ -81,8 +84,9 @@ machinery is needed. ## Implementation Stages -1. **Carve out the core.** Move the hosted-only surfaces behind the `hosted` feature and - introduce the session seam — no behavior change to the hosted server. +1. **Extract `imcp2-core`.** Move the shared components into the new core crate (with + `imcp2` re-exporting them for existing embedders) and introduce the session seam — no + behavior change to the hosted server. 2. **Ship `imcp2-local`.** The stdio server, the browser-login driver, and the loopback callback listener. Exit: a user logs in against II and uses the tools as their accounts. 3. **Polish.** Per-client setup docs and the wallet-grade trust note; integration tests that @@ -91,4 +95,5 @@ machinery is needed. end-to-end harness to the local login flow; optional keychain-backed session persistence. Open decisions (details in the full doc): hand-rolled vs axum-based loopback listener; how -to open the browser; v1 session persistence; two-crate vs three-crate layout. +to open the browser; v1 session persistence; confirming the `imcp2-core` split (recommended +over single-crate feature flags). diff --git a/docs/scoping-local-deployment.md b/docs/scoping-local-deployment.md index 6ca72eb..54feaed 100644 --- a/docs/scoping-local-deployment.md +++ b/docs/scoping-local-deployment.md @@ -14,9 +14,10 @@ per-app account delegations), and **drops the entire OAuth 2.1 authorization-ser Login runs as a **built-in browser handshake**: the binary opens the user's browser to II and receives the delegation on a transient localhost callback. -The existing crate keeps its name and role: **`imcp2`** remains the shared library (published -on crates.io) and the hosted server binary; the hosted-only surfaces move behind a default-on -`hosted` cargo feature so `imcp2-local` compiles a genuinely minimal dependency closure. +The existing crate keeps its name and role: **`imcp2`** remains the published library and the +hosted server binary. The shared components move down into a new **`imcp2-core`** crate that +both the hosted server and the local binary build on, so `imcp2-local` compiles a genuinely +minimal dependency closure — the isolation is a crate boundary, not a cargo feature. ## Problem @@ -92,64 +93,77 @@ references point at it. ## Design components -### 1. Crate layout — `imcp2` + a minimal `imcp2-local` - -The existing crate stays **`imcp2`** (its published, embeddable identity — `Cargo.toml:2`), -and its default binary stays **`imcp2`** (the Dockerfile `CMD ["imcp2"]`, `imcp2.service`, and -the deploy scripts all build/run a binary named `imcp2` — `Dockerfile:23,31`, -`deploy/native/*`, so renaming it would churn every deploy config). The local binary is a -**separate, minimal crate** so its dependency closure never includes the OAuth/HTTP machinery. - -- **`imcp2`** (lib + the hosted `imcp2` binary): - - The **library** is the shared, transport/OAuth-agnostic core both binaries build on: - - `identities.rs` **kept verbatim** (the II session-key grant + on-demand per-app account - delegations against production II — `IiInstance::prod()` at `identities.rs:185`, - `registration_pubkey_b64` `:595`, `redeem_registration_delegation` `:936`, - `delegated_identity_for` `:1030`, `list_accounts` `:873`). - - `calls.rs` (Candid textual↔binary codec, `raw_call`), `discover.rs` (discovery + SSRF - guard), `management.rs`, `skills.rs`. - - `tools.rs` — the **entire** MCP tool surface. The `#[tool_router]` / 26×`#[tool]` / - `#[tool_handler] impl ServerHandler for IcTools` macros expand into one impl on - `IcTools` and **must stay co-located**; both binaries construct `IcTools` and differ - only in transport + session source. - - **new `iiconnect` module** — the II connect-handshake primitives lifted out of - `auth.rs` (component 4), re-parameterised to plain values so they carry no - `AuthStore`/OAuth state. - - The **hosted binary** (`[[bin]] name = "imcp2"`, today's `main.rs`) and the OAuth 2.1 - layer (`auth.rs`, `McpServer`/routers in `lib.rs`, the landing page, `tests/routers.rs`) - sit behind a **default-on `hosted` feature** that pulls `axum`/`tower-http`/`prometheus` - as *optional* dependencies (`required-features = ["hosted"]` on the bin). The gate also - covers `pub mod metrics` (the `/metrics` Prometheus exposition — axum-typed middleware, - new on main) and `McpConfig`'s `state_dir`/`clients`/`require_resource` (the persisted - DCR client store and RFC 8707 strict mode — all OAuth/HTTP-deployment concerns). - `cargo build` here produces the `imcp2` server exactly as today, deploy configs unchanged. - The existing `e2e` feature (`pocket-ic`, `src/e2e_handshake.rs`) keeps compiling — its - optional dep never enters any binary's closure. -- **`imcp2-local`** (bin, new) — a few hundred lines that depend on - `imcp2 = { default-features = false }`, so the `hosted` optional deps (`axum`/`tower-http`/ - the OAuth modules, `#[cfg(feature = "hosted")]`) are **not compiled**. It serves `IcTools` - over rmcp's stdio transport, plus a browser-handshake login driver and a transient loopback - callback listener. rmcp features here: `["server", "macros", "transport-io"]`. - -`cargo build -p imcp2-local` compiles only the minimal closure. (Caveat: `cargo build ---workspace` unifies features, so it would build the shared `imcp2` lib with `hosted` on; -build the local binary with `-p imcp2-local` — or keep it out of the default workspace -members — to ship the genuinely minimal artifact.) - -**Published crate.** `imcp2` is now on crates.io (v0.2.0), released from `v*` tags via -trusted publishing with a tag-equals-version guard (`.github/workflows/publish-crate.yml`). -Two consequences: the restructure is a semver-visible change to a crate with external -embedders (the default-on `hosted` feature and `default-features = false` become supported -public API → minor version bump), and the publish workflow must be generalised if -`imcp2-local` becomes a second published crate (publish `imcp2` before the dependent crate, -per-crate version guards). The `.crate` excludes `Dockerfile`/`deploy/`/`docs/`/`monitoring/` -(repo-only), while `src/assets` and `static/` deliberately ship because they are pulled in -via `include_str!` — which supports keeping those assets in the core crate. - -*Alternative (stricter):* a three-crate split — `imcp2` (core lib, no bins), `imcp2-hosted` -(bin), `imcp2-local` (bin) — isolates dependencies regardless of build invocation, at the -cost of renaming the deployed binary to `imcp2-hosted` (deploy churn). The library changes -below are identical either way. +### 1. Crate layout — `imcp2-core` under `imcp2` and `imcp2-local` + +The crate names stay stable — **`imcp2`** keeps its published identity (`Cargo.toml:2`) and +its binary name (the Dockerfile `CMD ["imcp2"]`, `imcp2.service`, and the deploy scripts all +build/run a binary named `imcp2` — `Dockerfile:23,31`, `deploy/native/*`) — and the shared +components move **down** into a new **`imcp2-core`** crate that both binaries build on. The +library's job is to expose components from which either a hosted binary (HTTP router, OAuth +AS) or a local binary can be composed; the crate boundary — not a cargo feature — is what +keeps the local closure minimal. + +- **`imcp2-core`** (lib, new) — the transport/OAuth-agnostic components: + - `identities.rs` **kept verbatim** (the II session-key grant + on-demand per-app account + delegations against production II — `IiInstance::prod()` at `identities.rs:185`, + `registration_pubkey_b64` `:595`, `redeem_registration_delegation` `:936`, + `delegated_identity_for` `:1030`, `list_accounts` `:873`). + - `calls.rs` (Candid textual↔binary codec, `raw_call`), `discover.rs` (discovery + SSRF + guard), `management.rs`, `skills.rs`. + - `tools.rs` — the **entire** MCP tool surface. The `#[tool_router]` / 26×`#[tool]` / + `#[tool_handler] impl ServerHandler for IcTools` macros expand into one impl on + `IcTools` and **must stay co-located**; both binaries construct `IcTools` and differ + only in transport + session source. The `SessionSource` seam and the plain + `AuthedSession` struct (component 7) live here. + - **new `iiconnect` module** — the II connect-handshake primitives lifted out of + `auth.rs` (component 4), re-parameterised to plain values so they carry no + `AuthStore`/OAuth state. + - the `include_str!` assets those modules compile in: `static/` (the Candid/OQL + references) and the connect-flow assets under `src/assets/` (page, CSS, logo). + - Deps: rmcp `["server", "macros"]` (no transport), `ic-agent`/`candid`/`candid_parser`, + tokio, serde/serde_json, reqwest/url/regex (discovery), the II-connect and management + utilities (`base64`, `hex`, `sha2`, `crc32fast`, `getrandom`, `uuid`, `urlencoding`), + `http` (the request-extension type the Bearer session arm reads), tracing. +- **`imcp2`** (existing crate: lib + the hosted `imcp2` binary) — depends on `imcp2-core` + and adds the hosted composition: the OAuth 2.1 AS (the OAuth half of `auth.rs`), + `McpServer`/`McpConfig`/the routers (`lib.rs`, including `state_dir`/`clients`/ + `require_resource`), `metrics.rs` + `prometheus`, `main.rs` + the landing-page assets, + `tests/routers.rs`, and the PocketIC `e2e` harness (`src/e2e_handshake.rs` drives the + OAuth routes, so it stays here with its optional `pocket-ic` dep). It **re-exports** + `imcp2-core`'s public items (`pub use`), so existing embedders' `imcp2::…` imports keep + compiling — the restructure is a minor version bump, not a break. `cargo build` here + produces the `imcp2` server exactly as today; deploy configs unchanged. +- **`imcp2-local`** (bin, new) — depends on **`imcp2-core` only**: a few hundred lines that + serve `IcTools` over rmcp's stdio transport (features `["server", "macros", + "transport-io"]`), plus the browser-handshake login driver and the transient loopback + callback listener. `axum`, `tower-http`, `prometheus`, and every OAuth module are simply + **absent from its dependency graph** — under any build invocation, not just `-p` builds. + +(One benign note: a `--workspace` build compiles rmcp once with both transport features +unified — harmless, the linker strips the unused transport; the isolation that matters is +that the OAuth/HTTP code never enters `imcp2-local`'s graph at all.) + +**Published crates.** `imcp2` is on crates.io (v0.2.0), released from `v*` tags via trusted +publishing with a tag-equals-version guard (`.github/workflows/publish-crate.yml`). Because +crates.io does not accept path-only dependencies of a published crate, `imcp2-core` becomes a +second published crate: publish order core → `imcp2`, and the publish workflow is generalised +per-crate. Recommend versioning `imcp2-core` 0.x and documenting it as internal to the imcp2 +family — its API is consumed through `imcp2`'s re-exports, with no independent stability +promises — so component-level churn doesn't force `imcp2` majors. Whether `imcp2-local` +itself is published (vs distributed as release binaries) is an independent choice; nothing +depends on it. The `.crate` packaging constraint carries over: `src/assets` and `static/` +ship with the crate that `include_str!`s them, i.e. they move to `imcp2-core`. + +*Rejected alternative — a default-on `hosted` cargo feature on the single `imcp2` crate.* It +can produce the same two binaries, but: cargo features are additive and **unify**, so any +crate in a build graph that enables `imcp2/hosted` switches the OAuth/axum closure on for +everyone (a `--workspace` build, or a sibling dependency of a third-party embedder), leaving +the "minimal closure" claim invocation-dependent; it needs `#[cfg(feature = "hosted")]` +gates scattered through `lib.rs`/`tools.rs`/`McpConfig`; and it expresses "the library +exposes components" only implicitly, where the crate boundary states it structurally. *(A +second variant — moving the hosted binary out into an `imcp2-hosted` crate — was also +dropped: it renames the deployed binary and churns every deploy config for no isolation gain +over this layout.)* ### 2. Dependency profile @@ -184,9 +198,9 @@ gate is hand-rolled in `auth.rs`, and drops with the OAuth AS). MCP endpoint's browser-client layer and the OAuth/well-known permissive layer; locally only the `#4091` well-known needs one hand-set `Access-Control-Allow-Origin`), **`prometheus` + `src/metrics.rs`** (new on main: the `/metrics` Prometheus exposition behind -`MCP_SERVE_METRICS` — axum-typed middleware coupled to `McpServer`, hosted-only; it must move -behind the `hosted` feature or the local crate carries the metrics stack for nothing — the -`SessionGauges` counters in `identities.rs` are plain integers and stay in core), `tokio-util` +`MCP_SERVE_METRICS` — axum-typed middleware coupled to `McpServer`, hosted-only; it stays in +the `imcp2` crate so the local graph never carries the metrics stack — the `SessionGauges` +counters in `identities.rs` are plain integers and stay in core), `tokio-util` (only cancels the streamable-HTTP sessions; the reaper can be managed without a token), dev-deps `tower` + `http-body-util` (HTTP router tests), and `tokio`'s `signal` feature (graceful HTTP drain) plus its `time` feature (used only by the session-reaper tick and the @@ -199,7 +213,7 @@ is to **hand-roll** that 3-route loopback listener so the local crate drops `axum`/`tower-http` entirely; reusing axum is the lower-effort fallback (an open decision under Implementation Stages). -Net minimal local deps: `imcp2` (with `default-features = false`) + +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`). @@ -378,9 +392,10 @@ rather than being deleted: it is the only way to serve the cloud clients. 5. **Absolute paths; stdout = JSON-RPC only** (all diagnostics to stderr). A self-contained native binary avoids the frequent wrong-runtime/path failures Node-based servers hit. -*(Implementation: `authenticate`/`auth_status` are **local-only** tools — define them in the -`imcp2` library gated to the local build so they never appear on the hosted server, which logs -in via OAuth instead. Cursor's ~40-tool cap is comfortable: `imcp2` exposes ~26 plus these.)* +*(Implementation: `authenticate`/`auth_status` are **local-only** tools — defined in +`imcp2-core` but included in the tool router only when `IcTools` is constructed with the +singleton session source, so they never appear on the hosted server, which logs in via OAuth +instead. Cursor's ~40-tool cap is comfortable: the core exposes ~26 plus these.)* **Serving the cloud clients (hosted `imcp2`).** claude.ai web/mobile and Perplexity-web reach only a public HTTPS MCP endpoint with OAuth 2.1 — which hosted `imcp2` already is. Two @@ -401,11 +416,12 @@ Minimal, verified seam (no tool-signature changes, no `identities.rs` changes): - one `current_session_id(&self, &ctx) -> Option` method replacing the free fn; - rewrite the **13** lookup call-sites (`tools.rs:351,809,865,1298,1321,1344,1365,1386,1407, 1428,1446,1464,1482`) to call it; the `.ok_or(…)` handling is unchanged; -- keep `authed_session` + the `auth` import behind the hosted arm only. +- move the plain `AuthedSession` struct into `imcp2-core` beside the seam; the hosted + `require_token` middleware (in `imcp2`) keeps inserting it into the request extensions. -To avoid dragging axum into core, read `http::request::Parts` (the `http` crate) rather than -`axum::http::request::Parts` in the Bearer arm — axum merely re-exports it. Hosted constructs -`IcTools` with `Bearer`; local with `Singleton(sid)`. +With the core split, **no `#[cfg]` is needed anywhere**: the Bearer arm reads +`http::request::Parts` (the `http` crate — axum merely re-exports it), which core can depend +on unconditionally. Hosted constructs `IcTools` with `Bearer`; local with `Singleton(sid)`. Tools that are already session-free work unchanged locally: `get_canister_candid`, `get_canister_api_doc`, `open_app`, `resolve_app`, `discover_app_canisters`, the skills/lookup @@ -435,15 +451,14 @@ targeting our freshly-minted `X`. ## Implementation Stages -**Stage 1 — carve out the core (no behavior change to the `imcp2` binary).** Make the -`imcp2` library the transport/OAuth-agnostic core: keep `identities/calls/discover/management/ -skills/tools` + `static/` + connect assets, extract `iiconnect` from `auth.rs`, and put the -OAuth AS + `McpServer`/`main.rs` + `pub mod metrics` behind the default-on `hosted` feature -(optional `axum`/`tower-http`/`prometheus`), threading `McpConfig`'s -`state_dir`/`clients`/`require_resource` hosted-side and keeping the `e2e` feature compiling. -(The vestigial-dep cleanup this stage originally included was already done on main, #100.) -Apply the `SessionSource` seam (component 7). *Exit:* the `imcp2` binary builds and its tests -pass unchanged; `imcp2` with `default-features = false` compiles a minimal closure. +**Stage 1 — extract `imcp2-core` (no behavior change to the `imcp2` binary).** Move +`identities/calls/discover/management/skills/tools` + the new `iiconnect` (extracted from +`auth.rs`) + `static/` + the connect assets into the new core crate; `imcp2` keeps the OAuth +AS, `McpServer`/`McpConfig`/`main.rs`, `metrics`, and the `e2e` harness, depends on core, and +re-exports its public items so embedders keep compiling. Apply the `SessionSource` seam +(component 7). (The vestigial-dep cleanup this stage originally included was already done on +main, #100.) *Exit:* the `imcp2` binary builds and its tests pass unchanged; `imcp2-core` +compiles standalone with no `axum`/`tower-http`/`prometheus`/OAuth in its graph. **Stage 2 — the local binary.** `imcp2-local`: stdio `IcTools` server + the browser-handshake login driver + the loopback callback listener. *Exit:* `cargo build -p imcp2-local`; a user @@ -493,8 +508,11 @@ browser-handshake flow (loopback listener + slim redeem) with no live network. T (zero-dep). *Recommend* always return the URL in-band (the `authenticate` tool result) + best-effort auto-open; the flow never depends on auto-open succeeding. 3. **Session persistence:** in-memory only (recommend for v1) vs keychain-backed `S`. -4. **Crate layout:** `imcp2` + `imcp2-local` with a `hosted` feature (recommended, keeps the - `imcp2` binary name) vs the stricter three-crate split (component 1). +4. **Crate layout:** `imcp2-core` as the common dependency of `imcp2` (lib + hosted binary) + and `imcp2-local` — *recommended*: structural isolation with no feature-unification + hazard, no `#[cfg]` gates, and the `imcp2` binary name/deployments unchanged. The + single-crate default-on `hosted` feature is rejected (component 1); the cost to accept is + publishing `imcp2-core` as a second (family-internal, 0.x) crates.io crate. (Login timing was resolved by the client research — lazy on the first authenticated tool call, non-blocking.) From 5c4dc943853a6bf03868e1c7e7c2ba7276aea7a2 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 20 Aug 2026 16:57:10 +0000 Subject: [PATCH 12/23] Make the session-handling component self-contained in the overview 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 Claude-Session: https://claude.ai/code/session_01AqNkpMQiQHzYxC2djTfvBK --- docs/local-deployment.md | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/docs/local-deployment.md b/docs/local-deployment.md index a64646e..ee3d982 100644 --- a/docs/local-deployment.md +++ b/docs/local-deployment.md @@ -77,8 +77,11 @@ machinery is needed. lives in memory only. 6. **AI tool clients** — Claude Desktop, Claude Code, Codex, Cursor, Antigravity, and the Perplexity macOS app run `imcp2-local` directly; cloud surfaces keep using hosted. -7. **Session seam** — the hosted bearer-token lookup collapses to a singleton session id; - a small, verified change with no impact on tool behavior. +7. **Session handling** — every tool call acts as the user's Internet Identity session, so + each server must answer "which session?" The hosted server serves many users and resolves + it from each request's OAuth bearer token; the local server has exactly one user, so its + tools read the single in-memory session established at login. The tool implementations + are identical in both. 8. **Security model** — whoever drives the binary acts as the user's real II accounts on mainnet: treat the binary and its client config like a wallet. From 7bf434bbdbc31e56981af433bd77e09387023072 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 23 Aug 2026 19:06:08 +0000 Subject: [PATCH 13/23] Make the design docs self-contained 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 Claude-Session: https://claude.ai/code/session_01AqNkpMQiQHzYxC2djTfvBK --- docs/local-deployment.md | 3 +- docs/scoping-local-deployment.md | 167 +++++++++++++------------------ 2 files changed, 73 insertions(+), 97 deletions(-) diff --git a/docs/local-deployment.md b/docs/local-deployment.md index ee3d982..137453f 100644 --- a/docs/local-deployment.md +++ b/docs/local-deployment.md @@ -98,5 +98,4 @@ machinery is needed. end-to-end harness to the local login flow; optional keychain-backed session persistence. Open decisions (details in the full doc): hand-rolled vs axum-based loopback listener; how -to open the browser; v1 session persistence; confirming the `imcp2-core` split (recommended -over single-crate feature flags). +to open the browser; v1 session persistence. diff --git a/docs/scoping-local-deployment.md b/docs/scoping-local-deployment.md index 54feaed..44e0038 100644 --- a/docs/scoping-local-deployment.md +++ b/docs/scoping-local-deployment.md @@ -17,7 +17,7 @@ receives the delegation on a transient localhost callback. The existing crate keeps its name and role: **`imcp2`** remains the published library and the hosted server binary. The shared components move down into a new **`imcp2-core`** crate that both the hosted server and the local binary build on, so `imcp2-local` compiles a genuinely -minimal dependency closure — the isolation is a crate boundary, not a cargo feature. +minimal dependency closure. ## Problem @@ -100,8 +100,8 @@ its binary name (the Dockerfile `CMD ["imcp2"]`, `imcp2.service`, and the deploy build/run a binary named `imcp2` — `Dockerfile:23,31`, `deploy/native/*`) — and the shared components move **down** into a new **`imcp2-core`** crate that both binaries build on. The library's job is to expose components from which either a hosted binary (HTTP router, OAuth -AS) or a local binary can be composed; the crate boundary — not a cargo feature — is what -keeps the local closure minimal. +AS) or a local binary can be composed; the crate boundary is what keeps the local closure +minimal. - **`imcp2-core`** (lib, new) — the transport/OAuth-agnostic components: - `identities.rs` **kept verbatim** (the II session-key grant + on-demand per-app account @@ -154,64 +154,52 @@ itself is published (vs distributed as release binaries) is an independent choic depends on it. The `.crate` packaging constraint carries over: `src/assets` and `static/` ship with the crate that `include_str!`s them, i.e. they move to `imcp2-core`. -*Rejected alternative — a default-on `hosted` cargo feature on the single `imcp2` crate.* It -can produce the same two binaries, but: cargo features are additive and **unify**, so any -crate in a build graph that enables `imcp2/hosted` switches the OAuth/axum closure on for -everyone (a `--workspace` build, or a sibling dependency of a third-party embedder), leaving -the "minimal closure" claim invocation-dependent; it needs `#[cfg(feature = "hosted")]` -gates scattered through `lib.rs`/`tools.rs`/`McpConfig`; and it expresses "the library -exposes components" only implicitly, where the crate boundary states it structurally. *(A -second variant — moving the hosted binary out into an `imcp2-hosted` crate — was also -dropped: it renames the deployed binary and churns every deploy config for no isolation gain -over this layout.)* - ### 2. Dependency profile -**Already removed on main (#100).** The five vestigial direct deps this design originally -proposed dropping — `ed25519-dalek`, `p256`, `ic-signature-verification`, -`ic-representation-independent-hash`, and the top-level `schemars = "0.8"` — were removed on -main (commit `0e52fe9`); none appears in `Cargo.toml` at v0.2.0, so no stripping work -remains for them. The supporting facts still hold: all Ed25519 keygen/signing goes through -`ic-agent`'s `BasicIdentity::from_raw_key` (`fresh_ed25519`, `identities.rs:1252`), every -`schemars::JsonSchema` derive resolves through `use rmcp::schemars` (rmcp's re-exported 1.x), -and delegation chains are verified inside `ic-agent` — now both client-side against the -injected agent's root key (`DelegatedIdentity::new_with_root_key`, `identities.rs:1285-1303`) -and again by the replica at redeem — never by those crates. (`ed25519-dalek`/`p256` remain -*transitive* deps via `ic-agent`; #100 was direct-dep hygiene, not closure reduction.) - -**rmcp features:** keep `server` + `macros`; **swap** `transport-streamable-http-server` → -the stdio/io transport (`transport-io` in rmcp 1.x — provides `rmcp::transport::stdio()` over -tokio stdin/stdout, replacing `StreamableHttpService`); **drop** `auth` (unused — the bearer -gate is hand-rolled in `auth.rs`, and drops with the OAuth AS). - -**Stays, but not for OAuth** (so the local crate keeps them): +The dependency story follows the crate boundaries: `imcp2-core` carries what the components +need and nothing HTTP-shaped; `imcp2` adds the hosted stack; `imcp2-local` adds a stdio +transport and a browser-opener. + +**Baseline facts the split relies on.** The crate's direct dependencies are already lean: +all Ed25519 keygen/signing goes through `ic-agent`'s `BasicIdentity::from_raw_key` +(`fresh_ed25519`, `identities.rs:1252`); delegation chains are verified inside `ic-agent` — +client-side against the injected agent's root key (`DelegatedIdentity::new_with_root_key`, +`identities.rs:1285-1303`) and again by the replica at redeem — so no standalone +crypto-verification crates are involved; and every `schemars::JsonSchema` derive resolves +through `use rmcp::schemars` (rmcp's re-exported 1.x), so there is no direct schemars +dependency. + +**In `imcp2-core`:** +- MCP: rmcp with features `["server", "macros"]` — no transport; each binary adds its own. +- IC + II: `ic-agent`, `candid` (`value`), `candid_parser`. - discovery: `reqwest` (SSRF-pinned client, `discover.rs`), `url`, `regex`. -- canister management: `sha2` (Wasm hash + ledger AccountIdentifier; also the OAuth PKCE - S256 check in `auth.rs`, which drops — management keeps it either way), `crc32fast`, - `base64`, `hex` (`management.rs`). +- canister management: `sha2` (Wasm hash + ledger AccountIdentifier), `crc32fast`, `base64`, + `hex` (`management.rs`). - II connect/delegation: `getrandom` (key seeds + CSP nonce), `urlencoding` (II link), - `uuid` (connect `state`; replaceable by `getrandom`), `base64`/`hex` (delegation chain). -- core: `ic-agent`, `candid` (`value`), `candid_parser`, `tokio`, `serde`, `serde_json`, - `tracing`, `tracing-subscriber`, `anyhow`. - -**Drops with the HTTP surface:** `tower-http` (CORS — it now backs two hosted surfaces, the -MCP endpoint's browser-client layer and the OAuth/well-known permissive layer; locally only -the `#4091` well-known needs one hand-set `Access-Control-Allow-Origin`), **`prometheus` + -`src/metrics.rs`** (new on main: the `/metrics` Prometheus exposition behind -`MCP_SERVE_METRICS` — axum-typed middleware coupled to `McpServer`, hosted-only; it stays in -the `imcp2` crate so the local graph never carries the metrics stack — the `SessionGauges` -counters in `identities.rs` are plain integers and stay in core), `tokio-util` -(only cancels the streamable-HTTP sessions; the reaper can be managed without a token), -dev-deps `tower` + `http-body-util` (HTTP router tests), and `tokio`'s `signal` feature -(graceful HTTP drain) plus its `time` feature (used only by the session-reaper tick and the -OAuth persist throttle — subject to what rmcp's stdio transport itself requires). -`pocket-ic` (new on main, optional behind the `e2e` feature for `src/e2e_handshake.rs`) -never enters any binary's closure — no action needed. - -**`axum`** shrinks to at most the transient login callback (component 5). The recommendation -is to **hand-roll** that 3-route loopback listener so the local crate drops -`axum`/`tower-http` entirely; reusing axum is the lower-effort fallback (an open decision -under Implementation Stages). + `uuid` (connect `state`), `base64`/`hex` (delegation chain), `http` (the request-extension + type the session lookup reads). +- foundation: `tokio`, `serde`, `serde_json`, `tracing`, `anyhow`. + +**Added by `imcp2` (the hosted stack):** rmcp's `transport-streamable-http-server`; `axum`; +`tower-http` (CORS for the MCP endpoint's browser clients and the OAuth/well-known routes); +`prometheus` + `src/metrics.rs` (the `/metrics` exposition behind `MCP_SERVE_METRICS` — +axum-typed middleware coupled to `McpServer`; the `SessionGauges` counters it reads are plain +integers in core's `identities.rs`); `tokio-util` (cancellation for the streamable-HTTP +sessions); `tokio`'s `signal` (graceful HTTP drain) and `time` (session-reaper tick, OAuth +persist throttle) features; `sha2`'s second use, the OAuth PKCE S256 check; dev-deps `tower` ++ `http-body-util` (the HTTP router tests); and the optional `pocket-ic` behind the `e2e` +feature (`src/e2e_handshake.rs`). rmcp's `auth` feature is not used by either binary — the +OAuth AS is hand-rolled. + +**Added by `imcp2-local`:** rmcp's `transport-io` (`rmcp::transport::stdio()` over tokio +stdin/stdout, in place of `StreamableHttpService`), `tracing-subscriber` (stderr logging), +and a browser-opener (`open` crate, or `std::process::Command`). + +The local binary's one HTTP surface is the transient login callback (component 5) — three +loopback routes. The recommendation is to **hand-roll** it on a bare `tokio` `TcpListener` +(one hand-set `Access-Control-Allow-Origin` header on the `#4091` well-known) so +`axum`/`tower-http` stay out of the local graph; building it on axum is the lower-effort +fallback (an open decision under Implementation Stages). Net minimal local deps: `imcp2-core` + `rmcp{server,macros,transport-io}` + `tokio` + @@ -228,9 +216,9 @@ fixed local value; it need not be a reachable server. The prod instance is env-overridable via `II_URL_PROD`/`II_CANISTER_ID_PROD` (`identities.rs:185-190`; the plain `II_URL`/`II_CANISTER_ID` pair now overrides only -`IiInstance::beta()`), so the binary can still be pointed at beta II for testing. Since #92 -the hosted deployment itself serves **production II at `/mcp`** by default (beta is the -opt-in `/mcp-beta` staging instance), so prod II is no longer the unverified path (see +`IiInstance::beta()`), so the binary can be pointed at beta II for testing. The hosted +deployment serves **production II at `/mcp`** (beta is the opt-in `/mcp-beta` staging +instance), so the same II contract this binary relies on is exercised in production (see Verification under Implementation Stages). **Local-replica test configuration (nice-to-have; the integration-test vehicle).** The same @@ -269,10 +257,9 @@ tests exercise the full local flow end-to-end (see Verification). per `McpConfig::state_dir`, `auth.rs:799-820`), the hosted-redirect allow-list (`auth.rs:434-687`), AS/PR discovery metadata (`auth.rs:2223-2262`), the `require_token` bearer gate + `bearer_challenge` (`auth.rs:2264,2313`), the front-channel HTML error-screen -machinery, and the OAuth hardening added since this design's first draft — RFC 8707 -resource-indicator enforcement (the `require_resource` flag, #127), RFC 9207 `iss` emission -on redirects (#125), and the bounded/atomically-persisted DCR store (#137) — all on the -OAuth side, so the partition is unchanged. `AuthStore` slims to +machinery, RFC 8707 resource-indicator enforcement (the `require_resource` flag), RFC 9207 +`iss` emission on redirects, and the bounded/atomically-persisted DCR store. `AuthStore` +slims to `{identities, public_url, authz}` (loses `clients`/`tokens`/`codes`, plus the hosted-only `mcp_path` route prefix — the local build replaces it with a plain callback-base value — and `require_resource`). With `clients` gone the local binary needs **no `state_dir` at all**: @@ -419,9 +406,9 @@ Minimal, verified seam (no tool-signature changes, no `identities.rs` changes): - move the plain `AuthedSession` struct into `imcp2-core` beside the seam; the hosted `require_token` middleware (in `imcp2`) keeps inserting it into the request extensions. -With the core split, **no `#[cfg]` is needed anywhere**: the Bearer arm reads -`http::request::Parts` (the `http` crate — axum merely re-exports it), which core can depend -on unconditionally. Hosted constructs `IcTools` with `Bearer`; local with `Singleton(sid)`. +The seam involves no conditional compilation: the Bearer arm reads `http::request::Parts` +(the `http` crate — axum merely re-exports it), which core depends on directly. Hosted +constructs `IcTools` with `Bearer`; local with `Singleton(sid)`. Tools that are already session-free work unchanged locally: `get_canister_candid`, `get_canister_api_doc`, `open_app`, `resolve_app`, `discover_app_canisters`, the skills/lookup @@ -456,8 +443,7 @@ targeting our freshly-minted `X`. `auth.rs`) + `static/` + the connect assets into the new core crate; `imcp2` keeps the OAuth AS, `McpServer`/`McpConfig`/`main.rs`, `metrics`, and the `e2e` harness, depends on core, and re-exports its public items so embedders keep compiling. Apply the `SessionSource` seam -(component 7). (The vestigial-dep cleanup this stage originally included was already done on -main, #100.) *Exit:* the `imcp2` binary builds and its tests pass unchanged; `imcp2-core` +(component 7). *Exit:* the `imcp2` binary builds and its tests pass unchanged; `imcp2-core` compiles standalone with no `axum`/`tower-http`/`prometheus`/OAuth in its graph. **Stage 2 — the local binary.** `imcp2-local`: stdio `IcTools` server + the browser-handshake @@ -471,18 +457,17 @@ session persistence. ### Verification against production II -The original concern here — that only beta II was verified — is resolved on main: since #92 -the hosted deployment serves **production II at `/mcp` on every deploy** (`main.rs:376-385`; -beta is the opt-in `/mcp-beta` staging instance), CI probes production health on a schedule -(#113), and the crate gained a hermetic end-to-end test of the full connect contract — -`src/e2e_handshake.rs` (`--features e2e`, optional `pocket-ic` dep, needs `II_WASM` + -`POCKET_IC_BIN`) drives register → authorize → the II registration-delegation ceremony → -redeem → a real `mcp_register_v2` → token against a real Internet Identity release build in -PocketIC. So "does prod II implement the handshake" is answered in production. (Chains are -also now verified client-side against the injected agent's root key — `new_with_root_key`, -`identities.rs:1285-1303` — so a binary pointed at a test network can complete redemption -after `fetch_root_key`, which is what makes a PocketIC-based test of the *local* flow -possible.) +The II contract this binary depends on is already exercised end to end: the hosted +deployment serves **production II at `/mcp` on every deploy** (`main.rs:376-385`; beta is +the opt-in `/mcp-beta` staging instance), CI probes production health on a schedule, and a +hermetic end-to-end test of the full connect contract exists — `src/e2e_handshake.rs` +(`--features e2e`, optional `pocket-ic` dep, needs `II_WASM` + `POCKET_IC_BIN`) drives +register → authorize → the II registration-delegation ceremony → redeem → a real +`mcp_register_v2` → token against a real Internet Identity release build in PocketIC. +(Chains are verified client-side against the injected agent's root key — +`new_with_root_key`, `identities.rs:1285-1303` — so a binary pointed at a test network can +complete redemption after `fetch_root_key`, which is what makes a PocketIC-based test of the +*local* flow possible.) What remains open is specific to the **local binary's `http://127.0.0.1` callback**, which the hosted https deployment never exercises: @@ -508,25 +493,17 @@ browser-handshake flow (loopback listener + slim redeem) with no live network. T (zero-dep). *Recommend* always return the URL in-band (the `authenticate` tool result) + best-effort auto-open; the flow never depends on auto-open succeeding. 3. **Session persistence:** in-memory only (recommend for v1) vs keychain-backed `S`. -4. **Crate layout:** `imcp2-core` as the common dependency of `imcp2` (lib + hosted binary) - and `imcp2-local` — *recommended*: structural isolation with no feature-unification - hazard, no `#[cfg]` gates, and the `imcp2` binary name/deployments unchanged. The - single-crate default-on `hosted` feature is rejected (component 1); the cost to accept is - publishing `imcp2-core` as a second (family-internal, 0.x) crates.io crate. - -(Login timing was resolved by the client research — lazy on the first authenticated tool -call, non-blocking.) ## Appendix: evidence index (Line refs stamped against the v0.2.0 tree, rebased onto `189fabd`.) -- Vestigial deps: already removed on main in #100 (`0e52fe9`); Ed25519 via `ic-agent` - (`fresh_ed25519`, `identities.rs:1252`); chains verified by `ic-agent` client-side - (`new_with_root_key`, `identities.rs:1285-1303`) and by the replica. `schemars` via - `use rmcp::schemars` (all modules). +- Dependency baseline: no unused crypto deps in `Cargo.toml` (#100, `0e52fe9`); Ed25519 via + `ic-agent` (`fresh_ed25519`, `identities.rs:1252`); chains verified by `ic-agent` + client-side (`new_with_root_key`, `identities.rs:1285-1303`) and by the replica. + `schemars` via `use rmcp::schemars` (all modules). - rmcp features + streamable-HTTP wiring: `Cargo.toml:35-40`, `lib.rs:102-105,258-276`. -- New hosted-only surfaces: `prometheus`/`src/metrics.rs` (`lib.rs:76-81`, `Cargo.toml:64-69`, +- Hosted-only surfaces: `prometheus`/`src/metrics.rs` (`lib.rs:76-81`, `Cargo.toml:64-69`, `MCP_SERVE_METRICS`); `McpConfig.state_dir`/`require_resource` (`lib.rs:144-158`, `IMCP2_STATE_DIR`/`OAUTH_REQUIRE_RESOURCE` in `main.rs`). - OAuth AS vs II-connect split: `auth.rs:24-60` (module docs), handlers @@ -534,7 +511,7 @@ call, non-blocking.) `#4091` `auth.rs:1304/1319`. - Session seam: `auth.rs:2264/2292`, `tools.rs:1493`, 13 call-sites listed in component 7. - II login primitives: `identities.rs:595/936`, `ii_mcp_url` `auth.rs:1284`. -- Production II served at `/mcp` since #92 (`main.rs:376-385`); PocketIC e2e handshake test +- Production II served at `/mcp` (#92, `main.rs:376-385`); PocketIC e2e handshake test `src/e2e_handshake.rs` (`e2e` feature); beta II constants `identities.rs:121-128`, candid verification note `identities.rs:929-935`, generic live-round-trip caveat `README.md:993-999`. From b42ed137c664cacde0e25ab42230f1e0040f4b77 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 23 Aug 2026 19:13:52 +0000 Subject: [PATCH 14/23] Drop the local-replica bullet from Non-goals 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 Claude-Session: https://claude.ai/code/session_01AqNkpMQiQHzYxC2djTfvBK --- docs/local-deployment.md | 4 ---- docs/scoping-local-deployment.md | 6 ------ 2 files changed, 10 deletions(-) diff --git a/docs/local-deployment.md b/docs/local-deployment.md index 137453f..925f555 100644 --- a/docs/local-deployment.md +++ b/docs/local-deployment.md @@ -30,10 +30,6 @@ there is no IMCP2 artifact that fits that slot. ## Non-goals -- **Not a local-replica product surface** — the shipped binary targets mainnet and - production II. A **test configuration** that talks to a local replica (as spawned by the - ICP CLI) is deliberately easy to compile and is how integration tests run — but it is not - a v1 user-facing mode. - **No change to the hosted server** — its name, behavior, and deployments stay as they are. - **No session persistence in v1** — sessions are in-memory; users re-login per run. - **Not for cloud-only AI surfaces** — claude.ai web/mobile, Perplexity web, and Codex Cloud diff --git a/docs/scoping-local-deployment.md b/docs/scoping-local-deployment.md index 44e0038..808077a 100644 --- a/docs/scoping-local-deployment.md +++ b/docs/scoping-local-deployment.md @@ -43,12 +43,6 @@ fits that slot. ## Non-goals -- **Not a local-replica product surface.** The *shipped* binary targets mainnet + production - II: its default build has no `fetch_root_key`, no SSRF-guard relaxation, no local II. A - **test configuration** that talks to a local replica (as spawned by the ICP CLI) is a - deliberate nice-to-have — easy to compile, guarded so it can never run against mainnet, - and the vehicle for integration testing (see component 3 and Verification) — but it is not - a v1 user-facing mode. - **No change to the hosted server's behavior, name, or deployment.** The `imcp2` binary, Dockerfile (`CMD ["imcp2"]`, `Dockerfile:23,31`), `imcp2.service`, and deploy scripts stay as they are. From f35d9b6e68707b93c55cbcb8b2695d4e252f1095 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 23 Aug 2026 19:58:35 +0000 Subject: [PATCH 15/23] Resolve the login-flow choices like the ICP CLI; add end-user setup UX MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01AqNkpMQiQHzYxC2djTfvBK --- docs/local-deployment.md | 10 +++-- docs/scoping-local-deployment.md | 74 +++++++++++++++++++++++--------- 2 files changed, 60 insertions(+), 24 deletions(-) diff --git a/docs/local-deployment.md b/docs/local-deployment.md index 925f555..eac71d2 100644 --- a/docs/local-deployment.md +++ b/docs/local-deployment.md @@ -80,6 +80,11 @@ machinery is needed. are identical in both. 8. **Security model** — whoever drives the binary acts as the user's real II accounts on mainnet: treat the binary and its client config like a wallet. +9. **End-user setup** — no config editing anywhere: double-click the plugin bundle for + Claude Desktop, a one-click install link for Cursor, one pasted command for Claude Code + and Codex, guided connector UIs for Perplexity (macOS) and Antigravity — plus an + `imcp2-local setup` command that detects installed clients and registers the binary for + you. Signing in is one browser round-trip on first use. ## Implementation Stages @@ -88,10 +93,9 @@ machinery is needed. behavior change to the hosted server. 2. **Ship `imcp2-local`.** The stdio server, the browser-login driver, and the loopback callback listener. Exit: a user logs in against II and uses the tools as their accounts. -3. **Polish.** Per-client setup docs and the wallet-grade trust note; integration tests that +3. **Polish.** End-user packaging and setup (the double-click bundle, install links, the + `setup` command, signed binaries) and the wallet-grade trust note; integration tests that run `imcp2-local` in its local-replica test configuration (against an ICP CLI-spawned replica or PocketIC, with an II canister deployed there), extending the existing PocketIC end-to-end harness to the local login flow; optional keychain-backed session persistence. -Open decisions (details in the full doc): hand-rolled vs axum-based loopback listener; how -to open the browser; v1 session persistence. diff --git a/docs/scoping-local-deployment.md b/docs/scoping-local-deployment.md index 808077a..b4bd089 100644 --- a/docs/scoping-local-deployment.md +++ b/docs/scoping-local-deployment.md @@ -186,14 +186,15 @@ feature (`src/e2e_handshake.rs`). rmcp's `auth` feature is not used by either bi OAuth AS is hand-rolled. **Added by `imcp2-local`:** rmcp's `transport-io` (`rmcp::transport::stdio()` over tokio -stdin/stdout, in place of `StreamableHttpService`), `tracing-subscriber` (stderr logging), -and a browser-opener (`open` crate, or `std::process::Command`). +stdin/stdout, in place of `StreamableHttpService`), `axum` (the transient login listener), +`open` (browser launch), and `tracing-subscriber` (stderr logging). The local binary's one HTTP surface is the transient login callback (component 5) — three -loopback routes. The recommendation is to **hand-roll** it on a bare `tokio` `TcpListener` -(one hand-set `Access-Control-Allow-Origin` header on the `#4091` well-known) so -`axum`/`tower-http` stay out of the local graph; building it on axum is the lower-effort -fallback (an open decision under Implementation Stages). +loopback routes served with **axum**, the same shape as the ICP CLI's web-identity flow +(`icp identity link web`: an axum `Router` on a `TcpListener` at `127.0.0.1:0`, graceful +shutdown on completion — `crates/icp-cli/src/commands/identity/link/web.rs`). `tower-http` +is still not needed: the only CORS requirement is one `Access-Control-Allow-Origin` header +on the `#4091` well-known, set directly on that response. Net minimal local deps: `imcp2-core` + `rmcp{server,macros,transport-io}` + `tokio` + @@ -280,7 +281,9 @@ callback↔connect correlator. from this one value, so they cannot drift (II matches by exact string equality). 4. Build the II link (`iiconnect::ii_mcp_url`) against `https://id.ai` and surface it to the user **in-band** — as the text result of an `authenticate` MCP tool (component 6) — plus a - best-effort server-side browser auto-open. Do **not** rely on **stderr** for the URL: every + best-effort server-side browser auto-open via `open::that` (the `open` crate, as in the + ICP CLI); the flow never depends on the auto-open succeeding. Do **not** rely on + **stderr** for the URL: every client routes a stdio server's stderr to a log file/panel, never the chat (component 6). stdout is the JSON-RPC channel, so all logging stays on stderr. 5. Serve exactly three loopback routes: `GET /callback` (the pinned fragment-reading page), @@ -354,6 +357,9 @@ rather than being deleted: it is the only way to serve the cloud clients. - Perplexity macOS: Settings → Connectors → Add Connector → Advanced (needs the PerplexityXPC helper): `{ "command": "/usr/local/bin/imcp2-local", "args": [], "env": {} }` +These are the raw registration formats; component 9 gives the end-user setup paths that +avoid editing them by hand. + **Cross-client login invariants** (every subprocess-capable client agreed): 1. **Host OAuth never touches a stdio server.** All five drive OAuth only for *remote* servers; for a local stdio server the host just pipes stdin/stdout. So the II login is entirely the @@ -430,6 +436,37 @@ handshake. Even so, a rebinding attacker is largely inert: `/redeem` only advanc *this* process started (`state` match) and `registration_identity` rejects any chain not targeting our freshly-minted `X`. +### 9. End-user setup (UX) + +The bar for setup: install the artifact, follow the prompts, sign in — **no JSON or config +editing anywhere**. Two things deliver that: per-client one-click/one-command paths where +the client provides one, and a `setup` subcommand in the binary for the rest. + +| Client | What the user does | +|---|---| +| Claude Desktop | **Double-click the `imcp2.mcpb` bundle** → Claude Desktop shows its install dialog → Enable. (MCPB is Claude Desktop's plugin format; the bundle carries the per-platform binary and installs it in one step.) | +| Claude Code | Paste one command: `claude mcp add --transport stdio imcp2 -- ` | +| Codex (CLI / IDE / desktop) | Paste one command: `codex mcp add imcp2 -- ` | +| Cursor | Click the **"Add to Cursor"** install link on the docs page → Cursor opens its install prompt → Install. | +| Antigravity | Settings → Manage MCP Servers → Add (UI) — or run `imcp2-local setup`. | +| Perplexity (macOS) | Install Perplexity's local-MCP helper once (the app prompts for it), then Settings → Connectors → Add Connector and paste the binary path in the Simple tab. | + +**`imcp2-local setup`** closes the remaining gaps: run once, it detects installed clients +(Claude Desktop, Claude Code, Codex, Cursor, Antigravity, Perplexity), shows what it will +register, and writes each client's MCP config itself — the user never opens a JSON/TOML +file. `imcp2-local setup --remove` undoes it. (What it writes is exactly the per-client +registration in component 6.) + +**Signing in** is then the same everywhere: on the first tool call that needs the user's +identity, the client asks to approve the `authenticate` tool, the browser opens to Internet +Identity, the user signs in, and the tab says it can be closed. The session lives in memory, +so quitting the client ends it; the next run repeats the same one-step sign-in. + +**Packaging that makes this work:** signed and notarized release binaries (macOS Gatekeeper +and Windows SmartScreen block unsigned double-click installs), one artifact per platform, +and the `.mcpb` manifest pointing at the right per-OS binary. Building these artifacts is a +Stage 3 deliverable. + ## Implementation Stages **Stage 1 — extract `imcp2-core` (no behavior change to the `imcp2` binary).** Move @@ -444,10 +481,11 @@ compiles standalone with no `axum`/`tower-http`/`prometheus`/OAuth in its graph. login driver + the loopback callback listener. *Exit:* `cargo build -p imcp2-local`; a user logs in against II and runs read/write tools as their accounts. -**Stage 3 — polish.** Docs (how to add the binary to an MCP client config; the 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 -session persistence. +**Stage 3 — polish.** End-user packaging and setup (component 9: the `.mcpb` bundle, the +Add-to-Cursor link, the `setup` subcommand, signed/notarized release binaries) and the +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. ### Verification against production II @@ -478,16 +516,6 @@ deployed II canister — extending the existing PocketIC e2e harness to drive th browser-handshake flow (loopback listener + slim redeem) with no live network. The `II_URL_PROD`/`II_CANISTER_ID_PROD` overrides additionally allow pointing a binary at beta II. -### Open decisions - -1. **Loopback listener: hand-roll vs reuse axum.** *Recommend hand-roll* (3 routes, one CORS - header) so the local crate drops `axum`/`tower-http` — the security-sensitive page/CSP is a - static asset reused from core, not re-derived. Reuse-axum is the lower-effort fallback. -2. **Browser open:** `open`/`webbrowser` crate (convenience) vs `std::process::Command` - (zero-dep). *Recommend* always return the URL in-band (the `authenticate` tool result) + - best-effort auto-open; the flow never depends on auto-open succeeding. -3. **Session persistence:** in-memory only (recommend for v1) vs keychain-backed `S`. - ## Appendix: evidence index (Line refs stamped against the v0.2.0 tree, rebased onto `189fabd`.) @@ -509,3 +537,7 @@ browser-handshake flow (loopback listener + slim redeem) with no live network. T `src/e2e_handshake.rs` (`e2e` feature); beta II constants `identities.rs:121-128`, candid verification note `identities.rs:929-935`, generic live-round-trip caveat `README.md:993-999`. +- Login listener + browser-open follow the ICP CLI's web-identity flow: axum `Router` on + `TcpListener::bind("127.0.0.1:0")` with graceful shutdown, browser via `open::that` + (`icp-cli` `crates/icp-cli/src/commands/identity/link/web.rs:284,347,375,378`; workspace + deps `axum = "0.8"`, `open = "5"`). From de2918c8df625b010c673fef8b047eede8275c2c Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 23 Aug 2026 20:12:31 +0000 Subject: [PATCH 16/23] Add the upgrade story to the end-user setup UX MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01AqNkpMQiQHzYxC2djTfvBK --- docs/local-deployment.md | 7 +++++-- docs/scoping-local-deployment.md | 21 ++++++++++++++++++++- 2 files changed, 25 insertions(+), 3 deletions(-) diff --git a/docs/local-deployment.md b/docs/local-deployment.md index eac71d2..5155c59 100644 --- a/docs/local-deployment.md +++ b/docs/local-deployment.md @@ -84,7 +84,9 @@ machinery is needed. Claude Desktop, a one-click install link for Cursor, one pasted command for Claude Code and Codex, guided connector UIs for Perplexity (macOS) and Antigravity — plus an `imcp2-local setup` command that detects installed clients and registers the binary for - you. Signing in is one browser round-trip on first use. + you. Signing in is one browser round-trip on first use. Upgrading is one command (or a + new double-click for the Claude Desktop bundle): clients point at a stable path, the + installer's updater swaps the binary in place, and there is no state to migrate. ## Implementation Stages @@ -94,7 +96,8 @@ machinery is needed. 2. **Ship `imcp2-local`.** The stdio server, the browser-login driver, and the loopback callback listener. Exit: a user logs in against II and uses the tools as their accounts. 3. **Polish.** End-user packaging and setup (the double-click bundle, install links, the - `setup` command, signed binaries) and the wallet-grade trust note; integration tests that + `setup` command, the installers + in-place updater, signed binaries) and the wallet-grade + trust note; integration tests that run `imcp2-local` in its local-replica test configuration (against an ICP CLI-spawned replica or PocketIC, with an II canister deployed there), extending the existing PocketIC end-to-end harness to the local login flow; optional keychain-backed session persistence. diff --git a/docs/scoping-local-deployment.md b/docs/scoping-local-deployment.md index b4bd089..6f88952 100644 --- a/docs/scoping-local-deployment.md +++ b/docs/scoping-local-deployment.md @@ -457,6 +457,20 @@ register, and writes each client's MCP config itself — the user never opens a file. `imcp2-local setup --remove` undoes it. (What it writes is exactly the per-client registration in component 6.) +**Upgrading.** Client registrations point at a stable install path, so an upgrade is a +binary swap at that path — nothing to re-register; each client picks the new version up the +next time it starts the server (typically on app restart). Distribution and updates follow +the ICP CLI: releases are built with cargo-dist (shell/PowerShell installers for the major +platforms, with `install-updater = true` shipping the standalone axoupdater program +alongside, so one `imcp2-local-update` run upgrades in place), and — like the ICP CLI's +`dist_update_suggestion` — the binary detects which channel installed it (axoupdater +receipt, Homebrew, npm) and, when a newer release exists, surfaces that channel's exact +upgrade command. The Claude Desktop bundle has its own channel: upgrades ship as new +`.mcpb` versions — double-click the new bundle to upgrade in place (directory-listed +extensions update automatically). There is nothing to migrate: the binary keeps no on-disk +state (sessions are in-memory), so after an upgrade the next use simply repeats the +one-step sign-in. + **Signing in** is then the same everywhere: on the first tool call that needs the user's identity, the client asks to approve the `authenticate` tool, the browser opens to Internet Identity, the user signs in, and the tab says it can be closed. The session lives in memory, @@ -482,7 +496,8 @@ login driver + the loopback callback listener. *Exit:* `cargo build -p imcp2-loc logs in against II and runs read/write tools as their accounts. **Stage 3 — polish.** End-user packaging and setup (component 9: the `.mcpb` bundle, the -Add-to-Cursor link, the `setup` subcommand, signed/notarized release binaries) and the +Add-to-Cursor link, the `setup` subcommand, the cargo-dist installers + updater, +signed/notarized release binaries) and the 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. @@ -541,3 +556,7 @@ browser-handshake flow (loopback listener + slim redeem) with no live network. T `TcpListener::bind("127.0.0.1:0")` with graceful shutdown, browser via `open::that` (`icp-cli` `crates/icp-cli/src/commands/identity/link/web.rs:284,347,375,378`; workspace deps `axum = "0.8"`, `open = "5"`). +- Distribution/updates follow the ICP CLI: cargo-dist with shell/PowerShell installers and + `install-updater = true` (`icp-cli` `dist-workspace.toml`), channel detection + upgrade + suggestion via axoupdater (`icp-cli` `crates/icp-cli/src/dist.rs:31,47-60`, + `axoupdater = "0.10"`). From e6920515922be4dd0a0b7fc8a60d61f0d75de6e6 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 24 Aug 2026 08:47:08 +0000 Subject: [PATCH 17/23] Add signing and provenance to the end-user setup component MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01AqNkpMQiQHzYxC2djTfvBK --- docs/local-deployment.md | 4 +-- docs/scoping-local-deployment.md | 50 ++++++++++++++++++++++++++++---- 2 files changed, 46 insertions(+), 8 deletions(-) diff --git a/docs/local-deployment.md b/docs/local-deployment.md index 5155c59..09d03bb 100644 --- a/docs/local-deployment.md +++ b/docs/local-deployment.md @@ -96,8 +96,8 @@ machinery is needed. 2. **Ship `imcp2-local`.** The stdio server, the browser-login driver, and the loopback callback listener. Exit: a user logs in against II and uses the tools as their accounts. 3. **Polish.** End-user packaging and setup (the double-click bundle, install links, the - `setup` command, the installers + in-place updater, signed binaries) and the wallet-grade - trust note; integration tests that + `setup` command, the installers + in-place updater, Apple/Windows-signed binaries with + supply-chain attestations) and the wallet-grade trust note; integration tests that run `imcp2-local` in its local-replica test configuration (against an ICP CLI-spawned replica or PocketIC, with an II canister deployed there), extending the existing PocketIC end-to-end harness to the local login flow; optional keychain-backed session persistence. diff --git a/docs/scoping-local-deployment.md b/docs/scoping-local-deployment.md index 6f88952..aee728d 100644 --- a/docs/scoping-local-deployment.md +++ b/docs/scoping-local-deployment.md @@ -476,10 +476,48 @@ identity, the client asks to approve the `authenticate` tool, the browser opens Identity, the user signs in, and the tab says it can be closed. The session lives in memory, so quitting the client ends it; the next run repeats the same one-step sign-in. -**Packaging that makes this work:** signed and notarized release binaries (macOS Gatekeeper -and Windows SmartScreen block unsigned double-click installs), one artifact per platform, -and the `.mcpb` manifest pointing at the right per-OS binary. Building these artifacts is a -Stage 3 deliverable. +**Signing and provenance.** Two distinct layers, for two different verifiers: + +- **OS execution gates** (what lets the double-click work at all): + - *macOS.* A browser-downloaded file carries the quarantine attribute, and Gatekeeper + evaluates it at first execution — **including when an MCP client spawns the binary as a + subprocess**, where a block surfaces as an opaque "server failed to start". Gatekeeper + accepts exactly one thing: a **Developer ID** signature (Apple is the sole issuer of + these certificates, via an Apple Developer Program *organization* membership) plus a + **notarization** ticket. Pipeline: `codesign --options runtime --timestamp` on a macOS + runner, then `xcrun notarytool submit --key --wait`. No + installer artifact is needed — a bare signed + notarized binary passes (Gatekeeper + checks its ticket online; stapling only matters for offline `.pkg`/`.dmg` installs). + Without this, macOS blocks the file behind a buried System Settings "Open Anyway" flow + (macOS 15 removed the right-click bypass) — unacceptable for a wallet-grade tool. + - *Windows.* Authenticode via **Azure Trusted Signing** (Microsoft's managed service: + org-validated identity, keys in a managed HSM as the CA/Browser rules require, a + first-class GitHub Action, good SmartScreen reputation) — or an EV certificate through + a cloud signing service (cargo-dist has built-in SSL.com eSigner support). + - *Linux.* No OS gate; the cargo-dist release ships SHA256 checksums. + - The shell installer, Homebrew, and npm channels never set the quarantine bit, so they + work regardless — the OS-gate signing exists for the browser-download paths: the + `.mcpb` and direct release downloads. +- **Supply-chain provenance**, uniform across every artifact (all three platforms' binaries + and the `.mcpb`): **GitHub artifact attestations** — Sigstore-based and keyless (OIDC), so + there are no long-lived signing secrets — verifiable with + `gh attestation verify imcp2-local -R dfinity/imcp2`. This layer proves an artifact came + from this repository's release workflow; it is for humans and auditors, and does not + satisfy the OS gates above (Gatekeeper recognizes only Apple-issued signatures). +- **The `.mcpb` specifically.** Gatekeeper checks the **binary inside** the bundle + (extraction inherits quarantine), so the per-OS signing above is the substance. The MCPB + format does not currently specify bundle-level signing or install-time verification, so + the bundle itself carries a provenance attestation, and trusted distribution comes from + listing in Anthropic's extension directory (which also enables automatic updates). +- **Where it hooks in, and what DFINITY acquires.** The signing steps live in the + cargo-dist release pipeline, with secrets in a protected GitHub environment (required + reviewers — the same gating the deploy workflow uses). To acquire, with lead time for the + org validations: an Apple Developer Program organization membership → a Developer ID + Application certificate + an App Store Connect API key; an Azure Trusted Signing account + (or an EV certificate); artifact attestations are free to enable. + +One artifact per platform, with the `.mcpb` manifest pointing at the right per-OS binary; +building and signing these artifacts is a Stage 3 deliverable. ## Implementation Stages @@ -496,8 +534,8 @@ login driver + the loopback callback listener. *Exit:* `cargo build -p imcp2-loc logs in against II and runs read/write tools as their accounts. **Stage 3 — polish.** End-user packaging and setup (component 9: the `.mcpb` bundle, the -Add-to-Cursor link, the `setup` subcommand, the cargo-dist installers + updater, -signed/notarized release binaries) and the +Add-to-Cursor link, the `setup` subcommand, the cargo-dist installers + updater, the +signing + attestation pipeline) and the 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. From bad2c557244ba41391e5ae1167ca30ac14a1019c Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 24 Aug 2026 09:13:08 +0000 Subject: [PATCH 18/23] Promote signing and provenance to its own design component 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 Claude-Session: https://claude.ai/code/session_01AqNkpMQiQHzYxC2djTfvBK --- docs/local-deployment.md | 4 ++++ docs/scoping-local-deployment.md | 8 +++++--- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/docs/local-deployment.md b/docs/local-deployment.md index 09d03bb..ce61bb2 100644 --- a/docs/local-deployment.md +++ b/docs/local-deployment.md @@ -87,6 +87,10 @@ machinery is needed. you. Signing in is one browser round-trip on first use. Upgrading is one command (or a new double-click for the Claude Desktop bundle): clients point at a stable path, the installer's updater swaps the binary in place, and there is no state to migrate. +10. **Signing and provenance** — release binaries are Apple-signed + notarized and + Windows-signed (the OS gates that make double-click and client-spawned launches work), + and every artifact carries a keyless supply-chain attestation proving it came from this + repository's release workflow. ## Implementation Stages diff --git a/docs/scoping-local-deployment.md b/docs/scoping-local-deployment.md index aee728d..c500207 100644 --- a/docs/scoping-local-deployment.md +++ b/docs/scoping-local-deployment.md @@ -476,7 +476,9 @@ identity, the client asks to approve the `authenticate` tool, the browser opens Identity, the user signs in, and the tab says it can be closed. The session lives in memory, so quitting the client ends it; the next run repeats the same one-step sign-in. -**Signing and provenance.** Two distinct layers, for two different verifiers: +### 10. Signing and provenance + +Two distinct layers, for two different verifiers: - **OS execution gates** (what lets the double-click work at all): - *macOS.* A browser-downloaded file carries the quarantine attribute, and Gatekeeper @@ -534,8 +536,8 @@ login driver + the loopback callback listener. *Exit:* `cargo build -p imcp2-loc logs in against II and runs read/write tools as their accounts. **Stage 3 — polish.** End-user packaging and setup (component 9: the `.mcpb` bundle, the -Add-to-Cursor link, the `setup` subcommand, the cargo-dist installers + updater, the -signing + attestation pipeline) and the +Add-to-Cursor link, the `setup` subcommand, the cargo-dist installers + updater — and +component 10's signing + attestation pipeline) and the 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. From 92ddb1eba7ff7036f5909074a872cc55a6c10b68 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 24 Aug 2026 09:20:37 +0000 Subject: [PATCH 19/23] Commit to PocketIC as the integration-test dependency 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 Claude-Session: https://claude.ai/code/session_01AqNkpMQiQHzYxC2djTfvBK --- docs/local-deployment.md | 6 +++--- docs/scoping-local-deployment.md | 30 ++++++++++++++++-------------- 2 files changed, 19 insertions(+), 17 deletions(-) diff --git a/docs/local-deployment.md b/docs/local-deployment.md index ce61bb2..6e30dfb 100644 --- a/docs/local-deployment.md +++ b/docs/local-deployment.md @@ -102,7 +102,7 @@ machinery is needed. 3. **Polish.** End-user packaging and setup (the double-click bundle, install links, the `setup` command, the installers + in-place updater, Apple/Windows-signed binaries with supply-chain attestations) and the wallet-grade trust note; integration tests that - run `imcp2-local` in its local-replica test configuration (against an ICP CLI-spawned - replica or PocketIC, with an II canister deployed there), extending the existing PocketIC - end-to-end harness to the local login flow; optional keychain-backed session persistence. + run `imcp2-local` against PocketIC with an II canister deployed there, extending the + existing end-to-end harness to the local login flow; optional keychain-backed session + persistence. diff --git a/docs/scoping-local-deployment.md b/docs/scoping-local-deployment.md index c500207..26247be 100644 --- a/docs/scoping-local-deployment.md +++ b/docs/scoping-local-deployment.md @@ -187,7 +187,8 @@ OAuth AS is hand-rolled. **Added by `imcp2-local`:** rmcp's `transport-io` (`rmcp::transport::stdio()` over tokio stdin/stdout, in place of `StreamableHttpService`), `axum` (the transient login listener), -`open` (browser launch), and `tracing-subscriber` (stderr logging). +`open` (browser launch), and `tracing-subscriber` (stderr logging); dev-dependency: +`pocket-ic` (the local-flow integration tests, component 3). The local binary's one HTTP surface is the transient login callback (component 5) — three loopback routes served with **axum**, the same shape as the ICP CLI's web-identity flow @@ -216,17 +217,18 @@ deployment serves **production II at `/mcp`** (beta is the opt-in `/mcp-beta` st instance), so the same II contract this binary relies on is exercised in production (see Verification under Implementation Stages). -**Local-replica test configuration (nice-to-have; the integration-test vehicle).** The same -wiring supports a test build that talks to a **local replica spawned by the ICP CLI**: an -explicit IC-endpoint override for the agent, plus one `agent.fetch_root_key()` call so -certificate verification trusts the local replica's key, plus the existing II overrides -pointed at an II canister deployed on that replica. Guard: `fetch_root_key` is only ever -called when the endpoint override is explicitly set to a loopback target — refused otherwise -— so a mis-set environment can never make a binary trust a fetched root key against mainnet. -Everything downstream already works on a test network: delegation chains are verified against -the *injected agent's* root key (`new_with_root_key`, `identities.rs:1285-1303`), which is -the same property the PocketIC e2e harness relies on. This configuration is how integration -tests exercise the full local flow end-to-end (see Verification). +**Local-replica test configuration (the integration-test vehicle).** The same wiring +supports a test build that talks to a local replica: an explicit IC-endpoint override for +the agent, plus one `agent.fetch_root_key()` call so certificate verification trusts that +replica's key, plus the existing II overrides pointed at an II canister deployed there. +Guard: `fetch_root_key` is only ever called when the endpoint override is explicitly set to +a loopback target — refused otherwise — so a mis-set environment can never make a binary +trust a fetched root key against mainnet. Everything downstream works on a test network: +delegation chains are verified against the *injected agent's* root key +(`new_with_root_key`, `identities.rs:1285-1303`). Integration tests run this configuration +against **PocketIC** — the `pocket-ic` dev dependency, reusing the e2e harness's +real-II-canister setup (see Verification); the same override also lets a developer point a +test build at any other local replica, such as one spawned by the ICP CLI. ### 4. Dropping OAuth 2.1, keeping the II handshake @@ -566,8 +568,8 @@ the hosted https deployment never exercises: cross-origin; `*` is fine with `credentials: omit`). Verification plan: integration tests run `imcp2-local` in its **local-replica test -configuration** (component 3) — against an ICP CLI-spawned replica or PocketIC carrying a -deployed II canister — extending the existing PocketIC e2e harness to drive the local +configuration** (component 3) against **PocketIC carrying a deployed II canister** — the +`pocket-ic` dev dependency, extending the existing e2e harness to drive the local browser-handshake flow (loopback listener + slim redeem) with no live network. The `II_URL_PROD`/`II_CANISTER_ID_PROD` overrides additionally allow pointing a binary at beta II. From c498f8c629a5d793fec883a38257b63cfd847bba Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 24 Aug 2026 10:48:45 +0000 Subject: [PATCH 20/23] Reconcile the dependency and AuthStore claims with the axum decision 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 Claude-Session: https://claude.ai/code/session_01AqNkpMQiQHzYxC2djTfvBK --- docs/local-deployment.md | 7 +++---- docs/scoping-local-deployment.md | 34 +++++++++++++++++--------------- 2 files changed, 21 insertions(+), 20 deletions(-) diff --git a/docs/local-deployment.md b/docs/local-deployment.md index 6e30dfb..6bdb658 100644 --- a/docs/local-deployment.md +++ b/docs/local-deployment.md @@ -55,16 +55,15 @@ machinery is needed. - **A component core, two thin binaries.** A new `imcp2-core` library holds the shared components (the tools, II sessions, the connect handshake) from which either server is composed; `imcp2` keeps its name as the hosted server built on it, and `imcp2-local` is a - new small binary built on it whose dependency graph never contains the OAuth/HTTP - machinery. + new small binary built on it that carries none of the hosted server's OAuth machinery. ## Design components 1. **Crate layout** — `imcp2-core` (shared components), `imcp2` (hosted library + binary; unchanged name and deployments), `imcp2-local` (new minimal binary). 2. **Dependency profile** — the local closure is the MCP stack (stdio), `ic-agent`/Candid, - and the discovery/management essentials; no axum, no CORS layer, no metrics stack, no - OAuth persistence. + the discovery/management essentials, and axum for the transient login listener alone; no + CORS layer, no metrics stack, no OAuth persistence. 3. **IC + II wiring** — a mainnet agent and production II, env-overridable for staging. 4. **Auth partition** — keep the II connect-handshake primitives (link builder, callback page, delegation parser, the II callback allow-list, a slim redeem); drop the whole diff --git a/docs/scoping-local-deployment.md b/docs/scoping-local-deployment.md index 26247be..d67a579 100644 --- a/docs/scoping-local-deployment.md +++ b/docs/scoping-local-deployment.md @@ -127,15 +127,17 @@ minimal. `imcp2-core`'s public items (`pub use`), so existing embedders' `imcp2::…` imports keep compiling — the restructure is a minor version bump, not a break. `cargo build` here produces the `imcp2` server exactly as today; deploy configs unchanged. -- **`imcp2-local`** (bin, new) — depends on **`imcp2-core` only**: a few hundred lines that - serve `IcTools` over rmcp's stdio transport (features `["server", "macros", - "transport-io"]`), plus the browser-handshake login driver and the transient loopback - callback listener. `axum`, `tower-http`, `prometheus`, and every OAuth module are simply - **absent from its dependency graph** — under any build invocation, not just `-p` builds. +- **`imcp2-local`** (bin, new) — depends on `imcp2-core` plus its own small additions: a + few hundred lines that serve `IcTools` over rmcp's stdio transport (features `["server", + "macros", "transport-io"]`), the browser-handshake login driver, and the transient + loopback callback listener (`axum`, serving only the three login routes of component 5 — + the MCP tool surface never rides HTTP here). `tower-http`, `prometheus`, and every OAuth + module are **absent from its dependency graph** — under any build invocation, not just + `-p` builds. (One benign note: a `--workspace` build compiles rmcp once with both transport features unified — harmless, the linker strips the unused transport; the isolation that matters is -that the OAuth/HTTP code never enters `imcp2-local`'s graph at all.) +that the hosted OAuth and MCP-transport code never enters `imcp2-local`'s graph at all.) **Published crates.** `imcp2` is on crates.io (v0.2.0), released from `v*` tags via trusted publishing with a tag-equals-version guard (`.github/workflows/publish-crate.yml`). Because @@ -197,10 +199,9 @@ shutdown on completion — `crates/icp-cli/src/commands/identity/link/web.rs`). is still not needed: the only CORS requirement is one `Access-Control-Allow-Origin` header on the `#4091` well-known, set directly on that response. -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`). +Net minimal local deps: `imcp2-core` + `rmcp{server,macros,transport-io}` + `tokio` + +`axum` (the transient login listener) + `open` (browser launch) + `anyhow` + `serde_json` + +`url`/`urlencoding` + `tracing`/`tracing-subscriber`. ### 3. Mainnet and production II wiring @@ -255,12 +256,13 @@ per `McpConfig::state_dir`, `auth.rs:799-820`), the hosted-redirect allow-list (`auth.rs:434-687`), AS/PR discovery metadata (`auth.rs:2223-2262`), the `require_token` bearer gate + `bearer_challenge` (`auth.rs:2264,2313`), the front-channel HTML error-screen machinery, RFC 8707 resource-indicator enforcement (the `require_resource` flag), RFC 9207 -`iss` emission on redirects, and the bounded/atomically-persisted DCR store. `AuthStore` -slims to -`{identities, public_url, authz}` (loses `clients`/`tokens`/`codes`, plus the hosted-only -`mcp_path` route prefix — the local build replaces it with a plain callback-base value — and -`require_resource`). With `clients` gone the local binary needs **no `state_dir` at all**: -it writes no files, and all its state is the in-memory `Identities` session map. +`iss` emission on redirects, and the bounded/atomically-persisted DCR store. The hosted +`AuthStore` itself is untouched by the split: it stays in `imcp2`, where the retained OAuth +routes need all of its state (`clients`/`tokens`/`codes`, the `mcp_path` route prefix, +`require_resource`), and the extracted primitives carry none of it (component 1). The local +binary has **no `AuthStore` at all** — its login keeps only a minimal pending-connect state +(the `state` value and an in-flight flag) beside the in-memory `Identities` session map, so +it needs **no `state_dir`** and writes no files. **Consent-Bound Completion / the initiator cookie can be dropped locally.** The `sid` cookie (`auth.rs:170`, checked `:1869`) defends a *split-browser confused-deputy* that requires a From 8ffe6b79545647d7b510bee0b82b1df6815de634 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 24 Aug 2026 10:58:39 +0000 Subject: [PATCH 21/23] docs: address second review round on the local-deployment design MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 Claude-Session: https://claude.ai/code/session_01AqNkpMQiQHzYxC2djTfvBK --- docs/local-deployment.md | 3 ++- docs/scoping-local-deployment.md | 40 +++++++++++++++++++++++--------- 2 files changed, 31 insertions(+), 12 deletions(-) diff --git a/docs/local-deployment.md b/docs/local-deployment.md index 6bdb658..c9b4738 100644 --- a/docs/local-deployment.md +++ b/docs/local-deployment.md @@ -31,7 +31,8 @@ there is no IMCP2 artifact that fits that slot. ## Non-goals - **No change to the hosted server** — its name, behavior, and deployments stay as they are. -- **No session persistence in v1** — sessions are in-memory; users re-login per run. +- **No session persistence in v1** — sessions are in-memory; the user signs in again after + a restart, when the grant expires, or if they revoke it. - **Not for cloud-only AI surfaces** — claude.ai web/mobile, Perplexity web, and Codex Cloud cannot reach a local process; they continue to use the hosted server. diff --git a/docs/scoping-local-deployment.md b/docs/scoping-local-deployment.md index d67a579..b87c493 100644 --- a/docs/scoping-local-deployment.md +++ b/docs/scoping-local-deployment.md @@ -46,7 +46,8 @@ fits that slot. - **No change to the hosted server's behavior, name, or deployment.** The `imcp2` binary, Dockerfile (`CMD ["imcp2"]`, `Dockerfile:23,31`), `imcp2.service`, and deploy scripts stay as they are. -- **No session persistence in v1.** Sessions are in-memory (re-login per run), matching the +- **No session persistence in v1.** Sessions are in-memory — the user signs in again after + a restart, when the grant expires, or if they revoke it — matching the hosted model and the existing roadmap; keychain-backed persistence is future work. - **Not a path for cloud-only AI surfaces.** claude.ai web/mobile, Perplexity web/Windows, and Codex Cloud cannot reach `localhost`; they keep using the hosted server (which is why @@ -315,7 +316,11 @@ blocking on the callback (Codex times out a tool call at 60 s; Claude Code auto- calls over 2 min). A follow-up `auth_status` tool (or simply the next tool call) confirms the grant landed. Component 6 covers the per-client specifics. -Sessions are **in-memory** (re-login per run), matching today's model and the roadmap. +Sessions are **in-memory**: the user signs in again after a restart, when the grant expires +(the connect link requests a one-hour grant today; the effective TTL is the user's +consent-time choice), or if they revoke it — matching today's model and the roadmap. The +replaceable session slot (component 7) makes that re-login a same-process step, not a +client restart. Optional future work: persist the session seed `S` to an OS keychain to survive restarts — but `S` is a live capability to the user's real anchor, so never plaintext. @@ -383,10 +388,12 @@ avoid editing them by hand. 5. **Absolute paths; stdout = JSON-RPC only** (all diagnostics to stderr). A self-contained native binary avoids the frequent wrong-runtime/path failures Node-based servers hit. -*(Implementation: `authenticate`/`auth_status` are **local-only** tools — defined in -`imcp2-core` but included in the tool router only when `IcTools` is constructed with the -singleton session source, so they never appear on the hosted server, which logs in via OAuth -instead. Cursor's ~40-tool cap is comfortable: the core exposes ~26 plus these.)* +*(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 +not contain them. Cursor's ~40-tool cap is comfortable: the core exposes ~26 plus these.)* **Serving the cloud clients (hosted `imcp2`).** claude.ai web/mobile and Perplexity-web reach only a public HTTPS MCP endpoint with OAuth 2.1 — which hosted `imcp2` already is. Two @@ -400,10 +407,16 @@ local binary. Today a tool gets its `session_id` via bearer → `require_token` → `AuthedSession` injected in the request extensions → `authed_session(ctx)` (`tools.rs:1493`). Under stdio there is one -user and one connection, so this collapses to a **singleton** session id set at login. +user and one connection, so this collapses to a **singleton** session id, set at login and +replaced on each re-login. Minimal, verified seam (no tool-signature changes, no `identities.rs` changes): -- add `session: SessionSource { Bearer, Singleton(String) }` to `IcTools` (`tools.rs:52`); +- add `session: SessionSource { Bearer, Singleton(..) }` to `IcTools` (`tools.rs:52`). The + `Singleton` arm holds a **shared, replaceable slot** (an `Arc>>`- + style holder whose exact shape lands with the Stage 2 login driver), not a fixed id: the + II contract requires a **fresh session key** after `Unauthorized`, so each successful + `authenticate` writes a fresh session id into the slot — same-process reauthentication + after grant expiry or revocation, with no client restart; - one `current_session_id(&self, &ctx) -> Option` method replacing the free fn; - rewrite the **13** lookup call-sites (`tools.rs:351,809,865,1298,1321,1344,1365,1386,1407, 1428,1446,1464,1482`) to call it; the `.ok_or(…)` handling is unchanged; @@ -412,7 +425,7 @@ Minimal, verified seam (no tool-signature changes, no `identities.rs` changes): The seam involves no conditional compilation: the Bearer arm reads `http::request::Parts` (the `http` crate — axum merely re-exports it), which core depends on directly. Hosted -constructs `IcTools` with `Bearer`; local with `Singleton(sid)`. +constructs `IcTools` with `Bearer`; local with `Singleton`, whose slot the login flow fills. Tools that are already session-free work unchanged locally: `get_canister_candid`, `get_canister_api_doc`, `open_app`, `resolve_app`, `discover_app_canisters`, the skills/lookup @@ -572,8 +585,13 @@ the hosted https deployment never exercises: Verification plan: integration tests run `imcp2-local` in its **local-replica test configuration** (component 3) against **PocketIC carrying a deployed II canister** — the `pocket-ic` dev dependency, extending the existing e2e harness to drive the local -browser-handshake flow (loopback listener + slim redeem) with no live network. The -`II_URL_PROD`/`II_CANISTER_ID_PROD` overrides additionally allow pointing a binary at beta II. +browser-handshake flow (loopback listener + slim redeem) with no live network. That harness +exercises the protocol, not browser policy, so it cannot answer the two questions above — +Stage 2's login exit criterion therefore also includes a **real-browser compatibility +gate**: an automated Chromium/Firefox run of the full login against the loopback listener +(Playwright-style), plus a manual Safari and enterprise-policy pass, before the flow is +declared working on supported clients. The `II_URL_PROD`/`II_CANISTER_ID_PROD` overrides +additionally allow pointing a binary at beta II. ## Appendix: evidence index From 0cecf83d296a6236e712766a2d6ead3ac69514cd Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 24 Aug 2026 11:09:24 +0000 Subject: [PATCH 22/23] docs: address third review round on the local-deployment design MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 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 Claude-Session: https://claude.ai/code/session_01AqNkpMQiQHzYxC2djTfvBK --- docs/scoping-local-deployment.md | 55 ++++++++++++++++++++++---------- 1 file changed, 38 insertions(+), 17 deletions(-) diff --git a/docs/scoping-local-deployment.md b/docs/scoping-local-deployment.md index b87c493..1b65f31 100644 --- a/docs/scoping-local-deployment.md +++ b/docs/scoping-local-deployment.md @@ -99,22 +99,29 @@ AS) or a local binary can be composed; the crate boundary is what keeps the loca minimal. - **`imcp2-core`** (lib, new) — the transport/OAuth-agnostic components: - - `identities.rs` **kept verbatim** (the II session-key grant + on-demand per-app account - delegations against production II — `IiInstance::prod()` at `identities.rs:185`, - `registration_pubkey_b64` `:595`, `redeem_registration_delegation` `:936`, - `delegated_identity_for` `:1030`, `list_accounts` `:873`). + - `identities.rs` **moved whole, logic unchanged** (the II session-key grant + on-demand + per-app account delegations against production II — `IiInstance::prod()` at + `identities.rs:185`, `registration_pubkey_b64` `:595`, + `redeem_registration_delegation` `:936`, `delegated_identity_for` `:1030`, + `list_accounts` `:873`). - `calls.rs` (Candid textual↔binary codec, `raw_call`), `discover.rs` (discovery + SSRF guard), `management.rs`, `skills.rs`. - `tools.rs` — the **entire** MCP tool surface. The `#[tool_router]` / 26×`#[tool]` / `#[tool_handler] impl ServerHandler for IcTools` macros expand into one impl on - `IcTools` and **must stay co-located**; both binaries construct `IcTools` and differ - only in transport + session source. The `SessionSource` seam and the plain - `AuthedSession` struct (component 7) live here. + `IcTools` and **must stay co-located**; both binaries construct `IcTools`, differing + in transport + session source (the local binary additionally wraps it in its login + handler, component 6). The `SessionSource` seam and the plain `AuthedSession` struct + (component 7) live here. - **new `iiconnect` module** — the II connect-handshake primitives lifted out of `auth.rs` (component 4), re-parameterised to plain values so they carry no `AuthStore`/OAuth state. - the `include_str!` assets those modules compile in: `static/` (the Candid/OQL references) and the connect-flow assets under `src/assets/` (page, CSS, logo). + - 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). - Deps: rmcp `["server", "macros"]` (no transport), `ic-agent`/`candid`/`candid_parser`, tokio, serde/serde_json, reqwest/url/regex (discovery), the II-connect and management utilities (`base64`, `hex`, `sha2`, `crc32fast`, `getrandom`, `uuid`, `urlencoding`), @@ -129,7 +136,8 @@ minimal. compiling — the restructure is a minor version bump, not a break. `cargo build` here produces the `imcp2` server exactly as today; deploy configs unchanged. - **`imcp2-local`** (bin, new) — depends on `imcp2-core` plus its own small additions: a - few hundred lines that serve `IcTools` over rmcp's stdio transport (features `["server", + few hundred lines that serve `IcTools` (wrapped in the local login handler, component 6) + over rmcp's stdio transport (features `["server", "macros", "transport-io"]`), the browser-handshake login driver, and the transient loopback callback listener (`axum`, serving only the three login routes of component 5 — the MCP tool surface never rides HTTP here). `tower-http`, `prometheus`, and every OAuth @@ -246,7 +254,12 @@ test build at any other local replica, such as one spawned by the ICP CLI. (`auth.rs:1673-1782`). - a slimmed `connect_redeem` (`auth.rs:1839`): shape-check the fragment, single-flight, call `Identities::redeem_registration_delegation`, signal completion — **minus** the PKCE/code/ - token/cookie/redirect tail. + token/cookie/redirect tail. Dropping the redirect changes the page contract: the callback + script today treats only a JSON `redirect` as success and shows the error screen otherwise + (`auth.rs:1447-1452`), so the shared page gains a second success arm — the local redeem + answers `{"done": true}` and the page renders its completion state in place ("Signed in — + you can close this tab", the state component 9 promises), while the hosted redeem keeps + answering `redirect`. - the **`#4091` allow-list** `/.well-known/ii-auth-callbacks` (`auth.rs:1304,1319`) — still mandatory: II fail-closed-fetches it before honoring the callback. @@ -295,8 +308,10 @@ callback↔connect correlator. `POST /redeem` (slim redeem → `redeem_registration_delegation`), and `GET /.well-known/ii-auth-callbacks` (the `#4091` allow-list, one `Access-Control-Allow-Origin: *` header since II fetches it cross-origin). -6. On redeem success, record the grant in memory and shut the listener down. `IcTools` now - serves tools over stdio, minting per-app delegations on demand against mainnet. +6. On redeem success, answer `{"done": true}` — the callback page renders "Signed in — you + can close this tab" (component 4) — record the grant in memory, and shut the listener + down. `IcTools` now serves tools over stdio, minting per-app delegations on demand + against mainnet. The loopback listener is the unavoidable minimum, not a design slip: II delivers the delegation by *navigating the browser* to the callback (a URL fragment only a served page can @@ -388,12 +403,18 @@ avoid editing them by hand. 5. **Absolute paths; stdout = JSON-RPC only** (all diagnostics to stderr). A self-contained native binary avoids the frequent wrong-runtime/path failures Node-based servers hit. -*(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 -not contain them. Cursor's ~40-tool cap is comfortable: the core exposes ~26 plus these.)* +*(Implementation: `authenticate`/`auth_status` are **local-only** tools, and they cannot +live in `imcp2-core` at all — they drive the loopback listener and browser opener, which +are `imcp2-local` code that core cannot call back into. So `imcp2-local` defines a small +wrapper handler that owns the login driver and a writing handle to the shared session +slot (component 7) and wraps +`IcTools`: its own `#[tool_router]` impl carries the two login tools, and its +`ServerHandler` answers `tools/list` with the merged list and dispatches `tools/call` to +that router first, forwarding everything else to the inner `IcTools` handler (rmcp's +`RequestContext` is handler-independent, so forwarding is verbatim). The +hosted server thus contains no login-tool code by construction; a hosted-side test still +asserts its `tools/list` stays login-free as a regression guard. Cursor's ~40-tool cap is +comfortable: the core exposes ~26 plus these two.)* **Serving the cloud clients (hosted `imcp2`).** claude.ai web/mobile and Perplexity-web reach only a public HTTPS MCP endpoint with OAuth 2.1 — which hosted `imcp2` already is. Two From e045f0990d24cef2f0981c9b19dcabb875afd886 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 24 Aug 2026 11:16:27 +0000 Subject: [PATCH 23/23] docs: address fourth review round on the local-deployment design MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 Claude-Session: https://claude.ai/code/session_01AqNkpMQiQHzYxC2djTfvBK --- docs/scoping-local-deployment.md | 24 +++++++++++++++++------- 1 file changed, 17 insertions(+), 7 deletions(-) diff --git a/docs/scoping-local-deployment.md b/docs/scoping-local-deployment.md index 1b65f31..af2d93a 100644 --- a/docs/scoping-local-deployment.md +++ b/docs/scoping-local-deployment.md @@ -132,7 +132,8 @@ minimal. `require_resource`), `metrics.rs` + `prometheus`, `main.rs` + the landing-page assets, `tests/routers.rs`, and the PocketIC `e2e` harness (`src/e2e_handshake.rs` drives the OAuth routes, so it stays here with its optional `pocket-ic` dep). It **re-exports** - `imcp2-core`'s public items (`pub use`), so existing embedders' `imcp2::…` imports keep + the `imcp2-core` items that were previously `imcp2` public API (`pub use`), so existing + embedders' `imcp2::…` imports keep compiling — the restructure is a minor version bump, not a break. `cargo build` here produces the `imcp2` server exactly as today; deploy configs unchanged. - **`imcp2-local`** (bin, new) — depends on `imcp2-core` plus its own small additions: a @@ -140,9 +141,12 @@ minimal. over rmcp's stdio transport (features `["server", "macros", "transport-io"]`), the browser-handshake login driver, and the transient loopback callback listener (`axum`, serving only the three login routes of component 5 — - the MCP tool surface never rides HTTP here). `tower-http`, `prometheus`, and every OAuth - module are **absent from its dependency graph** — under any build invocation, not just - `-p` builds. + the MCP tool surface never rides HTTP here). `prometheus` and every OAuth module are + **absent from its dependency graph** — structurally, since `imcp2-local` never depends on + `imcp2` — under any build invocation, not just `-p` builds. The one nuance is + `tower-http`: the hosted CORS layer is not a dependency here, but the crate name still + appears deep in the graph as an rmcp/reqwest internal — the unavoidable transitive + baseline, not linked hosted code. (One benign note: a `--workspace` build compiles rmcp once with both transport features unified — harmless, the linker strips the unused transport; the isolation that matters is @@ -153,8 +157,12 @@ publishing with a tag-equals-version guard (`.github/workflows/publish-crate.yml crates.io does not accept path-only dependencies of a published crate, `imcp2-core` becomes a second published crate: publish order core → `imcp2`, and the publish workflow is generalised per-crate. Recommend versioning `imcp2-core` 0.x and documenting it as internal to the imcp2 -family — its API is consumed through `imcp2`'s re-exports, with no independent stability -promises — so component-level churn doesn't force `imcp2` majors. Whether `imcp2-local` +family. That label relaxes stability only where `imcp2` does **not** re-export the item: +whatever `imcp2` re-exports is `imcp2` public API, and breaking it is a breaking change for +`imcp2` embedders under `imcp2`'s own semver, whatever core's version says. So the re-export +set stays curated — exactly the pre-split `imcp2` surface — while the core-only composition +seams (the session slot, the connect-page internals, everything only `imcp2-local` consumes) +can churn on core 0.x bumps without forcing an `imcp2` release. Whether `imcp2-local` itself is published (vs distributed as release binaries) is an independent choice; nothing depends on it. The `.crate` packaging constraint carries over: `src/assets` and `static/` ship with the crate that `include_str!`s them, i.e. they move to `imcp2-core`. @@ -567,7 +575,9 @@ building and signing these artifacts is a Stage 3 deliverable. AS, `McpServer`/`McpConfig`/`main.rs`, `metrics`, and the `e2e` harness, depends on core, and re-exports its public items so embedders keep compiling. Apply the `SessionSource` seam (component 7). *Exit:* the `imcp2` binary builds and its tests pass unchanged; `imcp2-core` -compiles standalone with no `axum`/`tower-http`/`prometheus`/OAuth in its graph. +compiles standalone with no `axum`, no `prometheus`, and no OAuth code in its graph — +`tower-http` remains only as rmcp/reqwest's transitive internal, never as the direct CORS +dependency. **Stage 2 — the local binary.** `imcp2-local`: stdio `IcTools` server + the browser-handshake login driver + the loopback callback listener. *Exit:* `cargo build -p imcp2-local`; a user