Repository navigation
Stage 2: imcp2-local — the stdio binary with built-in II browser login - #150
Conversation
ac15eca to
2695c8a
Compare
The local half of the design merged in #148: a single-user binary an AI tool spawns and talks to over stdio, serving the same tool surface as the hosted server against the same mainnet and production Internet Identity, with none of the hosted server's OAuth machinery. imcp2-local (new crate, publish = false; distributed as release binaries per the design's Stage 3): - login driver: `authenticate` mints a FRESH session id + registration key, binds a transient loopback listener on 127.0.0.1:0, and returns the id.ai connect link in-band (plus a best-effort `open::that` browser launch), never blocking on the callback. The listener serves exactly three routes — the pinned fragment-reading callback page (shared with hosted, same CSP + hardening headers), the slim redeem, and the #4091 allow-list (CORS-readable, no-store) — and is torn down on redeem, on the 10-minute handshake timeout, or when a fresh flow replaces it. The redeem mirrors the hosted order (validate state, parse before claiming, single-flight claim, one mcp_register_v2 call) minus the OAuth tail: success answers {"done": true} and the page renders "Signed in — you can close this tab" in place. - wrapper handler: the two local-only login tools live on LocalServer's own #[tool_router]; tools/list is the merged surface (26 core + 2), tools/call dispatches to the login router first and forwards everything else (calls, resources, get_tool) to the inner IcTools handler verbatim. get_info identifies as imcp2-local and appends the sign-in guidance to core's instructions. - main: mainnet agent (IMCP2_IC_URL override; IMCP2_FETCH_ROOT_KEY only honoured for loopback endpoints — the local-replica test configuration), production II (II_URL_PROD/II_CANISTER_ID_PROD overrides), management identity derived at the hosted server's origin by default (IMCP2_MANAGEMENT_ORIGIN) so the same anchor keeps the same controller principal across deployments, IMCP2_NO_OPEN, all logging to stderr (stdout is the JSON-RPC channel). imcp2-core (the two seams Stage 1 left for the login driver): - SessionSource::Singleton now holds a SessionSlot — a shared, REPLACEABLE holder, not a fixed id: the II contract requires a fresh session key after Unauthorized, so each successful login writes a fresh session id and reauthentication is a same-process step. - the pinned callback page's script gains the {"done": true} success arm (hosted keeps answering `redirect`), with a `.done` terminal state in connect.css that drops the spinner without the error line. - regression guard: the core router must never carry the login tools (it IS the hosted tools/list). Tests: 218 pass workspace-wide (9 new in imcp2-local, 3 new in core), including a real MCP round-trip over an in-process duplex pipe (merged tools/list, login lifecycle through tools/call, forwarding into core tools) and browser-played listener tests (pinned page + CSP, allow-list verbatim-match + CORS, redeem refusal paths staying retryable). The built binary answers initialize/tools/list over real stdio. No behavior change to the hosted server. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AqNkpMQiQHzYxC2djTfvBK
2695c8a to
490983f
Compare
There was a problem hiding this comment.
Pull request overview
Adds the imcp2-local stdio binary with browser-based Internet Identity authentication while preserving hosted-server behavior.
Changes:
- Adds local login, session handling, and merged MCP tool routing.
- Makes singleton sessions replaceable and supports local callback completion.
- Registers the new crate and shared dependencies.
Reviewed changes
Copilot reviewed 9 out of 10 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
crates/imcp2-local/src/server.rs |
Wraps core tools with local authentication tools. |
crates/imcp2-local/src/main.rs |
Configures and launches the stdio server. |
crates/imcp2-local/src/login.rs |
Implements the loopback browser-login flow. |
crates/imcp2-local/Cargo.toml |
Defines the local binary crate. |
crates/imcp2-core/src/tools.rs |
Adds replaceable singleton sessions. |
crates/imcp2-core/src/lib.rs |
Exports SessionSlot. |
crates/imcp2-core/src/iiconnect.rs |
Handles local callback completion. |
crates/imcp2-core/src/assets/connect.css |
Styles the completed-login state. |
Cargo.toml |
Registers the crate and shared dependencies. |
Cargo.lock |
Locks new dependencies. |
Suppressed comments (2)
crates/imcp2-local/src/login.rs:493
- The unconditional last-writer-wins commit allows an expired/replaced redeem to become current. For example, a redeem claimed just before the 10-minute deadline can remain in
mcp_register_v2while the watchdog removes it and a new flow starts; when the old call returns, it overwrites the new flow's session slot. Revalidate that this session is still the active, unexpired pending flow under the lock before committing it.
// Fill the shared slot LAST-WRITER-WINS: II bound the grant to this
// session's key, so even if a newer flow replaced the pending entry
// mid-call, this successful login is real and becomes current.
driver.inner.slot.set(body.state.clone());
crates/imcp2-local/src/server.rs:123
auth_statusis also advertised without an object-rootedoutputSchemaor matchingstructuredContent, contrary to the all-tools contract enforced incrates/imcp2-core/src/tools.rs:2521-2537. Define a structured status output and return it alongside the human-readable text so the merged local surface preserves the same MCP contract as the core tools.
#[tool(
description = "Report the Internet Identity sign-in state of this local server: signed in (with the session principal, access level, and time to expiry), sign-in pending (with the link to finish it), expired, or signed out.",
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 9 out of 10 changed files in this pull request and generated 1 comment.
Suppressed comments (3)
crates/imcp2-local/src/login.rs:296
- A refresh flow can coexist with the still-live previous grant, but this grant-first ordering hides that flow: after
authenticate(refresh=true),auth_statusimmediately reports the old account as signed in instead of reporting the pending link. An agent can therefore treat an account switch/access upgrade as complete before the browser handshake finishes. Check a non-expired pending flow before the existing grant (while still falling back to the grant when the pending flow has expired).
if let Some(g) = &state.grant {
if !g.expired() {
return LoginStatus::SignedIn(g.clone());
}
}
crates/imcp2-local/src/server.rs:85
authenticateomitsoutput_schemaand returns only text, so the merged tool surface breaks the repository's established MCP contract that every tool exposes an object-rooted output schema and matching structured content (crates/imcp2-core/src/tools.rs:2521-2538; all core tool attributes follow this pattern). Add a small serializable output type, declare it withoutput_schema, and populatestructuredContentalongside the human-readable message.
#[tool(
description = "Sign in with Internet Identity. Returns an id.ai sign-in link for the USER to open in their browser (the server also tries to open it for them), and returns immediately — it never waits for the browser. After the user finishes, auth_status (or simply retrying the tool that needed a session) confirms the session. Call this when a tool answers that it needs an authenticated session. Repeat calls while a sign-in is pending return the same link.",
annotations(
title = "Sign in with Internet Identity",
read_only_hint = false,
destructive_hint = false,
open_world_hint = true
)
crates/imcp2-local/src/server.rs:129
auth_statusalso lacks the object-rootedoutput_schemaand structured result required by the established tool convention (crates/imcp2-core/src/tools.rs:2521-2538). Returning a structured state (for example, state plus optional URL/grant details) is especially important here so clients do not have to parse changing prose to determine whether login is pending or complete.
#[tool(
description = "Report the Internet Identity sign-in state of this local server: signed in (with the session principal, access level, and time to expiry), sign-in pending (with the link to finish it), expired, or signed out.",
annotations(
title = "Check sign-in status",
read_only_hint = true,
destructive_hint = false,
open_world_hint = false
)
…le-redeem guard - authenticate/auth_status now declare the object-rooted outputSchema and attach matching structuredContent (one AuthOutput shape for the whole sign-in lifecycle), preserving the all-tools MCP contract the core surface enforces. - auth_status reports a live pending handshake BEFORE a live grant, matching begin's precedence: after authenticate(refresh=true) the truthful answer is "a replacement sign-in is waiting", not "signed in". - The redeem's commit is no longer last-writer-wins: each flow carries an epoch, and a stale redeem (claimed, then outlived by the watchdog while its mcp_register_v2 call ran) is refused when a NEWER pending or completed sign-in exists — while a late success with nothing newer around still lands. Tests: 220 pass workspace-wide (2 new: pending-outranks-grant status, stale-redeem supersession) with structured-content assertions added to the MCP round-trip and the outputSchema check extended to the merged surface. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AqNkpMQiQHzYxC2djTfvBK
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 9 out of 10 changed files in this pull request and generated no new comments.
Suppressed comments (1)
Previously missed (1) — in code that hasn't changed since the last review.
crates/imcp2-local/src/login.rs:207
- A grant is considered active solely from its recorded expiration. If II has already returned
Unauthorizedbecause the grant was revoked or replaced,identities.rsonly returnsRECONNECT_MSGand does not invalidate thisGrant. Consequently, a normalauthenticatecall reportsAlreadySignedIn(andauth_statusreportssigned_in) until the original expiry, instead of starting the fresh-session flow required afterUnauthorized. Propagate that definitive invalidation to the local login state/slot, or otherwise makebegindistinguish a revoked session so reauthentication works without requiring callers to discoverrefresh=truethrough an extra failed call.
if !refresh {
if let Some(g) = &state.grant {
if !g.expired() {
return Ok(BeginOutcome::AlreadySignedIn(g.clone()));
…local-deploy-jpof9i-stage2 # Conflicts: # crates/imcp2-core/src/tools.rs
The epoch guard fixed only the local half of the stale-redeem race: II's mcp_register_v2 REPLACES the anchor's previous grant, so a timed-out flow's slow registration landing after a newer flow's left II honoring the old session while the guarded slot kept naming the new one — every subsequent tool call would come back Unauthorized. A local commit-time check cannot undo the remote side effect. Replace the guard with what the race actually needs: a registration mutex that serializes each redeem's mcp_register_v2 AND its local commit, across flows. The last registration to settle at II is the live grant, and under the lock the commit order equals the settle order, so the slot always names the session II actually honors — in both interleavings of an old in-flight registration and its replacement. The per-flow single-flight claim stays (it stops page double-submits); the epoch machinery goes. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AqNkpMQiQHzYxC2djTfvBK
The design's loopback hardening requires it (bind 127.0.0.1 explicitly AND validate the Host header, the local analogue of the hosted server's allowed-hosts list), and the listener accepted any Host: a page on an attacker's hostname that rebinds to 127.0.0.1 could reach the three routes with `Host: attacker.example`. Every URL this flow hands out carries exactly one authority — this listener's 127.0.0.1:<port> — so a middleware now rejects anything else (exact string match, like II's own allow-list check) before routing. Even without it the routes are largely inert to a rebinder (/redeem requires the connect state and a chain targeting the in-process X); this closes the door outright. Tested with raw-socket requests: a rebound hostname and even localhost:<port> bounce with 403; the one advertised authority passes. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AqNkpMQiQHzYxC2djTfvBK
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 9 out of 10 changed files in this pull request and generated no new comments.
Suppressed comments (2)
Previously missed (1) — in code that hasn't changed since the last review.
crates/imcp2-local/src/server.rs:176
- This advertised tool description always claims that the server tries to open the browser, even when
IMCP2_NO_OPENdisables that behavior. Since clients use this description to explain the flow, qualify the claim so it remains accurate in both configurations.
This issue also appears on line 203 of the same file.
description = "Sign in with Internet Identity. Returns an id.ai sign-in link for the USER to open in their browser (the server also tries to open it for them), and returns immediately — it never waits for the browser. After the user finishes, auth_status (or simply retrying the tool that needed a session) confirms the session. Call this when a tool answers that it needs an authenticated session. Repeat calls while a sign-in is pending return the same link.",
crates/imcp2-local/src/server.rs:207
- On a repeated
authenticatecall,freshis false andLoginDriver::begindoes not invokeopen::that, but this message still says the system was asked to open the browser whenever auto-open is enabled. Gate this text onfreshas well so the tool result accurately describes what happened.
let opener = if self.auto_open {
"I also asked the system to open it in your browser. "
} else {
""
};
The pinned page's inline script used single-character names (t, cls, c, r, d); source stays readable — minification, if ever wanted, is a build transformation, not a source style. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AqNkpMQiQHzYxC2djTfvBK
…lver The SessionSource enum put deployment knowledge (bearer vs singleton) inside core's tool layer. Replace it with an injected resolver: core only asks "which already-validated session does this call act as?", and each binary owns its authentication end to end, in its own package — the hosted crate's require_token middleware validates the bearer and its resolver reads the stashed AuthedSession back (both now defined in imcp2's auth.rs again); the local binary's login flow fills its SessionSlot (now defined in imcp2-local's login.rs) and hands core the slot-reading resolver. Core drops its `http` dependency along the way — the request-extension read was the only use. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AqNkpMQiQHzYxC2djTfvBK
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 12 out of 13 changed files in this pull request and generated no new comments.
Suppressed comments (1)
Previously missed (1) — in code that hasn't changed since the last review.
crates/imcp2-local/src/login.rs:420
- The Stage 2 acceptance criteria in
docs/scoping-local-deployment.md:621-624require an automated Chromium/Firefox run plus Safari/enterprise-policy validation before this loopback callback flow is declared working. This PR explicitly leaves that gate unchecked and the reqwest test cannot exercise browser mixed-content/PNA policy, fragment handling, or the page script. Complete the required browser compatibility gate (or align the stated stage/scope) before shipping Stage 2.
/// GET /callback — the pinned fragment-reading page (shared with the hosted
/// server), pointed at this listener's `/redeem`, with the same non-CSP
/// hardening headers the hosted server adds.
async fn callback_page() -> Response {
…h the opener - login.rs (~1000 lines) becomes the login/ module: mod.rs (the driver and its state machine), slot.rs (the SessionSlot), routes.rs (the loopback listener's three routes + Host validation), tests.rs. - CONTACT -> CONTACT_EMAIL, saying what kind of string it is. - The browser opener uses open::that_detached: open 5.x already nulls the launcher's stdio either way (so nothing could interleave with the JSON-RPC stream), but detached additionally double-forks the launcher away off macOS, so it can never hold a thread or a handle on us. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AqNkpMQiQHzYxC2djTfvBK
…blind to it Component 7 now says what the reviewed implementation does: IcTools asks one injected SessionResolver "which already-validated session is this call?" and knows nothing else — not the transport, not how callers are validated, not whether the deployment is single- or multi-user. Each binary owns its authentication end to end in its own package (the hosted bearer middleware + AuthedSession in imcp2's auth.rs; the local login flow's SessionSlot in imcp2-local), and the doc spells out why the seam is a value-returning callback rather than a middleware slot in core: real authentication middleware needs its transport, so the middlewares live with their transports and core receives only their outcome. Core's dependency profile loses `http` accordingly, and the concise doc's component 7 matches. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AqNkpMQiQHzYxC2djTfvBK
…is PR - The local binary now runs the same 60-second session reaper the hosted server does: expired grants, abandoned sign-in keys, and their cached delegations leave memory promptly instead of accumulating over a long-lived process (below MAX_PENDING_CONNECTS nothing else scans the map). - The register -> slot/grant commit -> listener-shutdown success path now has an in-repository test ON THIS PR: the PocketIC end-to-end login (a live II canister issues a genuine canister-signed registration delegation; the test plays the browser over real HTTP against the real listener) moves here from the follow-up branch, gated exactly like the hosted crate's harness (cargo feature `e2e` + II_WASM/POCKET_IC_BIN at runtime). Run here and passing. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AqNkpMQiQHzYxC2djTfvBK
The reaper removes an expired session's server-side state, but the slot kept resolving its id — session-requiring tools would then bypass their "needs an authenticated session" guidance and surface internal errors (or re-create a keyless session via ensure_session). The slot now stores the grant expiration II returned and reports NO session the moment the grant lapses, so unauthenticated is immediate and accurate — the reaper's 60-second cadence frees memory, it does not gate authentication. auth_status keeps its Expired reporting via the driver's retained Grant. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AqNkpMQiQHzYxC2djTfvBK
A minor bump rather than a patch, because both the library API and the served tool surface changed incompatibly since 0.2.0. `imcp2` no longer contains the tool surface and the II session engine: they moved to a new sibling crate, `imcp2-core` (#150, #157), which `imcp2` now depends on by version and which publishes alongside it. The re-exports in `src/lib.rs` keep the familiar names resolving (`IiInstance`, `SessionGauges`, `Agent`, `IC_URL`), but the crate an embedder links has changed shape, and `IcTools` is gone — split into `IcCanisterTools` and `IcProtocolTools` (#157). The surface an MCP client sees changed too: only the app/canister tools are served (#158), state-changing calls now reach only applications that publish a service declaration (#166), the ledger transfer/approval methods are refused (#154, #163), `icp_top_up_canister` is instructions-only (#153), and routine replies no longer echo the per-app principal (#177). Under cargo's 0.x rules `0.2` is the compatibility range, so shipping this as 0.2.1 would hand the break to everyone on `imcp2 = "0.2"` — the requirement our own README recommends — the next time they ran `cargo update`. 0.3.0 opens a new range instead; existing users stay on 0.2.x until they choose to move. The README's dependency line follows, as `"0.2"` would no longer resolve to a current release. `imcp2-core` moves in lockstep: publish-crate.yml checks the tag against both manifests, and the workspace pin that binds them (`imcp2-core = { path = …, version = … }`) has to name the same number or the path dependency stops resolving. `imcp2-local` moves with them — it releases on its own `imcp2-local-v*` tags and has never been cut, so there is nothing to keep it behind, and its `--version` output would otherwise report 0.2.0 from a 0.3.0 tree. Claude-Session: https://claude.ai/code/session_01GqhkbpCwxs67F5E2EzyDwh Co-authored-by: Claude <noreply@anthropic.com>
Summary
Stage 2 of the local-deployment design (docs merged in #148, Stage 1 merged in #77): ship
imcp2-local, the single-user binary an AI tool (Claude Desktop/Code, Codex, Cursor, …) spawns and talks to over stdio. It serves the same tool surface as the hosted server against the same mainnet and production Internet Identity, and carries none of the hosted server's OAuth machinery — signing in is a built-in browser handshake behind an in-bandauthenticatetool. No behavior change to the hosted server.Related issues
Implements Implementation Stages → Stage 2 of the design docs merged in #148 (components 3–7).
Changes
crates/imcp2-local(new bin crate,publish = false— distributed as release binaries per the design's Stage 3):login.rs):authenticatemints a fresh session id + registration key, binds a transient loopback listener on127.0.0.1:0, and returns the id.ai connect link in-band (plus best-effortopen::that), never blocking on the callback. Exactly three routes — the pinned callback page (shared with hosted, same CSP + hardening headers), the slim redeem, and the #4091 allow-list (CORS-readable,no-store) — Host-validated against the listener's own authority (anti-DNS-rebinding, per the design's loopback hardening), torn down on redeem, on the 10-minute handshake timeout (hosted'sCONNECT_TTL), or when a fresh flow replaces it. The redeem mirrors the hosted order (validate state → parse before claiming → single-flight claim → onemcp_register_v2call) minus the OAuth tail: success answers{"done": true}; a registration mutex serializes register+commit across flows so the local slot always names the session II actually honors.server.rs): the two local-only login tools live onLocalServer's own#[tool_router];tools/listis the merged surface (26 core + 2),tools/calldispatches to the login router first and forwards everything else (calls, resources,get_tool) to the innerIcToolshandler verbatim. Both tools declare object-rootedoutputSchemas and attach matchingstructuredContent.get_infoidentifies asimcp2-localand appends sign-in guidance to core's instructions.main.rs: mainnet agent (IMCP2_IC_URLoverride;IMCP2_FETCH_ROOT_KEYhonoured only for loopback endpoints — the local-replica test configuration), production II (II_URL_PROD/II_CANISTER_ID_PROD), management identity derived at the hosted origin by default (IMCP2_MANAGEMENT_ORIGIN) so the same anchor keeps the same controller principal across deployments,IMCP2_NO_OPEN; all logging to stderr (stdout is the JSON-RPC channel).imcp2-core(the seams Stage 1 left for the login driver, refined per review):IcToolstakes an injectedSessionResolverthat only reports the already-validated session for a call. The hosted crate'srequire_tokenmiddleware validates the bearer and its resolver reads the stashedAuthedSessionback (both defined inimcp2'sauth.rs); the local binary's login flow fills itsSessionSlot(defined inimcp2-local'slogin.rs) and injects a slot-reading resolver. Core drops itshttpdependency along the way.{"done": true}success arm ("Signed in — you can close this tab",.doneterminal state inconnect.css); hosted keeps answeringredirect.tools/list).imcp2-localadded to members;axum/anyhow/uuid/tracing-subscriberpins lifted to[workspace.dependencies].Testing
cargo build --workspace --all-targets/cargo test --workspace --all-targets— all suites green (hosted 52+10+9 unchanged; core 142; imcp2-local 12, including forged-Host rejection via raw sockets and the MCP round-trip with structured-content assertions)tools/list(28) → login lifecycle throughtools/call(signed out → link → pending) → forwarded core toolAccess-Control-Allow-Origin: *andno-store; redeem refusal paths (unknown state, malformed delegation, garbage signatures) all offline and all leave the flow retryableinitialize/tools/listover real stdio (28 tools, identifies asimcp2-local)cargo check --features e2e --all-targets(hosted PocketIC harness intact);cargo fmt --checkclean on the new crate;cargo clippy: no warnings inimcp2-localcargo publish --dry-run -p imcp2-corestill rehearses;cargo package -p imcp2is blocked onimcp2-core's first (manual) crates.io release — pre-existing, reproduced identically on the Stage 1 base commitChecklist
🤖 Generated with Claude Code
https://claude.ai/code/session_01AqNkpMQiQHzYxC2djTfvBK