From c8634ae6bdc5041aae5fe13b49ae7b85424dc87c Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 23 Jul 2026 16:29:58 +0000 Subject: [PATCH 1/4] 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 3bccaa2f149b5be1a300929c6e788aa814840dcd Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 23 Jul 2026 17:08:39 +0000 Subject: [PATCH 2/4] 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 680a70e0d18d3db103425d4ccd3f5b8be36817c0 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 24 Jul 2026 13:10:44 +0000 Subject: [PATCH 3/4] 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 38fb07f855b6cc4ba1c52e4b8935999284194f3b Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 27 Jul 2026 14:00:49 +0000 Subject: [PATCH 4/4] 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`.