From 30a5a395e3be8025545d64180c181aa4ef174c85 Mon Sep 17 00:00:00 2001 From: Raymond Khalife Date: Tue, 3 Nov 2026 18:18:25 +0000 Subject: [PATCH 1/5] Serve icp_deploy_bundle and icp_cycles_balance from the local MCP server The last of three changes bringing `icp deploy` to the local MCP server: the tools over the bundle host and the deploy pipeline, and their wiring. - `icp_deploy_bundle` takes a bundle (by absolute path, or inline as base64 bounded on its length before decoding), the environment name, and optionally the canister-id mapping an earlier deploy returned; it answers with the status, the mapping to pass back next time, the canisters created and a per-phase log, as structured content under a declared output schema. It needs a session granted "Actions & questions", checked before the bundle is decoded. - Calls are signed as the user's CLI identity: their default Internet Identity account at `https://cli.id.ai`, the origin `icp identity link web` signs in at, so canisters created here are the ones `icp` lists and controls, funded from the same cycles balance. It is not the management identity the canister lifecycle tools act as; the docs say so. A per-app delegation lives five minutes and a deploy can run longer, so the agent signs through `RefreshingIdentity`, which swaps in a fresh delegation from the session store every ten seconds, for the same principal, until the deploy ends. - `icp_cycles_balance` reports that principal and its cycles-ledger balance, the check a deploy that will create canisters starts with. Core's tool of that name sits in the protocol half the local server does not serve; this is the same logic on the served surface. `cycles_balance_of` is split out of core's `cycles_balance` and `Identities::agent_as` made public for it. - The local server composes the deploy router next to the login tools, serves the `skill://deploy-with-mcp` skill (bundle, deploy, write the mapping back into `.icp/data/mappings/.ids.json`) ahead of core's bundled skills, and adds a DEPLOYING paragraph to its instructions. README and the crate docs describe the tool, the identity it acts as and the `ic`-only rule. Co-Authored-By: Claude Fable 5.1 --- Cargo.lock | 1 + crates/imcp2-core/src/identities.rs | 4 +- crates/imcp2-core/src/lib.rs | 4 + crates/imcp2-core/src/management.rs | 11 ++ crates/imcp2-deploy/src/identity.rs | 159 +++++++++++++++ crates/imcp2-deploy/src/lib.rs | 58 ++++-- .../static/skills/deploy-with-mcp.md | 181 ++++++++++++++++++ crates/imcp2-local/Cargo.toml | 5 + crates/imcp2-local/README.md | 63 +++++- crates/imcp2-local/src/main.rs | 17 +- crates/imcp2-local/src/server.rs | 117 +++++++++-- 11 files changed, 577 insertions(+), 43 deletions(-) create mode 100644 crates/imcp2-deploy/src/identity.rs create mode 100644 crates/imcp2-deploy/static/skills/deploy-with-mcp.md diff --git a/Cargo.lock b/Cargo.lock index b9c2778..1a5bd03 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2515,6 +2515,7 @@ dependencies = [ "dirs", "hex", "imcp2-core", + "imcp2-deploy", "open", "pocket-ic", "reqwest 0.13.4", diff --git a/crates/imcp2-core/src/identities.rs b/crates/imcp2-core/src/identities.rs index 2864451..7210479 100644 --- a/crates/imcp2-core/src/identities.rs +++ b/crates/imcp2-core/src/identities.rs @@ -472,7 +472,9 @@ impl Identities { /// transport, route provider, and root key, so calls signed as `identity` /// go through the embedding application's boundary-node routing — never a /// second, hard-coded endpoint. - pub(crate) fn agent_as(&self, identity: I) -> Agent { + /// A clone of the injected base agent signing as `identity`: the same + /// endpoint, root key and boundary-node routing as every other call. + pub fn agent_as(&self, identity: I) -> Agent { let mut agent = self.agent.clone(); agent.set_identity(identity); agent diff --git a/crates/imcp2-core/src/lib.rs b/crates/imcp2-core/src/lib.rs index c56e2e8..b05d02a 100644 --- a/crates/imcp2-core/src/lib.rs +++ b/crates/imcp2-core/src/lib.rs @@ -60,6 +60,10 @@ pub use calls::guard_candid_text; /// crate links. pub use ic_agent::{self, Agent}; pub use identities::{IiInstance, SessionGauges}; +/// The cycles-ledger balance of a session's management principal: the +/// `icp_cycles_balance` tool's logic, for an embedder that serves that tool +/// on its own surface (the local server's deploy tools do). +pub use management::{cycles_balance, cycles_balance_of, CyclesBalance}; pub use tools::{IcCanisterTools, IcProtocolTools, IcTools, SessionResolver}; /// A sensible default IC API boundary node (the public mainnet endpoint) for diff --git a/crates/imcp2-core/src/management.rs b/crates/imcp2-core/src/management.rs index b4142a1..ee66ae9 100644 --- a/crates/imcp2-core/src/management.rs +++ b/crates/imcp2-core/src/management.rs @@ -152,6 +152,17 @@ fn default_init_arg() -> String { /// here can operate a canister the user created. pub async fn cycles_balance(ids: &Identities, session_id: &str) -> Result { let (agent, principal) = management_agent(ids, session_id).await?; + cycles_balance_of(&agent, principal).await +} + +/// The cycles-ledger balance of `principal`, read through `agent`. The query +/// is anonymous-readable, so the agent's identity only matters for routing; +/// callers acting as a principal other than the management identity (the +/// deploy tools) pair this with their own agent. +pub async fn cycles_balance_of( + agent: &Agent, + principal: Principal, +) -> Result { let ledger = parse_principal(CYCLES_LEDGER)?; let account = Account { owner: principal, subaccount: None }; let arg = Encode!(&account).map_err(|e| format!("encode account: {e}"))?; diff --git a/crates/imcp2-deploy/src/identity.rs b/crates/imcp2-deploy/src/identity.rs new file mode 100644 index 0000000..a303a9c --- /dev/null +++ b/crates/imcp2-deploy/src/identity.rs @@ -0,0 +1,159 @@ +//! An identity that stays signed in for as long as a deploy runs. +//! +//! The per-app delegations `imcp2_core::identities` mints are capped at five +//! minutes by Internet Identity, and the store re-derives one on request +//! when the cached one is near expiry. An `ic-agent` holds one `Identity` +//! for its lifetime, though, and `Identity::sign` is synchronous, so a deploy +//! that built its agent over a fixed `DelegatedIdentity` would start failing +//! signatures partway through an asset sync. [`RefreshingIdentity`] signs +//! with whatever delegation is current, and a task alongside it asks the +//! store for a fresh one on a short cadence until the identity is dropped. + +use std::{ + sync::{Arc, RwLock, Weak}, + time::Duration, +}; + +use candid::Principal; +use ic_agent::{ + agent::EnvelopeContent, + identity::{Delegation, SenderInfo, SignedDelegation}, + Identity, Signature, +}; +use imcp2_core::identities::Identities; + +/// How often the refresher asks the store for the session's delegation. The +/// store answers from its cache while that is fresh (no network), and +/// re-derives inside its 30-second expiry margin, so this cadence decides how +/// much of that margin is left when the new delegation lands. +const REFRESH_EVERY: Duration = Duration::from_secs(10); + +/// An `Identity` whose signing delegation is swapped for a fresh one while a +/// deploy runs. Its `sender` is fixed at construction: a refresh is accepted +/// only from an identity for the same principal, so nothing signed by the +/// agent can change hands midway. +pub struct RefreshingIdentity { + sender: Principal, + current: RwLock>, +} + +impl RefreshingIdentity { + /// Derive the session's identity at `origin` now, and keep it fresh until + /// the returned handle is dropped. + pub async fn start( + identities: Identities, + session_id: String, + origin: &'static str, + ) -> Result, String> { + let initial = identities.delegated_identity(&session_id, origin, None).await?; + let this = Arc::new(Self::over(Arc::new(initial))?); + let weak = Arc::downgrade(&this); + tokio::spawn(refresh_loop(weak, identities, session_id, origin)); + Ok(this) + } + + /// Wrap `initial`; refreshes come through [`Self::refresh_with`]. + pub fn over(initial: Arc) -> Result { + let sender = initial.sender()?; + Ok(Self { sender, current: RwLock::new(initial) }) + } + + /// Replace the signing identity. Refused when `fresh` is for another + /// principal: a deploy must finish as the principal it started as. + pub fn refresh_with(&self, fresh: Arc) -> Result<(), String> { + let sender = fresh.sender()?; + if sender != self.sender { + return Err(format!( + "refreshed identity is {sender}, not {} the deploy started as", + self.sender + )); + } + *self.current.write().unwrap_or_else(|e| e.into_inner()) = fresh; + Ok(()) + } + + fn current(&self) -> Arc { + self.current.read().unwrap_or_else(|e| e.into_inner()).clone() + } +} + +/// Runs until the identity it refreshes is gone: the deploy drops its agent, +/// the agent drops the identity, and the next tick finds nothing to refresh. +async fn refresh_loop( + identity: Weak, + identities: Identities, + session_id: String, + origin: &'static str, +) { + loop { + tokio::time::sleep(REFRESH_EVERY).await; + let Some(identity) = identity.upgrade() else { return }; + match identities.delegated_identity(&session_id, origin, None).await { + Ok(fresh) => { + if let Err(e) = identity.refresh_with(Arc::new(fresh)) { + tracing::warn!("deploy identity not refreshed: {e}"); + } + } + // Transient: the current delegation stays in use, and the next + // tick tries again. If the session grant itself has lapsed the + // deploy's calls fail with II's own reason. + Err(e) => tracing::warn!("deploy identity not refreshed: {e}"), + } + } +} + +impl Identity for RefreshingIdentity { + fn sender(&self) -> Result { + Ok(self.sender) + } + fn public_key(&self) -> Option> { + self.current().public_key() + } + fn sign(&self, content: &EnvelopeContent) -> Result { + self.current().sign(content) + } + fn sign_delegation(&self, content: &Delegation) -> Result { + self.current().sign_delegation(content) + } + fn sign_arbitrary(&self, content: &[u8]) -> Result { + self.current().sign_arbitrary(content) + } + fn delegation_chain(&self) -> Vec { + self.current().delegation_chain() + } + fn sender_info(&self) -> Option { + self.current().sender_info() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use ic_agent::identity::BasicIdentity; + + fn key(seed: u8) -> Arc { + Arc::new(BasicIdentity::from_raw_key(&[seed; 32])) + } + + #[test] + fn a_refresh_keeps_the_principal_and_takes_the_new_key() { + let first = key(1); + let identity = RefreshingIdentity::over(first.clone()).unwrap(); + assert_eq!(identity.sender().unwrap(), first.sender().unwrap()); + + // Same key again: accepted, and the signer is the new object. + let again = key(1); + identity.refresh_with(again.clone()).unwrap(); + assert_eq!( + identity.sign_arbitrary(b"x").unwrap().signature, + again.sign_arbitrary(b"x").unwrap().signature + ); + + // A different principal is refused and leaves the current one in place. + let other = key(2); + let err = identity.refresh_with(other.clone()).unwrap_err(); + assert!(err.contains("not"), "{err}"); + assert_eq!(identity.public_key(), first.public_key()); + assert_ne!(identity.public_key(), other.public_key()); + } +} diff --git a/crates/imcp2-deploy/src/lib.rs b/crates/imcp2-deploy/src/lib.rs index 162a0cb..4d01d56 100644 --- a/crates/imcp2-deploy/src/lib.rs +++ b/crates/imcp2-deploy/src/lib.rs @@ -1,26 +1,50 @@ -//! imcp2-deploy — deploying an icp project bundle to the Internet Computer -//! through the icp CLI's own project library (`icp-project`, the code behind -//! `icp deploy`). +//! imcp2-deploy — the `icp_deploy_bundle` MCP tool: deploy an icp project +//! bundle to the Internet Computer as the user, through the icp CLI's own +//! project library (`icp-project`, the code behind `icp deploy`). Next to it, +//! `icp_cycles_balance`: the balance those deploys create canisters from. +//! +//! The tool takes a bundle (the `.tar.gz` that `icp project bundle` writes), +//! the name of an environment the bundle declares, and optionally the canister +//! ids an earlier deploy produced; it answers with the deploy's status and the +//! resulting canister-id mapping. Canisters the mapping does not name are +//! created (funded from the user's cycles-ledger balance), the rest are +//! upgraded in place; every phase of `icp deploy` runs — including the sync +//! steps that upload a frontend canister's assets. +//! +//! Calls are signed as the user's **CLI identity**: their default Internet +//! Identity account at [`CLI_ORIGIN`] (`https://cli.id.ai`), the origin +//! `icp identity link web` derives at. It is the principal the icp CLI already +//! deploys as on the user's machine, so canisters created here are the ones +//! `icp` lists and controls, and the cycles that fund them are the CLI's. This +//! is deliberately NOT the management identity the canister lifecycle tools +//! act as (the default account at the server's own origin): a canister a +//! deploy creates is controlled by the CLI principal, and reaching it through +//! the lifecycle tools means adding their principal as a controller first. //! //! This crate is composed into the local server only. It depends on //! `icp-project` straight from the icp-cli repository, which crates.io does //! not allow, so it cannot be part of the published `imcp2-core`/`imcp2` //! crates (see `Cargo.toml`). //! -//! [`host`] is the `icp_project::Host` for a bundle: the archive `icp project -//! bundle` writes, treated as untrusted input. The rule it is held to is the -//! library's own, `icp_project::untrusted`, the one `icp --untrusted` +//! The bundle is untrusted input, and the rule it is held to is the +//! library's own: `icp_project::untrusted`, the one `icp --untrusted` //! applies (dfinity/icp-cli#873, on #868 for what `icp project bundle` -//! writes; issue #865): this crate pins icp-cli's main at -//! their merge, composes the rule as the CLI does, and adds the bounds on the -//! archive itself and the checks on the bundle's shape. [`deploy_bundle`] -//! runs the library's deploy operation over it as a given identity: create -//! the canisters the bundle names that do not exist yet, wire their ids, -//! install or upgrade, run the sync steps. The MCP tools that expose it -//! follow in their own change. +//! writes; issue #865). This crate pins icp-cli's main at their merge, composes +//! the rule as the CLI does, and adds the bounds on the archive itself and the +//! checks on the bundle's shape (see [`host`]). -mod calls; -mod deploy; -pub mod host; +use std::{collections::BTreeMap, sync::Arc}; -pub use deploy::{deploy_bundle, BundleSource, DeployOutcome, DeployRequest, RequestError, Status}; +use base64::Engine; +use camino::Utf8PathBuf; +use candid::Principal; +use ic_agent::{identity::DelegatedIdentity, Identity}; +use imcp2_core::{cycles_balance_of, identities::Identities, CyclesBalance, SessionResolver}; +use rmcp::{ + handler::server::wrapper::Parameters, + model::{CallToolResult, ContentBlock}, + schemars, + service::RequestContext, + tool, tool_router, ErrorData as McpError, RoleServer, +}; +use serde::{Deserialize, Serialize}; diff --git a/crates/imcp2-deploy/static/skills/deploy-with-mcp.md b/crates/imcp2-deploy/static/skills/deploy-with-mcp.md new file mode 100644 index 0000000..9467169 --- /dev/null +++ b/crates/imcp2-deploy/static/skills/deploy-with-mcp.md @@ -0,0 +1,181 @@ +--- +name: deploy-with-mcp +description: "Deploy an icp project to the Internet Computer through this MCP server's icp_deploy_bundle tool instead of running icp deploy yourself. Covers the three steps an agent performs: bundle the project with icp project bundle, call icp_deploy_bundle with the bundle and the environment's canister-id mapping, and write the mapping the tool returns back into the project. Use when the user asks you to deploy, ship, publish, upgrade or redeploy an icp project, or asks what icp_deploy_bundle needs. Do NOT use for projects targeting a local network (run icp deploy there) or for building the project (see the icp-cli skill)." +license: Apache-2.0 +metadata: + title: Deploy with this MCP server + category: Infrastructure +--- + +# Deploy with this MCP server + +## What This Is + +This server has one tool that deploys: `icp_deploy_bundle`. It takes a +project bundle, an environment name and the environment's canister-id +mapping, runs every phase of `icp deploy` on the server's side (create, +wire ids, apply settings, install or upgrade, sync), and returns the status +and the resulting mapping. The project itself stays on your side: you build +it, bundle it, and keep its canister ids. + +A deploy is three steps, in order: + +1. Bundle the project with the icp CLI. +2. Call `icp_deploy_bundle` with the bundle, the environment, and the + environment's current canister-id mapping. +3. Write the mapping the tool returns back into the project. + +Skipping step 3 is the mistake that costs the most: the next deploy would +not know the canisters already exist and would create a second set. + +## Prerequisites + +- The icp CLI (0.3.0 or later) on the machine that builds the project. + Installation and project configuration are in the `icp-cli` skill. +- A signed-in session with the "Actions & questions" access level. If a + tool answers that it needs an authenticated session, call `authenticate`, + have the user open the returned id.ai link, then retry. Check with + `auth_status`. +- Cycles. Every canister the bundle names that does not exist yet is created + from the user's cycles balance, 2T cycles each unless `cycles` says + otherwise. `icp_cycles_balance` reports the balance and the principal + it belongs to: the user's CLI identity, the same principal `icp identity + link web` signs in as, so a canister deployed here is one `icp` controls + too. Before a deploy that will create canisters, tell the user how + many and confirm; creation is not undone by a later failure. +- An environment on the `ic` network (mainnet), which every manifest has. + The tool deploys to the Internet Computer only: an environment on any other + network — the default `local`, a testnet — is refused. Deploy those with + `icp deploy` in the project directory instead. + +## Step 1: Bundle the project + +From the project root (the directory holding `icp.yaml`): + +```bash +icp project bundle --output /absolute/path/to/myapp.tar.gz +``` + +This builds every canister, then writes a gzipped tar archive holding the +built wasms, the plugins the sync steps need, and a rewritten `icp.yaml` +whose build steps reference the bundled wasms, pinned by sha256. The bundle +is self-contained: the server needs no toolchain to deploy it. + +Rules that matter here: + +- Use an absolute output path outside any directory a canister syncs (a + frontend's `dist/`, for example), or the archive would include itself. +- Only `plugin` sync steps can be replayed from a bundle. A canister with a + `script` sync step cannot be bundled; the CLI says so. +- The bundle is built for the environments the manifest declares. Pick the + one you will deploy in step 2 now, and build with the matching settings. + +If the build fails, fix the project and rebuild. Nothing has been sent yet. + +## Step 2: Request the deployment + +### Read the environment's canister-id mapping + +The icp CLI keeps one mapping file per environment: + +``` +.icp/data/mappings/.ids.json +``` + +It is a JSON object from canister name to principal: + +```json +{ + "backend": "rrkah-fqaaa-aaaaa-aaaaq-cai", + "frontend": "ryjl3-tyaaa-aaaaa-aaaba-cai" +} +``` + +Read it if it exists. If it does not, this is the first deploy of that +environment and every canister will be created. + +### Call the tool + +Pass the bundle, the environment name, and the mapping as `canister_ids`: + +```json +{ + "bundle_path": "/absolute/path/to/myapp.tar.gz", + "environment": "ic", + "canister_ids": { + "backend": "rrkah-fqaaa-aaaaa-aaaaq-cai", + "frontend": "ryjl3-tyaaa-aaaaa-aaaba-cai" + } +} +``` + +- `bundle_path` must be absolute. When the server cannot read your + filesystem, send the archive inline as `bundle_base64` instead; exactly one + of the two is accepted. +- `environment` is the name as the manifest declares it, `ic` for a default + project. +- `canister_ids` is the file's content. Omit it, or send `{}`, on a first + deploy. Every canister named in it is upgraded in place; every canister the + bundle names that is missing from it is created. + +Canisters named in `canister_ids` are upgraded with the Candid compatibility +check; an incompatible interface change fails the deploy before any code is +replaced. Pass `skip_candid_check: true` only when the user has accepted the +break. `mode` (default `auto`; `install`, `reinstall`, `upgrade`), +`cycles`, `subnet` and `no_create` mirror the flags of `icp deploy`. + +### Read the answer + +The tool returns: + +- `status`: `deployed`, `failed`, or `nothing_to_deploy`. +- `canister_ids`: the mapping after this run, the ids you passed in plus + every id created. Step 3 uses this. +- `created`: the canisters created by this run, in order. +- `error`: what failed, when the status is `failed`. +- `log`: one line per phase and per canister task, with the cause of each + failure. + +A deploy that fails still returns every canister it created before failing, +in both `canister_ids` and `created`. Do step 3 on that answer too, then +fix the cause the log names and run step 2 again with the updated mapping: +the retry upgrades what exists and creates only what is still missing. + +## Step 3: Update the project's mapping file + +Write the returned `canister_ids` into the environment's mapping file, +replacing the entries it names and keeping any others: + +``` +.icp/data/mappings/.ids.json +``` + +Create the `.icp/data/mappings/` directory if it does not exist. The file is +the project's only record of which on-chain canisters belong to it; the +`icp-cli` skill lists committing `.icp/data/` to version control as the +rule, and losing the file as the pitfall. Tell the user which canisters were +created and their ids. + +After this step, `icp deploy -e ` from the CLI and +`icp_deploy_bundle` from here see the same canisters and can be used +interchangeably for that environment. + +## Common Pitfalls + +1. **Deploying twice without writing the mapping back.** The second deploy + creates new canisters next to the first ones, spending cycles on + duplicates. Always do step 3, including after a failure. +2. **Passing a relative `bundle_path`.** The tool refuses it; the server's + working directory is not the project's. +3. **Deploying `local`.** Refused: this tool deploys to the `ic` network + only. Use `icp deploy` in the project directory for local work and for + other networks. +4. **Bundling a stale build.** `icp project bundle` builds first, so the + bundle is current, but a bundle written earlier is not. Rebundle after + every change. +5. **Mapping entries that are not canisters of the environment.** A name in + `canister_ids` that the bundle's manifest does not declare for that + environment is rejected before anything runs. Remove canisters from the + file when they are removed from the project. +6. **Expecting the server to keep state.** It keeps nothing between calls: + no bundle, no mapping. The project's mapping file is the record. diff --git a/crates/imcp2-local/Cargo.toml b/crates/imcp2-local/Cargo.toml index 34ac5f5..d5c0870 100644 --- a/crates/imcp2-local/Cargo.toml +++ b/crates/imcp2-local/Cargo.toml @@ -23,6 +23,11 @@ dist = true # primitives the login listener wraps. The IC agent is constructed from # core's `ic_agent` re-export, so the process links exactly one ic-agent. imcp2-core = { workspace = true } +# The `icp_deploy_bundle` tool: deploying an icp project bundle through the +# icp CLI's project library. Its own crate because it pulls `icp-project` from +# git, which the published crates cannot; this binary is dist-released, so it +# can. (It is what brings wasmtime — the sync-plugin runtime — into the build.) +imcp2-deploy = { path = "../imcp2-deploy" } rmcp = { workspace = true, features = ["server", "macros", "transport-io"] } tokio = { workspace = true, features = ["macros", "rt-multi-thread", "net", "time"] } # The transient loopback login listener (component 5 of the design): three diff --git a/crates/imcp2-local/README.md b/crates/imcp2-local/README.md index deedb86..909d7cf 100644 --- a/crates/imcp2-local/README.md +++ b/crates/imcp2-local/README.md @@ -7,7 +7,10 @@ It serves the same tools as the hosted server at [mcp.internetcomputer.org](https://mcp.internetcomputer.org) — canister reads and writes in textual Candid, app discovery, OQL, canister management — against the same **IC mainnet** and the same **production Internet -Identity**. What it drops is the hosted server's entire OAuth 2.1 layer: a +Identity**, plus two tools of its own: `icp_deploy_bundle`, which deploys an +icp project bundle (see [Deploying a project bundle](#deploying-a-project-bundle)), +and `icp_cycles_balance`, which reports the balance those deploys draw on. +What it drops is the hosted server's entire OAuth 2.1 layer: a single-user process reached over a pipe needs no bearer tokens, so your II login never passes through a third-party server. @@ -136,6 +139,62 @@ Sessions are **in memory only**: you sign in again after a restart, when the grant expires, or if you revoke it at id.ai (Manage access). Signing in again is the same one-step browser round-trip — no client restart needed. +## Deploying a project bundle + +`icp_deploy_bundle` deploys an [icp CLI](https://github.com/dfinity/icp-cli) +project to the Internet Computer as you, running the same phases as +`icp deploy` through the CLI's own project library: it creates the canisters +that do not exist yet (funded from your cycles-ledger balance, 2T cycles each +by default), wires their ids into each other's environment variables, applies +the manifest's settings, installs or upgrades the code (with the Candid +compatibility check on upgrades), and runs the sync steps that upload a +frontend's assets. + +It takes: + +- the **bundle** — the self-contained archive `icp project bundle --output + ` writes, by absolute path (`bundle_path`) or inline (`bundle_base64`); +- the **environment** to deploy, by name, among those the bundle declares + (`ic` for a bundle built with the defaults). It must be on the `ic` + network: the tool deploys to the Internet Computer only, so a locally + launched network or a testnet is refused; +- optionally the **canister ids** from an earlier deploy (`canister_ids`, a + name → id map: the contents of the CLI's `.ids.json`, or what a + previous call returned). Canisters named there are upgraded in place; the + rest are created. + +It answers with the status (`deployed`, `failed`, `nothing_to_deploy`), the +resulting canister-id mapping — pass it back next time to upgrade — the +canisters created, and a log of each phase. A failed deploy still reports every +canister it created before failing, so the run can be picked up rather than +repeated. + +Calls are signed as your **CLI identity**: your default II account at +`https://cli.id.ai`, the origin `icp identity link web` signs in at. That is +the principal the icp CLI deploys as on your machine, so canisters deployed +here are the ones `icp` lists and controls, and they are funded from the same +cycles balance. `icp_cycles_balance` reports that balance and principal. It is +not the management identity the canister lifecycle tools act as (see +`IMCP2_MANAGEMENT_ORIGIN`); to reach a deployed canister through those tools, +add their principal as a controller. The session must have been granted +"Actions & questions" at sign-in. The bundle is extracted into a +scratch directory for the duration of the deploy and removed afterwards; +nothing is written to the project it came from. + +The server also serves a skill for the agent, `skill://deploy-with-mcp`, +next to the IC skills the core surface bundles. It walks through the loop +above from the agent's side: bundle the project with `icp project bundle`, +call `icp_deploy_bundle` with the environment's +`.icp/data/mappings/.ids.json` as `canister_ids`, and write the +mapping the tool returns back into that file so the next deploy upgrades +rather than creates. The document lives at +`crates/imcp2-deploy/static/skills/deploy-with-mcp.md`. + +Why only here: the tool is built on `icp-project`, a crate the icp CLI does not +publish, so it is pulled from git — which the crates.io-published `imcp2-core` +and `imcp2` cannot do. It lives in its own `imcp2-deploy` crate, composed into +this binary alone. + ## Upgrading Client registrations point at a stable installed path, so an upgrade is a @@ -153,7 +212,7 @@ Internet Identity (`https://id.ai`). Environment overrides, mainly for tests: | `IMCP2_IC_URL` | IC API endpoint. | | `IMCP2_FETCH_ROOT_KEY` | Truthy: trust the endpoint's fetched root key — honoured **only** when `IMCP2_IC_URL` targets loopback (a local replica / PocketIC); startup refuses otherwise. | | `II_URL_PROD` / `II_CANISTER_ID_PROD` | Override the Internet Identity instance (e.g. beta II, or an II canister in PocketIC). | -| `IMCP2_MANAGEMENT_ORIGIN` | Derivation origin of the canister-management identity. Defaults to the hosted server's origin so the same anchor keeps the same controller principal locally and hosted. | +| `IMCP2_MANAGEMENT_ORIGIN` | Derivation origin of the canister-management identity. Defaults to the hosted server's origin so the same anchor keeps the same controller principal locally and hosted. The deploy tools do not use it; they act at `https://cli.id.ai`. | | `IMCP2_NO_OPEN` | Truthy: never auto-open the browser on sign-in (the link is still returned in-band). | All diagnostics go to stderr (`RUST_LOG` filters them); stdout is the MCP diff --git a/crates/imcp2-local/src/main.rs b/crates/imcp2-local/src/main.rs index 5f4526a..4accf23 100644 --- a/crates/imcp2-local/src/main.rs +++ b/crates/imcp2-local/src/main.rs @@ -6,6 +6,13 @@ //! authorization server: a single-user process reached over a pipe needs no //! bearer tokens — the OS process boundary is the auth gate. //! +//! On top of the core surface it serves `icp_deploy_bundle` (from +//! `imcp2-deploy`): deploying an icp project bundle through the icp CLI's own +//! project library, as the user's CLI identity (their default II account at +//! `https://cli.id.ai`, the principal `icp identity link web` signs in as, so +//! `icp` and this tool deploy as the same controller). Local-only because +//! that library is a git dependency the published crates cannot carry. +//! //! Signing in is a built-in browser handshake (see `login`): the //! `authenticate` tool returns an id.ai link, a transient loopback listener //! receives II's callback, and the redeemed session lives in memory only. @@ -26,7 +33,8 @@ //! management identity (default `https://mcp.internetcomputer.org`, the //! hosted server's origin, so the same anchor gets the SAME controller/ //! funder principal locally and hosted — canisters created through one -//! stay manageable through the other). +//! stay manageable through the other). Not used by the deploy tools, +//! which act at `https://cli.id.ai`. //! * `IMCP2_NO_OPEN` — truthy disables the best-effort browser auto-open on //! sign-in (the link is always returned in-band regardless). @@ -40,6 +48,7 @@ mod setup; mod e2e_local_login; use imcp2_core::{identities::Identities, IcTools, IiInstance}; +use imcp2_deploy::IcDeployTools; use login::SessionSlot; use rmcp::ServiceExt; use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt}; @@ -167,11 +176,13 @@ async fn serve() -> anyhow::Result<()> { // hands core answers every tool call's "which session?" from it. let slot = SessionSlot::new(); let tools = IcTools::new(agent, identities.clone(), slot.resolver()); + let deploy = IcDeployTools::new(identities.clone(), slot.resolver()); let auto_open = !truthy("IMCP2_NO_OPEN"); let login = login::LoginDriver::new(identities, slot, auto_open); - let service = - server::LocalServer::new(tools, login, auto_open).serve(rmcp::transport::stdio()).await?; + let service = server::LocalServer::new(tools, deploy, login, auto_open) + .serve(rmcp::transport::stdio()) + .await?; tracing::info!("imcp2-local serving MCP over stdio"); // Runs until the client closes the pipe (or the service errors). service.waiting().await?; diff --git a/crates/imcp2-local/src/server.rs b/crates/imcp2-local/src/server.rs index 4dbe029..1cfe69b 100644 --- a/crates/imcp2-local/src/server.rs +++ b/crates/imcp2-local/src/server.rs @@ -1,27 +1,34 @@ //! The local MCP server: [`IcTools`] wrapped in a thin handler that adds the -//! two **local-only login tools**. They cannot live in `imcp2-core` — they -//! drive this binary's loopback listener and browser opener, which core cannot -//! call back into — so this wrapper carries them in its own `#[tool_router]`, -//! answers `tools/list` with the merged list, dispatches `tools/call` to its -//! own router first, and forwards everything else to the inner [`IcTools`] -//! handler verbatim. The hosted server contains none of this code, so its tool -//! surface stays login-free by construction (a core-side test guards the +//! two **local-only login tools** and the **deploy tool**. The login tools +//! cannot live in `imcp2-core` — they drive this binary's loopback listener +//! and browser opener, which core cannot call back into — so this wrapper +//! carries them in its own `#[tool_router]`. The deploy tool +//! (`icp_deploy_bundle` and `icp_cycles_balance`, [`IcDeployTools`]) cannot either, for a different +//! reason: it runs on the icp CLI's project library, a git dependency the +//! published crates cannot carry. The wrapper answers `tools/list` with the +//! merged list, dispatches `tools/call` to its own router, then the deploy +//! router, and forwards everything else to the inner [`IcTools`] handler +//! verbatim. The hosted server contains none of this code, so its tool surface +//! stays login- and deploy-free by construction (a core-side test guards the //! boundary too). use rmcp::handler::server::tool::ToolCallContext; use rmcp::handler::server::wrapper::Parameters; use rmcp::model::{ CallToolRequestParams, CallToolResult, ContentBlock, ListResourcesResult, ListToolsResult, - PaginatedRequestParams, ReadResourceRequestParams, ReadResourceResult, Tool, + PaginatedRequestParams, ReadResourceRequestParams, ReadResourceResult, Resource, + ResourceContents, Tool, }; use rmcp::service::RequestContext; use rmcp::{schemars, tool, tool_router, ErrorData as McpError, RoleServer, ServerHandler}; use crate::login::{BeginOutcome, Grant, LoginDriver, LoginStatus}; use imcp2_core::IcTools; +use imcp2_deploy::IcDeployTools; pub struct LocalServer { tools: IcTools, + deploy: IcDeployTools, login: LoginDriver, /// Whether the login flow will also try to open the browser itself — /// only phrasing: the link is always returned in-band. @@ -159,8 +166,8 @@ fn human_minutes(mins: u64) -> String { #[tool_router] impl LocalServer { - pub fn new(tools: IcTools, login: LoginDriver, auto_open: bool) -> Self { - Self { tools, login, auto_open } + pub fn new(tools: IcTools, deploy: IcDeployTools, login: LoginDriver, auto_open: bool) -> Self { + Self { tools, deploy, login, auto_open } } #[tool( @@ -266,7 +273,18 @@ impl ServerHandler for LocalServer { SIGNING IN. On this local server the user signs in through `authenticate`, which \ returns an id.ai link for them to open. Sessions are held in memory, so the user \ signs in again after a restart, when the grant expires, or if they revoke it at \ - id.ai." + id.ai.\n\n\ + DEPLOYING. This local server adds `icp_deploy_bundle`, the one exception to the \ + rule above that no tool deploys: it deploys an icp project bundle (`icp project \ + bundle --output `, built in the user's environment) to the environment it \ + names, creating the canisters that do not exist yet from the user's cycles \ + balance (`icp_cycles_balance` reports it) and upgrading those passed in \ + `canister_ids`. It deploys as the user's CLI identity (the principal `icp \ + identity link web` signs in as), not the management identity the canister \ + tools act as. It needs an \"Actions & questions\" session, and it returns the \ + canister-id mapping to pass back next time. \ + The `skill://deploy-with-mcp` resource walks through the loop: bundle, deploy, \ + write the mapping back into the project." )); info } @@ -278,6 +296,7 @@ impl ServerHandler for LocalServer { ) -> Result { let mut result = self.tools.list_tools(request, context).await?; result.tools.extend(Self::tool_router().list_all()); + result.tools.extend(IcDeployTools::tool_router().list_all()); Ok(result) } @@ -289,13 +308,20 @@ impl ServerHandler for LocalServer { if Self::tool_router().has_route(&request.name) { let tcc = ToolCallContext::new(self, request, context); Self::tool_router().call(tcc).await + } else if IcDeployTools::tool_router().has_route(&request.name) { + let tcc = ToolCallContext::new(&self.deploy, request, context); + IcDeployTools::tool_router().call(tcc).await } else { self.tools.call_tool(request, context).await } } fn get_tool(&self, name: &str) -> Option { - Self::tool_router().get(name).cloned().or_else(|| self.tools.get_tool(name)) + Self::tool_router() + .get(name) + .cloned() + .or_else(|| IcDeployTools::tool_router().get(name).cloned()) + .or_else(|| self.tools.get_tool(name)) } async fn list_resources( @@ -303,7 +329,14 @@ impl ServerHandler for LocalServer { request: Option, context: RequestContext, ) -> Result { - self.tools.list_resources(request, context).await + let mut result = self.tools.list_resources(request, context).await?; + // The one skill this server adds, next to core's bundled IC skills: the + // deploy loop around `icp_deploy_bundle`, which only exists here. + result.resources.push(Resource::new( + format!("skill://{}", imcp2_deploy::DEPLOY_SKILL_NAME), + format!("IC skill: {}", imcp2_deploy::DEPLOY_SKILL_TITLE), + )); + Ok(result) } async fn read_resource( @@ -311,6 +344,11 @@ impl ServerHandler for LocalServer { request: ReadResourceRequestParams, context: RequestContext, ) -> Result { + if let Some(md) = + request.uri.strip_prefix("skill://").and_then(imcp2_deploy::skill_document) + { + return Ok(ReadResourceResult::new(vec![ResourceContents::text(md, request.uri)])); + } self.tools.read_resource(request, context).await } } @@ -332,8 +370,9 @@ mod tests { ); let slot = SessionSlot::new(); let tools = IcTools::new(agent, identities.clone(), slot.resolver()); + let deploy = IcDeployTools::new(identities.clone(), slot.resolver()); let login = crate::login::LoginDriver::new(identities, slot, /* auto_open */ false); - LocalServer::new(tools, login, /* auto_open */ false) + LocalServer::new(tools, deploy, login, /* auto_open */ false) } // The wrapper's own router carries EXACTLY the two login tools — the rest @@ -389,8 +428,9 @@ mod tests { // A REAL MCP round-trip over an in-process duplex pipe (the same // byte-stream shape stdio serves): initialize; tools/list is the merged - // surface (the 11 served core tools + the 2 login tools); tools/call dispatches - // login tools to the wrapper and everything else to IcTools; the login + // surface (the 10 served core tools + the 2 login tools + the deploy tool); + // tools/call dispatches login tools to the wrapper, the deploy tool to its + // router, and everything else to IcTools; the login // lifecycle (signed out → link → pending) runs through the MCP layer — // all with no network and no browser. #[tokio::test] @@ -411,8 +451,20 @@ mod tests { assert!(instructions.contains("textual Candid"), "core guidance must survive the merge"); let tools = client.list_all_tools().await.expect("tools/list"); - assert_eq!(tools.len(), 12, "10 served core tools + authenticate + auth_status"); - for expected in ["get_canister_candid", "canister_query", "authenticate", "auth_status"] { + assert_eq!( + tools.len(), + 14, + "10 served core tools + authenticate + auth_status + icp_deploy_bundle + \ + icp_cycles_balance" + ); + for expected in [ + "get_canister_candid", + "canister_query", + "authenticate", + "auth_status", + "icp_deploy_bundle", + "icp_cycles_balance", + ] { assert!(tools.iter().any(|t| &*t.name == expected), "missing {expected}"); } @@ -465,6 +517,20 @@ mod tests { assert!(is_error, "an invalid canister id is a tool error"); assert!(text.contains("invalid canister id"), "{text}"); + // The deploy tool dispatches to its own router through the wrapper: + // signed out, it refuses before reading the bundle (the path named here + // does not exist, and must not be reached for) or touching the network. + let (is_error, text, _) = call( + "icp_deploy_bundle", + Some(serde_json::json!({ + "bundle_path": "/nonexistent/bundle.tar.gz", + "environment": "ic" + })), + ) + .await; + assert!(is_error, "a deploy without a session is a tool error"); + assert!(text.contains("authenticated session"), "{text}"); + // Deferral: a protocol/meta tool is not just unlisted — calling it // fails with the router's standard "tool not found" error (the // invalid-params shape any unknown tool name gets), rather than @@ -508,8 +574,13 @@ mod tests { assert_eq!( skills.len(), imcp2_core::skills::BUNDLED_SKILLS.len() - + imcp2_core::skills::BUNDLED_SKILL_REFERENCES.len(), - "every bundled document must be advertised: {skills:?}" + + imcp2_core::skills::BUNDLED_SKILL_REFERENCES.len() + + 1, + "every bundled document, plus this server's deploy skill, must be advertised: {skills:?}" + ); + assert!( + skills.iter().any(|u| u == "skill://deploy-with-mcp"), + "the deploy skill is advertised: {skills:?}" ); for uri in &skills { @@ -557,6 +628,12 @@ mod tests { let api_reference = one("skill://writing-motoko/references/api-reference.md").await; assert!(!api_reference.trim().is_empty()); + // This server's own skill arrives whole through the same surface, and + // names the tool it teaches — which only this server lists. + let deploy = one("skill://deploy-with-mcp").await; + assert_eq!(deploy, imcp2_deploy::DEPLOY_SKILL_MD); + assert!(deploy.contains("icp_deploy_bundle"), "{deploy:.400}"); + // An unknown skill, and an unknown companion of a real skill, are both // resource-not-found — not a panic, a disconnect, or a silent empty. for missing in ["skill://no-such-skill", "skill://writing-motoko/references/no-such.md"] { From 51d7076ac0c6dab98269a0fc0ba8b4ce58e5139b Mon Sep 17 00:00:00 2001 From: Raymond Khalife Date: Tue, 3 Nov 2026 23:10:09 +0000 Subject: [PATCH 2/5] One deploy at a time, as one account, on mainnet, with its record kept past a cut-off MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit What the review found around the tools rather than in the pipeline, each taken as it was raised. A deploy signs as the account the first derivation resolved the session's default to, and asks for that account by number from then on (core's delegated_identity_resolved reports it): the default is the user's to change, and a refresh that came back as another principal would have been refused until the delegation in hand expired. When the store reports the grant over — II's one terminal answer, RECONNECT_MSG, now public — the identity stops signing at once instead of riding out that delegation. Two deploys at once for one project, each with the mapping as it was before either, would create two sets of canisters: a server runs one at a time, and refuses rather than queues a second, since its mapping would be stale by the time it ran. And a deploy's working directory is kept under the OS's local data directory until the outcome is assembled, so a call cut off mid-deploy leaves `.icp/data/mappings/.ids.json` with every canister it created where the README and the skill say to look. The balance check reads the Internet Computer through the same mainnet agent a deploy uses, whatever endpoint the rest of the server is pointed at. `bundle_path` is trimmed and must be absolute before any I/O, and an unparsable id is reported as what it is, not a valid principal. The deploy skill joins the first page of resources alone. The identity the tools act as is described as what it is: the default II account at cli.id.ai, which `icp identity link web` links the CLI to — the CLI's own principal only when its selected identity is that link. The README, the skill, the server instructions and the module docs say so, and point at `icp identity principal` for the comparison. Co-Authored-By: Claude Fable 5.1 --- crates/imcp2-core/src/identities.rs | 39 +- crates/imcp2-deploy/src/deploy.rs | 90 ++- crates/imcp2-deploy/src/identity.rs | 89 ++- crates/imcp2-deploy/src/lib.rs | 658 +++++++++++++++++- .../static/skills/deploy-with-mcp.md | 28 +- crates/imcp2-local/README.md | 32 +- crates/imcp2-local/src/main.rs | 18 +- crates/imcp2-local/src/server.rs | 70 +- 8 files changed, 952 insertions(+), 72 deletions(-) diff --git a/crates/imcp2-core/src/identities.rs b/crates/imcp2-core/src/identities.rs index 7210479..abcd024 100644 --- a/crates/imcp2-core/src/identities.rs +++ b/crates/imcp2-core/src/identities.rs @@ -170,7 +170,12 @@ const II_CANISTER_ID_PROD_DEFAULT: &str = "rdmx6-jaaaa-aaaaa-aaadq-cai"; /// stored grant expiration has passed. Per the spec, any `Unauthorized` means the /// session is over — the caller must start a fresh connect with a fresh session /// key; it must NOT retry. -const RECONNECT_MSG: &str = "Your Internet Identity session is over (the grant expired, was revoked, \ +/// The one answer that ends a session for good: the grant expired, was revoked +/// at id.ai, or was replaced by a newer connection, as Internet Identity itself +/// reported it. Every other failure to derive or refresh a delegation is +/// transient. Public so an embedder holding a long-lived signer (the local +/// server's deploy tools) can tell the two apart and stop signing on this one. +pub const RECONNECT_MSG: &str = "Your Internet Identity session is over (the grant expired, was revoked, \ or was replaced by a newer connection). Reconnect with Internet Identity to continue — do not retry."; /// Shown when a tool needs update (write) access but the II session is @@ -346,6 +351,10 @@ struct Session { /// A cached on-demand per-app account delegation. The chain ends at a per-app key /// distinct from the session key; its seed is kept so the identity can be rebuilt. struct AppDelegation { + /// The account II resolved the delegation to: the number it was asked + /// for, or, asked for the default (`None`), the concrete account that was + /// the default at prepare time (`None` when that is the unnamed one). + account_number: Option, user_key: Vec, chain: Vec, expiration_ns: u64, @@ -1021,18 +1030,35 @@ impl Identities { domain: &str, account_number: Option, ) -> Result { + Ok(self.delegated_identity_resolved(session_id, domain, account_number).await?.0) + } + + /// [`Self::delegated_identity`], also returning the account the delegation + /// was resolved to. Asked for the default account (`None`), II resolves it + /// to whichever account is the default at that moment — a choice the user + /// can change — and this is that account: `Some(n)` for a named one, `None` + /// for the unnamed one. A caller that must keep signing as the same + /// principal across re-derivations (a long deploy) asks for this account + /// explicitly from then on, rather than for "the default" again. + pub async fn delegated_identity_resolved( + &self, + session_id: &str, + domain: &str, + account_number: Option, + ) -> Result<(DelegatedIdentity, Option), String> { self.ensure_session(session_id).await?; // Reuse a cached, still-fresh delegation if present. if let Some(app) = self.cached_fresh(session_id, domain, account_number).await { - return build_identity(&app); + return Ok((build_identity(&app)?, app.account_number)); } // Otherwise derive a fresh one on demand against the II canister. let app = self.derive_app_delegation(session_id, domain, account_number).await?; let identity = build_identity(&app)?; + let resolved = app.account_number; self.store(session_id, domain, account_number, app).await; - Ok(identity) + Ok((identity, resolved)) } async fn cached_fresh( @@ -1048,6 +1074,7 @@ impl Identities { return None; } Some(AppDelegation { + account_number: app.account_number, user_key: app.user_key.clone(), chain: app.chain.clone(), expiration_ns: app.expiration_ns, @@ -1149,6 +1176,7 @@ impl Identities { let chain = vec![signed.into_agent(&app_key_der)?]; Ok(AppDelegation { + account_number: prepared.account_number, user_key: prepared.user_key, chain, expiration_ns: prepared.expiration, @@ -1198,6 +1226,7 @@ impl Identities { .signature .ok_or("the test delegation carried no signature")?; let app = AppDelegation { + account_number: None, user_key: anchor_der, chain: vec![SignedDelegation { delegation, signature }], expiration_ns: expiration, @@ -1761,6 +1790,7 @@ mod tests { s.app_delegations.insert( (domain.to_string(), account), AppDelegation { + account_number: None, user_key: vec![account.unwrap_or(0) as u8], chain: vec![], expiration_ns: exp, @@ -1874,6 +1904,7 @@ mod tests { fn app_delegation(expiration_ns: u64) -> AppDelegation { AppDelegation { + account_number: None, user_key: Vec::new(), chain: Vec::new(), expiration_ns, @@ -2502,6 +2533,7 @@ mod tests { // Build the delegated identity exactly as the server does; the chain // validates because the anchor's signature covers this read-only delegation. let app = AppDelegation { + account_number: None, user_key: user_der.clone(), chain: vec![signed.clone()], expiration_ns: expiration, @@ -2557,6 +2589,7 @@ mod tests { let mut tampered = signed; tampered.delegation.permissions = None; let tampered_app = AppDelegation { + account_number: None, user_key: user_der, chain: vec![tampered], expiration_ns: expiration, diff --git a/crates/imcp2-deploy/src/deploy.rs b/crates/imcp2-deploy/src/deploy.rs index 723f12a..3807dfe 100644 --- a/crates/imcp2-deploy/src/deploy.rs +++ b/crates/imcp2-deploy/src/deploy.rs @@ -28,6 +28,7 @@ use icp_project::{ deploy::{deploy, resolve_targets, DeployParams, DeployReport}, task::Task, }, + prelude::{IC_MAINNET_NETWORK_API_URL, IC_ROOT_KEY}, }; use crate::{calls::AgentCalls, host}; @@ -75,6 +76,13 @@ pub struct DeployRequest { pub no_create: bool, /// Skip the Candid interface compatibility check on upgrades. pub skip_candid_check: bool, + /// Where the deploy's working directory is made (the system temporary + /// directory when `None`). A run that returns removes it; a run that is + /// cut off — the server stopped mid-deploy, the call cancelled — leaves + /// it, and with it `.icp/data/mappings/.ids.json`, which + /// records every canister the deploy created, so a caller who never got + /// the answer can recover the ids instead of creating a second set. + pub workdir: Option, } /// How a deploy ended. @@ -171,12 +179,23 @@ pub async fn deploy_bundle( BundleSource::Path(path) => read_bundle_file(&path, host::MAX_BUNDLE_BYTES).await?, }; - // The scratch directory lives exactly as long as this function: the - // bundle's files, the ids the deploy records, and the build artifacts all - // go with it, whichever way the deploy ends. + // The working directory holds the bundle's files, the ids the deploy + // records, and the build artifacts. Until the deploy starts it is a + // temporary directory, gone with any early return; from then on it is + // kept on purpose and removed only once the outcome — the ids above all — + // has been assembled, so a run that is cut off leaves its record behind. + let parent = match &request.workdir { + Some(dir) => { + std::fs::create_dir_all(dir).map_err(|e| { + RequestError(format!("cannot create the deploy directory '{dir}': {e}")) + })?; + dir.as_std_path().to_path_buf() + } + None => std::env::temp_dir(), + }; let scratch = tempfile::Builder::new() .prefix("imcp2-deploy-") - .tempdir() + .tempdir_in(parent) .map_err(|e| RequestError(format!("cannot create a scratch directory: {e}")))?; let dir = Utf8PathBuf::from_path_buf(scratch.path().to_path_buf()).map_err(|p| { RequestError(format!("scratch directory path is not UTF-8: {}", p.display())) @@ -270,26 +289,13 @@ pub async fn deploy_bundle( }; // The agent is made by the first phase that reaches the network, as the - // CLI does: a deploy that fails to load its wasms never resolves a root - // key. The identity is already unlocked — it is the session's — so what - // is deferred here is only the network resolution. - let network = env.network.clone(); - let host_for_agent = host.clone(); + // CLI does: a deploy that fails to load its wasms never builds one. The + // network is the Internet Computer — `check_bundle` held the environment + // to it — so the agent is the mainnet one, as the balance check's is. let calls = Deferred::new(move || { - let host = host_for_agent.clone(); - let network = network.clone(); let identity = identity.clone(); async move { - let access = - host.network.access(&network).await.map_err(|e| RequestError(error_chain(&e)))?; - let agent = Agent::builder() - .with_url(access.api_url.as_str()) - .with_arc_identity(identity) - .build() - .map_err(|e| { - RequestError(format!("cannot build an agent for {}: {e}", access.api_url)) - })?; - agent.set_root_key(access.root_key); + let agent = mainnet_agent(identity).map_err(RequestError)?; let calls: Arc = Arc::new(AgentCalls::new(agent).map_err(RequestError)?); Ok::<_, RequestError>(calls) @@ -301,6 +307,8 @@ pub async fn deploy_bundle( let (reporter, receiver) = icp_events::channel::(); let collector = tokio::spawn(collect_log(receiver)); + // From here the directory outlives this future (see above). + let workdir = scratch.keep(); let mut report = DeployReport::default(); let result = deploy(&host, &calls, ¶ms, &reporter, &mut report).await; // The receiver ends only once every reporter handle is gone. @@ -332,10 +340,30 @@ pub async fn deploy_bundle( outcome.canister_ids.insert(name.clone(), *id); } - drop(scratch); + // The outcome carries everything the directory recorded; the directory + // has done its job. + drop(host); + if let Err(e) = tokio::fs::remove_dir_all(&workdir).await { + outcome + .log + .push(format!("removing the deploy directory '{}' failed: {e}", workdir.display())); + } Ok(outcome) } +/// An agent for the Internet Computer signing as `identity`: the mainnet +/// endpoint and the pinned root key, the one network a deploy reaches. The +/// balance check uses the same, so what it reports is what a deploy spends. +pub fn mainnet_agent(identity: Arc) -> Result { + let agent = Agent::builder() + .with_url(IC_MAINNET_NETWORK_API_URL) + .with_arc_identity(identity) + .build() + .map_err(|e| format!("cannot build an agent for the Internet Computer: {e}"))?; + agent.set_root_key(IC_ROOT_KEY.to_vec()); + Ok(agent) +} + /// Read the bundle at `path`, which must be an absolute path to a regular /// file of at most `limit` bytes, and nothing else: a device or a pipe reports /// a length that says nothing about what a read of it produces (`/dev/zero` @@ -493,6 +521,7 @@ mod tests { // verified the bundled wasm. no_create: true, skip_candid_check: false, + workdir: None, } } @@ -689,6 +718,23 @@ mod tests { // A `bundle_path` is read only when it is a regular file: a directory // here, and by the same check a device or a pipe, whose length says // nothing about what reading it would produce. + // A deploy works under the directory it is given and removes its own + // when it returns, so a run that is cut off is the only one to leave a + // record there. + #[tokio::test(flavor = "multi_thread")] + async fn a_deploy_works_under_its_directory_and_removes_it_when_done() { + let (wasm, sha) = wasm(); + let parent = tempfile::tempdir().unwrap(); + let workdir = Utf8PathBuf::from_path_buf(parent.path().join("deploys")).unwrap(); + let mut request = request(bundle(&sha, &wasm), "ic"); + request.workdir = Some(workdir.clone()); + let outcome = deploy_bundle(Arc::new(AnonymousIdentity), request).await.unwrap(); + assert_eq!(outcome.status, Status::Failed, "{:?}", outcome.log); + assert!(workdir.is_dir(), "the parent is made"); + let left: Vec<_> = std::fs::read_dir(&workdir).unwrap().collect(); + assert!(left.is_empty(), "the run's own directory is removed: {left:?}"); + } + // The library resolves the mode at install, after creation, and panics on // a spelling it does not know: refused at entry instead, before anything // is read. diff --git a/crates/imcp2-deploy/src/identity.rs b/crates/imcp2-deploy/src/identity.rs index a303a9c..3652523 100644 --- a/crates/imcp2-deploy/src/identity.rs +++ b/crates/imcp2-deploy/src/identity.rs @@ -8,6 +8,13 @@ //! signatures partway through an asset sync. [`RefreshingIdentity`] signs //! with whatever delegation is current, and a task alongside it asks the //! store for a fresh one on a short cadence until the identity is dropped. +//! +//! Two things a refresh must not do: change the principal — the account the +//! first derivation resolved to is the one asked for from then on, and a +//! delegation for any other principal is refused — and outlive the session. +//! When the store reports the grant over (expired, revoked at id.ai, or +//! replaced by a newer sign-in), the identity stops signing at once rather +//! than riding out the delegation it still holds. use std::{ sync::{Arc, RwLock, Weak}, @@ -20,7 +27,7 @@ use ic_agent::{ identity::{Delegation, SenderInfo, SignedDelegation}, Identity, Signature, }; -use imcp2_core::identities::Identities; +use imcp2_core::identities::{Identities, RECONNECT_MSG}; /// How often the refresher asks the store for the session's delegation. The /// store answers from its cache while that is fresh (no network), and @@ -31,24 +38,29 @@ const REFRESH_EVERY: Duration = Duration::from_secs(10); /// An `Identity` whose signing delegation is swapped for a fresh one while a /// deploy runs. Its `sender` is fixed at construction: a refresh is accepted /// only from an identity for the same principal, so nothing signed by the -/// agent can change hands midway. +/// agent can change hands midway. Once [`revoked`](Self::revoke), it signs +/// nothing more. pub struct RefreshingIdentity { sender: Principal, current: RwLock>, } impl RefreshingIdentity { - /// Derive the session's identity at `origin` now, and keep it fresh until - /// the returned handle is dropped. + /// Derive the session's default identity at `origin` now, and keep it + /// fresh until the returned handle is dropped. The account that + /// derivation resolves to is the one refreshed from then on: the default + /// is the user's to change, and a deploy must finish as the principal it + /// started as. pub async fn start( identities: Identities, session_id: String, origin: &'static str, ) -> Result, String> { - let initial = identities.delegated_identity(&session_id, origin, None).await?; + let (initial, account) = + identities.delegated_identity_resolved(&session_id, origin, None).await?; let this = Arc::new(Self::over(Arc::new(initial))?); let weak = Arc::downgrade(&this); - tokio::spawn(refresh_loop(weak, identities, session_id, origin)); + tokio::spawn(refresh_loop(weak, identities, session_id, origin, account)); Ok(this) } @@ -72,6 +84,13 @@ impl RefreshingIdentity { Ok(()) } + /// Stop signing: every signature from now on fails with `why`. The + /// principal stays, so the deploy still reports as whom it was running. + pub fn revoke(&self, why: String) { + let revoked: Arc = Arc::new(Revoked { sender: self.sender, why }); + *self.current.write().unwrap_or_else(|e| e.into_inner()) = revoked; + } + fn current(&self) -> Arc { self.current.read().unwrap_or_else(|e| e.into_inner()).clone() } @@ -79,29 +98,64 @@ impl RefreshingIdentity { /// Runs until the identity it refreshes is gone: the deploy drops its agent, /// the agent drops the identity, and the next tick finds nothing to refresh. +/// Or until the session is: the store's one terminal answer revokes the +/// identity, and the loop ends with it. async fn refresh_loop( identity: Weak, identities: Identities, session_id: String, origin: &'static str, + account: Option, ) { loop { tokio::time::sleep(REFRESH_EVERY).await; let Some(identity) = identity.upgrade() else { return }; - match identities.delegated_identity(&session_id, origin, None).await { + match identities.delegated_identity(&session_id, origin, account).await { Ok(fresh) => { if let Err(e) = identity.refresh_with(Arc::new(fresh)) { tracing::warn!("deploy identity not refreshed: {e}"); } } + // The grant is over — expired, revoked at id.ai, or replaced by a + // newer sign-in — as Internet Identity itself reported it: the + // delegation in hand may still verify for a few minutes, and + // must not be used for them. + Err(e) if e == RECONNECT_MSG => { + tracing::warn!("deploy identity revoked: {e}"); + identity.revoke(e); + return; + } // Transient: the current delegation stays in use, and the next - // tick tries again. If the session grant itself has lapsed the - // deploy's calls fail with II's own reason. + // tick tries again. Err(e) => tracing::warn!("deploy identity not refreshed: {e}"), } } } +/// What a [`RefreshingIdentity`] signs with once its session is over: nothing. +struct Revoked { + sender: Principal, + why: String, +} + +impl Identity for Revoked { + fn sender(&self) -> Result { + Ok(self.sender) + } + fn public_key(&self) -> Option> { + None + } + fn sign(&self, _content: &EnvelopeContent) -> Result { + Err(self.why.clone()) + } + fn sign_delegation(&self, _content: &Delegation) -> Result { + Err(self.why.clone()) + } + fn sign_arbitrary(&self, _content: &[u8]) -> Result { + Err(self.why.clone()) + } +} + impl Identity for RefreshingIdentity { fn sender(&self) -> Result { Ok(self.sender) @@ -156,4 +210,21 @@ mod tests { assert_eq!(identity.public_key(), first.public_key()); assert_ne!(identity.public_key(), other.public_key()); } + + // Revoked, the identity keeps its principal and signs nothing: the + // deploy's next call fails with the session's own reason, and a later + // refresh cannot bring the signer back. + #[test] + fn a_revoked_identity_signs_nothing_more() { + let first = key(1); + let identity = RefreshingIdentity::over(first.clone()).unwrap(); + identity.revoke(RECONNECT_MSG.into()); + assert_eq!(identity.sender().unwrap(), first.sender().unwrap()); + assert_eq!(identity.sign_arbitrary(b"x").unwrap_err(), RECONNECT_MSG); + assert!(identity.public_key().is_none()); + // The refresh loop has ended by then; a refresh that did arrive would + // reinstate signing, so revocation is the loop's last act. + identity.refresh_with(key(1)).unwrap(); + assert!(identity.sign_arbitrary(b"x").is_ok()); + } } diff --git a/crates/imcp2-deploy/src/lib.rs b/crates/imcp2-deploy/src/lib.rs index 4d01d56..018ed43 100644 --- a/crates/imcp2-deploy/src/lib.rs +++ b/crates/imcp2-deploy/src/lib.rs @@ -11,15 +11,26 @@ //! upgraded in place; every phase of `icp deploy` runs — including the sync //! steps that upload a frontend canister's assets. //! -//! Calls are signed as the user's **CLI identity**: their default Internet -//! Identity account at [`CLI_ORIGIN`] (`https://cli.id.ai`), the origin -//! `icp identity link web` derives at. It is the principal the icp CLI already -//! deploys as on the user's machine, so canisters created here are the ones -//! `icp` lists and controls, and the cycles that fund them are the CLI's. This -//! is deliberately NOT the management identity the canister lifecycle tools -//! act as (the default account at the server's own origin): a canister a -//! deploy creates is controlled by the CLI principal, and reaching it through -//! the lifecycle tools means adding their principal as a controller first. +//! Calls are signed as the user's default Internet Identity account at +//! [`CLI_ORIGIN`] (`https://cli.id.ai`), the origin `icp identity link web` +//! links an `icp` identity to. When the CLI's selected identity is such a +//! link to the same account, the two deploy as the same principal: the +//! canisters are the ones `icp` lists and controls, and the cycles are the +//! same balance. The CLI can also use a local key (`icp identity new`) or a +//! link to another account, and those are other principals sharing neither; +//! `icp_cycles_balance` names the principal used here, for the user to +//! compare with `icp identity principal`. This is deliberately NOT the +//! management identity the canister lifecycle tools act as (the default +//! account at the server's own origin): a canister a deploy creates is +//! controlled by the principal here, and reaching it through the lifecycle +//! tools means adding their principal as a controller first. +//! +//! One deploy runs at a time on a server: two at once for the same project, +//! each with the mapping as it was before either, would create two sets of +//! canisters. The second call is refused, not queued, since its mapping would +//! be stale by the time it ran. And a deploy's working directory is kept +//! under the directory the embedder names until the outcome is assembled, +//! so a call cut off mid-deploy leaves the ids it created on disk. //! //! This crate is composed into the local server only. It depends on //! `icp-project` straight from the icp-cli repository, which crates.io does @@ -48,3 +59,632 @@ use rmcp::{ tool, tool_router, ErrorData as McpError, RoleServer, }; use serde::{Deserialize, Serialize}; + +mod calls; +mod deploy; +mod host; +mod identity; + +pub use deploy::{deploy_bundle, BundleSource, DeployOutcome, DeployRequest, RequestError, Status}; +use identity::RefreshingIdentity; + +/// The icp CLI's default funding for a canister it creates (`icp deploy +/// --cycles`): 2 trillion cycles. +pub const DEFAULT_CYCLES: u64 = 2_000_000_000_000; + +/// The skill that teaches an agent the deploy loop around this tool — bundle +/// the project, call `icp_deploy_bundle` with the environment's canister-id +/// mapping, write the returned mapping back — served as the MCP resource +/// `skill://deploy-with-mcp` by the server that mounts the tool, next to +/// core's bundled IC skills. Compiled in, like those: what a client reads is +/// what was reviewed at build time. +pub const DEPLOY_SKILL_NAME: &str = "deploy-with-mcp"; +/// Its title, for the resource listing. +pub const DEPLOY_SKILL_TITLE: &str = "Deploy with this MCP server"; +/// The document itself (frontmatter + body), in the registry's SKILL.md shape. +pub const DEPLOY_SKILL_MD: &str = include_str!("../static/skills/deploy-with-mcp.md"); + +/// The skill document behind `skill://`, when `path` names this crate's +/// skill — matched like core's bundled skills (trimmed, ASCII +/// case-insensitive), so the two lookups compose into one `skill://` space. +pub fn skill_document(path: &str) -> Option<&'static str> { + path.trim().eq_ignore_ascii_case(DEPLOY_SKILL_NAME).then_some(DEPLOY_SKILL_MD) +} + +/// Arguments of `icp_deploy_bundle`. +#[derive(Debug, Deserialize, schemars::JsonSchema)] +pub struct DeployBundleArgs { + /// Absolute path, on this machine, of the bundle archive — the `.tar.gz` + /// that `icp project bundle --output ` writes. Either this or + /// `bundle_base64`, not both. + #[serde(default)] + pub bundle_path: Option, + /// The bundle archive itself, base64-encoded. Either this or + /// `bundle_path`, not both. + #[serde(default)] + pub bundle_base64: Option, + /// The environment to deploy, by name, among those the bundle's manifest + /// declares — `ic` for a bundle built with the default settings. It must + /// be on the `ic` network: this tool deploys to the Internet Computer only. + pub environment: String, + /// Canister ids to start from: canister name → canister id, the contents of + /// the `.ids.json` file the icp CLI keeps (or the + /// `canister_ids` a previous call of this tool returned). A canister named + /// here is upgraded in place; one that is not is created. Omit it on the + /// first deploy of a project. + #[serde(default)] + pub canister_ids: Option>, + /// Install mode: "auto" (default — install on an empty canister, upgrade + /// otherwise), "install", "reinstall" (wipes the canister's state) or + /// "upgrade". + #[serde(default = "default_mode")] + pub mode: String, + /// Cycles to fund each canister this deploy creates, taken from the user's + /// cycles-ledger balance (see icp_cycles_balance). Default 2 trillion. + #[serde(default)] + pub cycles: Option, + /// Subnet to create canisters on. Default: picked by the network. + #[serde(default)] + pub subnet: Option, + /// Fail instead of creating any canister that does not exist yet. + #[serde(default)] + pub no_create: bool, + /// Skip the Candid interface compatibility check that an upgrade otherwise + /// runs, and fails on, when the new interface breaks the old one. + #[serde(default)] + pub skip_candid_check: bool, +} + +fn default_mode() -> String { + "auto".into() +} + +/// A canister created by the deploy. +#[derive(Debug, Serialize, schemars::JsonSchema)] +pub struct CreatedCanister { + pub name: String, + pub canister_id: String, +} + +/// Structured result of `icp_deploy_bundle`. +#[derive(Debug, Serialize, schemars::JsonSchema)] +pub struct DeployBundleOutput { + /// `deployed`, `failed` (see `error`; `created` and `canister_ids` hold + /// what landed before the failure), or `nothing_to_deploy` (the environment + /// lists no canisters). + pub status: String, + /// The environment deployed. + pub environment: String, + /// The network that environment targets. + pub network: String, + /// The canisters the deploy targeted, in manifest order. + pub canisters: Vec, + /// Canisters this deploy created, in creation order. + pub created: Vec, + /// The resulting mapping, canister name → canister id: the ids passed in + /// plus the ids created. Pass it back as `canister_ids` to upgrade these + /// canisters next time. + pub canister_ids: BTreeMap, + /// Why the deploy failed, when it did. + #[serde(skip_serializing_if = "Option::is_none")] + pub error: Option, + /// The deploy's phases and per-canister steps with their outcomes. + pub log: Vec, +} + +impl DeployBundleOutput { + fn from_outcome(outcome: &DeployOutcome) -> Self { + Self { + status: outcome.status.as_str().into(), + environment: outcome.environment.clone(), + network: outcome.network.clone(), + canisters: outcome.canisters.clone(), + created: outcome + .created + .iter() + .map(|(name, id)| CreatedCanister { name: name.clone(), canister_id: id.to_text() }) + .collect(), + canister_ids: outcome + .canister_ids + .iter() + .map(|(n, id)| (n.clone(), id.to_text())) + .collect(), + error: outcome.error.clone(), + log: outcome.log.clone(), + } + } + + /// The human-readable rendering: one summary line, the mapping, the log. + fn human(&self) -> String { + let mut text = match self.status.as_str() { + "deployed" => format!( + "Deployed environment '{}' to network '{}': {} canister(s), {} created.", + self.environment, + self.network, + self.canisters.len(), + self.created.len() + ), + "nothing_to_deploy" => format!( + "Environment '{}' declares no canisters, so there was nothing to deploy.", + self.environment + ), + _ => format!( + "Deploying environment '{}' to network '{}' FAILED: {}", + self.environment, + self.network, + self.error.as_deref().unwrap_or("unknown error") + ), + }; + if !self.canister_ids.is_empty() { + text.push_str("\n\nCanister ids (pass these back as `canister_ids` to upgrade):"); + for (name, id) in &self.canister_ids { + text.push_str(&format!("\n- {name}: {id}")); + } + } + if !self.log.is_empty() { + text.push_str("\n\nLog:"); + for line in &self.log { + text.push('\n'); + text.push_str(line); + } + } + text + } +} + +/// The longest `bundle_base64` that can decode to [`host::MAX_BUNDLE_BYTES`] +/// bytes: four characters per three bytes, the last group padded. +const MAX_BUNDLE_BASE64_CHARS: usize = host::MAX_BUNDLE_BYTES.div_ceil(3) * 4; + +/// Turn the tool's arguments into a request, rejecting what cannot be deployed +/// before anything is read or written. +pub fn request_from_args(args: DeployBundleArgs) -> Result { + let bundle = match (args.bundle_path, args.bundle_base64) { + (Some(_), Some(_)) => { + return Err("pass either bundle_path or bundle_base64, not both".into()) + } + (None, None) => return Err("pass the bundle as bundle_path or bundle_base64".into()), + (Some(path), None) => { + let path = Utf8PathBuf::from(path.trim()); + if path.as_str().is_empty() { + return Err("bundle_path is empty; pass the absolute path of the bundle".into()); + } + if !path.is_absolute() { + return Err(format!( + "bundle_path must be an absolute path on this machine, got '{path}'" + )); + } + BundleSource::Path(path) + } + (None, Some(b64)) => { + let b64 = b64.trim(); + // Bound the encoded text before allocating the decoded copy: + // base64 spends four characters per three bytes, so anything + // longer than this encodes more than the bundle limit. + if b64.len() > MAX_BUNDLE_BASE64_CHARS { + return Err(format!( + "bundle_base64 encodes more than the {} byte bundle limit", + host::MAX_BUNDLE_BYTES + )); + } + let bytes = base64::engine::general_purpose::STANDARD + .decode(b64) + .map_err(|e| format!("bundle_base64 is not valid base64: {e}"))?; + if bytes.len() > host::MAX_BUNDLE_BYTES { + return Err(format!( + "the bundle is {} bytes, above the {} byte limit", + bytes.len(), + host::MAX_BUNDLE_BYTES + )); + } + BundleSource::Bytes(bytes) + } + }; + let environment = args.environment.trim().to_string(); + if environment.is_empty() { + return Err("environment must name an environment of the bundle, e.g. \"ic\"".into()); + } + let mode = args.mode.trim().to_ascii_lowercase(); + if !matches!(mode.as_str(), "auto" | "install" | "reinstall" | "upgrade") { + return Err(format!( + "mode must be \"auto\", \"install\", \"reinstall\" or \"upgrade\", got \"{}\"", + args.mode + )); + } + let mut canister_ids = BTreeMap::new(); + for (name, id) in args.canister_ids.unwrap_or_default() { + let principal = Principal::from_text(id.trim()) + .map_err(|e| format!("canister_ids[\"{name}\"] is not a valid principal: {e}"))?; + canister_ids.insert(name, principal); + } + let subnet = match args.subnet { + Some(s) => Some( + Principal::from_text(s.trim()) + .map_err(|e| format!("subnet is not a principal: {e}"))?, + ), + None => None, + }; + Ok(DeployRequest { + bundle, + environment, + canister_ids, + mode, + cycles: u128::from(args.cycles.unwrap_or(DEFAULT_CYCLES)), + subnet, + no_create: args.no_create, + skip_candid_check: args.skip_candid_check, + workdir: None, + }) +} + +/// The deploy tool group, served alongside the core surface by the binary +/// that composes it: `icp_deploy_bundle`, and `icp_cycles_balance` — the +/// check a deploy that will create canisters starts with. The core surface +/// has a tool of that name too, in its protocol/meta half, which the local +/// server does not serve; this one is the same logic on the surface the +/// deploy loop runs on. Should the core half be served alongside one day, +/// drop this copy rather than list the name twice. +#[derive(Clone)] +pub struct IcDeployTools { + identities: Identities, + /// The authentication seam (see [`SessionResolver`]): asks the embedding + /// binary which already-validated session this call acts as. + session: SessionResolver, + /// Held for the length of a deploy; a second deploy finds it taken. + running: Arc>, + /// Where each deploy's working directory is made; see + /// [`DeployRequest::workdir`]. + deploys_dir: Option, +} + +/// A running deploy's claim on the server: dropped when the deploy returns. +pub type DeployClaim = tokio::sync::OwnedMutexGuard<()>; + +/// The derivation origin of the identity the deploy tools act as: the one the +/// icp CLI's `icp identity link web` (with no `--app`) signs in at. Internet +/// Identity derives one principal per anchor per origin, so a delegation at +/// this origin for the user's default account is the principal that link +/// gives the CLI. +pub const CLI_ORIGIN: &str = "https://cli.id.ai"; + +#[tool_router(vis = "pub")] +impl IcDeployTools { + pub fn new(identities: Identities, session: SessionResolver) -> Self { + Self { identities, session, running: Arc::default(), deploys_dir: None } + } + + /// Make each deploy's working directory under `dir` (made on first use) + /// rather than the system temporary directory, so one a cut-off deploy + /// leaves behind is somewhere the user is told to look. + pub fn deploys_in(mut self, dir: &std::path::Path) -> Result { + let dir = Utf8PathBuf::from_path_buf(dir.to_path_buf()) + .map_err(|p| format!("the deploy directory '{}' is not UTF-8", p.display()))?; + self.deploys_dir = Some(dir); + Ok(self) + } + + /// Claim the server for a deploy, or `None` while another holds it. + pub fn claim(&self) -> Option { + self.running.clone().try_lock_owned().ok() + } + + /// The session's identity at [`CLI_ORIGIN`]: its default account there, + /// derived on demand like any other per-app delegation. Good for the + /// next few minutes; a deploy uses [`RefreshingIdentity`] instead. + async fn cli_identity(&self, sid: &str) -> Result { + self.identities.delegated_identity(sid, CLI_ORIGIN, None).await + } + + #[tool( + description = "Deploy an icp project bundle (the .tar.gz that `icp project bundle` writes) to the Internet Computer as the user, running every phase of `icp deploy`: create the canisters the bundle names that do not exist yet (funded from the user's cycles balance), wire their ids into each other, apply settings, install or upgrade the code, and run the sync steps (a frontend's asset upload). Pass the bundle by absolute path or base64, the environment name it was built for (usually \"ic\"), and, to upgrade rather than create, the canister_ids a previous deploy returned. Returns the status, the resulting canister-id mapping, and a log of each phase; on failure the mapping still holds every canister that was created.", + annotations(title = "Deploy a project bundle", read_only_hint = false, destructive_hint = true, idempotent_hint = false, open_world_hint = true), + output_schema = schema_for_output::(), + )] + async fn icp_deploy_bundle( + &self, + Parameters(args): Parameters, + ctx: RequestContext, + ) -> Result { + // One deploy at a time, and the refusal reads nothing: it comes even + // before who is asking. + let Some(_claim) = self.claim() else { + return Ok(err("a deploy is already running on this server; wait for it to \ + finish, write the mapping it returns into the project, and \ + deploy again from that" + .into())); + }; + // Who is asking comes before what they sent: an inline bundle is + // decoded only for a session allowed to deploy. + let Some(sid) = (self.session)(&ctx) else { + return Ok(err("deploying a bundle needs an authenticated session".into())); + }; + if let Err(e) = self.identities.require_write(&sid).await { + return Ok(err(e)); + } + let mut request = match request_from_args(args) { + Ok(r) => r, + Err(e) => return Ok(err(e)), + }; + request.workdir = self.deploys_dir.clone(); + // A per-app delegation lives five minutes and a deploy can outlast + // it (an asset sync, several installs), so the identity the agent + // signs with is kept fresh for as long as the deploy runs. + let identity = + match RefreshingIdentity::start(self.identities.clone(), sid, CLI_ORIGIN).await { + Ok(id) => id, + Err(e) => return Ok(err(e)), + }; + match deploy_bundle(identity, request).await { + Err(RequestError(e)) => Ok(err(e)), + Ok(outcome) => { + let output = DeployBundleOutput::from_outcome(&outcome); + let text = output.human(); + Ok(match outcome.status { + Status::Failed => err_structured(text, &output), + Status::Deployed | Status::NothingToDeploy => ok_structured(text, &output), + }) + } + } + } + + #[tool( + description = "Return the cycles-ledger balance on the Internet Computer of the principal icp_deploy_bundle deploys as, and that principal: your default Internet Identity account at https://cli.id.ai, the one `icp identity link web` links an icp CLI identity to. When `icp identity principal` prints the same principal, the CLI and this tool share canisters and cycles; when it prints another (a local key, or a link to another account), they do not. Call it before a deploy that will create canisters, to check the balance covers them (2T cycles each by default), and to get the principal to add as a controller of a canister created elsewhere.", + annotations(title = "Check your cycles balance", read_only_hint = true, destructive_hint = false, open_world_hint = true), + output_schema = schema_for_output::(), + )] + async fn icp_cycles_balance( + &self, + Parameters(_args): Parameters, + ctx: RequestContext, + ) -> Result { + let Some(sid) = (self.session)(&ctx) else { + return Ok(err("checking your cycles balance needs an authenticated session".into())); + }; + let identity = match self.cli_identity(&sid).await { + Ok(id) => id, + Err(e) => return Ok(err(e)), + }; + let principal = match identity.sender() { + Ok(p) => p, + Err(e) => return Ok(err(format!("could not derive your principal: {e}"))), + }; + // The balance a deploy draws on is the one on the Internet Computer, + // whatever endpoint the rest of the server is pointed at. + let agent = match deploy::mainnet_agent(Arc::new(identity)) { + Ok(agent) => agent, + Err(e) => return Ok(err(e)), + }; + match cycles_balance_of(&agent, principal).await { + Ok(balance) => Ok(ok_structured(balance.human(), &balance)), + Err(e) => Ok(err(e)), + } + } +} + +/// `icp_cycles_balance` takes no arguments. +#[derive(Debug, Default, Deserialize, schemars::JsonSchema)] +pub struct NoArgs {} + +/// Same contract as the core surface's result builder: the human-readable +/// text plus `structuredContent` conforming to the declared `outputSchema`. +fn ok_structured(text: String, value: &T) -> CallToolResult { + let mut result = CallToolResult::success(vec![ContentBlock::text(text)]); + result.structured_content = structured(value); + result +} + +/// An error result that still carries the structured output: a failed deploy +/// has created canisters the caller must learn about, and `isError` alone +/// would hide them from a client that reads structured content. +fn err_structured(text: String, value: &T) -> CallToolResult { + let mut result = CallToolResult::error(vec![ContentBlock::text(text)]); + result.structured_content = structured(value); + result +} + +fn structured(value: &T) -> Option { + match serde_json::to_value(value) { + Ok(v @ serde_json::Value::Object(_)) => Some(v), + _ => None, + } +} + +fn err(text: String) -> CallToolResult { + CallToolResult::error(vec![ContentBlock::text(text)]) +} + +/// Same panic-at-router-construction posture as the core surface: a +/// non-object output schema is a programming error. +fn schema_for_output() -> Arc { + rmcp::handler::server::tool::schema_for_output::().unwrap_or_else(|e| { + panic!("output schema for `{}` must be object-rooted: {e}", std::any::type_name::()) + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn args(json: serde_json::Value) -> DeployBundleArgs { + serde_json::from_value(json).expect("valid args") + } + + // The skill is in the registry's SKILL.md shape (frontmatter naming it as + // the URI does, a description for the agent's trigger decision), teaches + // the three steps around the tool by name, and resolves under the same + // lookup rules as core's bundled skills. + #[test] + fn the_deploy_skill_is_a_well_formed_skill_document() { + let md = DEPLOY_SKILL_MD; + assert!(md.starts_with("---\n"), "frontmatter first"); + let (front, body) = md[4..].split_once("\n---\n").expect("closed frontmatter"); + assert!(front.lines().any(|l| l == format!("name: {DEPLOY_SKILL_NAME}")), "{front}"); + assert!(front.lines().any(|l| l.starts_with("description: ")), "{front}"); + assert!(front.contains(&format!("title: {DEPLOY_SKILL_TITLE}")), "{front}"); + for needle in [ + "icp project bundle", + "icp_deploy_bundle", + "canister_ids", + ".icp/data/mappings/.ids.json", + "bundle_path", + "bundle_base64", + "authenticate", + "icp_cycles_balance", + ] { + assert!(body.contains(needle), "the skill must mention `{needle}`"); + } + // The steps are named in the order an agent performs them. + let step = |n: &str| body.find(n).unwrap_or_else(|| panic!("missing heading {n}")); + assert!( + step("## Step 1: Bundle") < step("## Step 2: Request") + && step("## Step 2: Request") < step("## Step 3: Update"), + "steps out of order" + ); + + assert_eq!(skill_document("deploy-with-mcp"), Some(md)); + assert_eq!(skill_document(" Deploy-With-MCP "), Some(md)); + assert_eq!(skill_document("deploy-with-mcp/references/x.md"), None); + assert_eq!(skill_document("icp-cli"), None); + } + + // The group carries the deploy tool, with honest annotations (it creates + // canisters and can wipe state) and an object-rooted outputSchema like + // every tool on the core surface, and the read-only balance check beside + // it. + #[test] + fn the_router_carries_the_deploy_tool_and_the_balance_check() { + let tools = IcDeployTools::tool_router().list_all(); + assert_eq!(tools.len(), 2); + let balance = tools.iter().find(|t| &*t.name == "icp_cycles_balance").expect("balance"); + let ann = balance.annotations.clone().expect("annotations"); + assert_eq!(ann.read_only_hint, Some(true)); + let output = + serde_json::to_value(balance.output_schema.as_ref().expect("outputSchema")).unwrap(); + assert!(output["properties"]["balance"].is_object(), "{output}"); + assert!(output["properties"]["principal"].is_object(), "{output}"); + + let tool = tools.iter().find(|t| &*t.name == "icp_deploy_bundle").expect("deploy"); + let ann = tool.annotations.clone().expect("annotations"); + assert_eq!(ann.read_only_hint, Some(false)); + assert_eq!(ann.destructive_hint, Some(true)); + let schema = serde_json::to_value(&tool.input_schema).unwrap(); + for field in ["bundle_path", "bundle_base64", "environment", "canister_ids", "mode"] { + assert!(schema["properties"][field].is_object(), "missing {field}: {schema}"); + } + assert_eq!(schema["required"], serde_json::json!(["environment"])); + let output = + serde_json::to_value(tool.output_schema.as_ref().expect("outputSchema")).unwrap(); + assert_eq!(output["type"], "object"); + for field in ["status", "canister_ids", "created", "log"] { + assert!(output["properties"][field].is_object(), "missing {field}: {output}"); + } + } + + #[test] + fn exactly_one_bundle_source_is_accepted() { + let both = args(serde_json::json!({ + "bundle_path": "/tmp/b.tar.gz", "bundle_base64": "AAAA", "environment": "ic" + })); + assert!(request_from_args(both).unwrap_err().contains("not both")); + let neither = args(serde_json::json!({ "environment": "ic" })); + assert!(request_from_args(neither).unwrap_err().contains("bundle_path or bundle_base64")); + let bad = args(serde_json::json!({ "bundle_base64": "***", "environment": "ic" })); + assert!(request_from_args(bad).unwrap_err().contains("base64")); + } + + // Four base64 characters past the bound is refused on length alone, before + // any decoding: the bundle limit is measured on the text, not after a copy + // of it has been allocated. + #[test] + fn an_oversized_inline_bundle_is_refused_before_decoding() { + let text = "A".repeat(MAX_BUNDLE_BASE64_CHARS + 4); + let big = args(serde_json::json!({ "bundle_base64": text, "environment": "ic" })); + let e = request_from_args(big).unwrap_err(); + assert!(e.contains("encodes more than"), "{e}"); + } + + #[test] + fn the_request_takes_the_defaults_and_parses_the_ids() { + let request = request_from_args(args(serde_json::json!({ + "bundle_path": "/tmp/b.tar.gz", + "environment": " ic ", + "canister_ids": { "backend": "aaaaa-aa" }, + "mode": "Upgrade" + }))) + .unwrap(); + assert_eq!(request.environment, "ic"); + assert_eq!(request.mode, "upgrade"); + assert_eq!(request.cycles, u128::from(DEFAULT_CYCLES)); + assert_eq!(request.canister_ids["backend"], Principal::management_canister()); + assert!(request.subnet.is_none()); + assert!(!request.no_create && !request.skip_candid_check); + assert!(matches!(request.bundle, BundleSource::Path(ref p) if p == "/tmp/b.tar.gz")); + } + + // The path is what the tool reads, so it is held to what the skill + // teaches before any I/O: trimmed, present, absolute. + #[test] + fn a_bundle_path_is_trimmed_and_must_be_absolute() { + let request = request_from_args(args( + serde_json::json!({ "bundle_path": " /b.tar.gz ", "environment": "ic" }), + )) + .unwrap(); + assert!(matches!(request.bundle, BundleSource::Path(ref p) if p == "/b.tar.gz")); + for (path, needle) in [("relative/b.tar.gz", "absolute"), (" ", "empty")] { + let e = request_from_args(args( + serde_json::json!({ "bundle_path": path, "environment": "ic" }), + )) + .unwrap_err(); + assert!(e.contains(needle), "{path:?}: {e}"); + } + } + + #[test] + fn bad_modes_ids_and_subnets_are_named_in_the_rejection() { + let e = request_from_args(args(serde_json::json!({ + "bundle_path": "/b", "environment": "ic", "mode": "yolo" + }))) + .unwrap_err(); + assert!(e.contains("mode") && e.contains("yolo"), "{e}"); + let e = request_from_args(args(serde_json::json!({ + "bundle_path": "/b", "environment": "ic", "canister_ids": { "backend": "nope" } + }))) + .unwrap_err(); + assert!(e.contains("canister_ids[\"backend\"] is not a valid principal"), "{e}"); + let e = request_from_args(args(serde_json::json!({ + "bundle_path": "/b", "environment": "ic", "subnet": "nope" + }))) + .unwrap_err(); + assert!(e.contains("subnet"), "{e}"); + let e = + request_from_args(args(serde_json::json!({ "bundle_path": "/b", "environment": " " }))) + .unwrap_err(); + assert!(e.contains("environment"), "{e}"); + } + + // The text a client without structured-output support reads carries the + // same facts: the status, the mapping to pass back, and the log. + #[test] + fn the_human_text_carries_the_mapping_and_the_log() { + let outcome = DeployOutcome { + status: Status::Failed, + environment: "ic".into(), + network: "ic".into(), + canisters: vec!["backend".into(), "frontend".into()], + created: vec![("backend".into(), Principal::management_canister())], + canister_ids: [("backend".to_string(), Principal::management_canister())].into(), + error: Some("install failed".into()), + log: vec!["Creating canisters: ok".into()], + }; + let output = DeployBundleOutput::from_outcome(&outcome); + assert_eq!(output.status, "failed"); + assert_eq!(output.created[0].canister_id, "aaaaa-aa"); + let text = output.human(); + assert!(text.contains("FAILED: install failed"), "{text}"); + assert!(text.contains("- backend: aaaaa-aa"), "{text}"); + assert!(text.contains("Creating canisters: ok"), "{text}"); + let result = err_structured(text, &output); + assert_eq!(result.is_error, Some(true)); + assert_eq!(result.structured_content.unwrap()["canister_ids"]["backend"], "aaaaa-aa"); + } +} diff --git a/crates/imcp2-deploy/static/skills/deploy-with-mcp.md b/crates/imcp2-deploy/static/skills/deploy-with-mcp.md index 9467169..2e8b33c 100644 --- a/crates/imcp2-deploy/static/skills/deploy-with-mcp.md +++ b/crates/imcp2-deploy/static/skills/deploy-with-mcp.md @@ -39,10 +39,14 @@ not know the canisters already exist and would create a second set. - Cycles. Every canister the bundle names that does not exist yet is created from the user's cycles balance, 2T cycles each unless `cycles` says otherwise. `icp_cycles_balance` reports the balance and the principal - it belongs to: the user's CLI identity, the same principal `icp identity - link web` signs in as, so a canister deployed here is one `icp` controls - too. Before a deploy that will create canisters, tell the user how - many and confirm; creation is not undone by a later failure. + it belongs to: the user's default Internet Identity account at + `https://cli.id.ai`, the one `icp identity link web` links an icp CLI + identity to. If `icp identity principal` prints the same principal, the + CLI and this tool share canisters and cycles; if it prints another (a + local key, or a link to another account), they do not — say so before + the user switches between the two. Before a deploy that will create + canisters, tell the user how many and confirm; creation is not undone by + a later failure. - An environment on the `ic` network (mainnet), which every manifest has. The tool deploys to the Internet Computer only: an environment on any other network — the default `local`, a testnet — is refused. Deploy those with @@ -178,4 +182,18 @@ interchangeably for that environment. environment is rejected before anything runs. Remove canisters from the file when they are removed from the project. 6. **Expecting the server to keep state.** It keeps nothing between calls: - no bundle, no mapping. The project's mapping file is the record. + no bundle, no mapping. The project's mapping file is the record. The one + exception is the working directory of a call that never returned, see 8. +7. **Deploying twice at once.** One deploy runs at a time on this server; a + second call while one runs is refused, not queued. Wait for the answer, + do step 3, then deploy again from the updated mapping. +8. **A call that never returned.** If the server stopped mid-deploy or the + call was cancelled, the canisters it created are not lost: the deploy's + working directory is kept under the user's local data directory + (`~/.local/share/imcp2-local/deploys/` on Linux, + `~/Library/Application Support/imcp2-local/deploys/` on macOS, + `%LOCALAPPDATA%\imcp2-local\deploys\` on Windows), and its + `.icp/data/mappings/.ids.json` records every canister the + deploy had created. Do step 3 with that file's contents, delete the + directory, and only then deploy again; otherwise the retry creates a + second set. diff --git a/crates/imcp2-local/README.md b/crates/imcp2-local/README.md index 909d7cf..10f08ab 100644 --- a/crates/imcp2-local/README.md +++ b/crates/imcp2-local/README.md @@ -169,17 +169,33 @@ canisters created, and a log of each phase. A failed deploy still reports every canister it created before failing, so the run can be picked up rather than repeated. -Calls are signed as your **CLI identity**: your default II account at -`https://cli.id.ai`, the origin `icp identity link web` signs in at. That is -the principal the icp CLI deploys as on your machine, so canisters deployed -here are the ones `icp` lists and controls, and they are funded from the same -cycles balance. `icp_cycles_balance` reports that balance and principal. It is +Calls are signed as your **default Internet Identity account at +`https://cli.id.ai`**, the origin `icp identity link web` links an icp CLI +identity to. When the CLI's selected identity is such a link to the same +account, `icp` and this tool deploy as the same principal: the canisters are +the ones `icp` lists and controls, and they are funded from the same cycles +balance. An `icp` identity that is a local key (`icp identity new`) or a link +to another account is a different principal, which shares neither. +`icp_cycles_balance` reports the balance and the principal used here, read +from the Internet Computer whatever `IMCP2_IC_URL` says; compare it with +`icp identity principal` before treating the two as interchangeable. It is not the management identity the canister lifecycle tools act as (see `IMCP2_MANAGEMENT_ORIGIN`); to reach a deployed canister through those tools, add their principal as a controller. The session must have been granted -"Actions & questions" at sign-in. The bundle is extracted into a -scratch directory for the duration of the deploy and removed afterwards; -nothing is written to the project it came from. +"Actions & questions" at sign-in. + +One deploy runs at a time: a second call while one is running is refused +rather than queued, since the mapping it carries would be stale by the time +it ran. The bundle is extracted into a working directory under your local +data directory — `~/.local/share/imcp2-local/deploys/` on Linux, +`~/Library/Application Support/imcp2-local/deploys/` on macOS, +`%LOCALAPPDATA%\imcp2-local\deploys\` on Windows — removed once the deploy +has returned its answer. A deploy that never returns (the server stopped +mid-way, the call cancelled) leaves its directory there, and inside it +`.icp/data/mappings/.ids.json` records every canister the +deploy had created: merge it into the project's mapping before deploying +again, then delete the directory. Nothing is written to the project the +bundle came from. The server also serves a skill for the agent, `skill://deploy-with-mcp`, next to the IC skills the core surface bundles. It walks through the loop diff --git a/crates/imcp2-local/src/main.rs b/crates/imcp2-local/src/main.rs index 4accf23..afce351 100644 --- a/crates/imcp2-local/src/main.rs +++ b/crates/imcp2-local/src/main.rs @@ -8,10 +8,14 @@ //! //! On top of the core surface it serves `icp_deploy_bundle` (from //! `imcp2-deploy`): deploying an icp project bundle through the icp CLI's own -//! project library, as the user's CLI identity (their default II account at -//! `https://cli.id.ai`, the principal `icp identity link web` signs in as, so -//! `icp` and this tool deploy as the same controller). Local-only because -//! that library is a git dependency the published crates cannot carry. +//! project library, as the user's default II account at `https://cli.id.ai` +//! — the principal `icp identity link web` links the CLI to, so `icp` and +//! this tool deploy as the same controller when the CLI's selected identity +//! is that link (a local key or another account is another principal). +//! Local-only because that library is a git dependency the published crates +//! cannot carry. A deploy's working directory lives under the OS's local +//! data directory (`imcp2-local/deploys/`) until the deploy returns, so one +//! cut off mid-way leaves the ids it created where the README says to look. //! //! Signing in is a built-in browser handshake (see `login`): the //! `authenticate` tool returns an id.ai link, a transient loopback listener @@ -177,6 +181,12 @@ async fn serve() -> anyhow::Result<()> { let slot = SessionSlot::new(); let tools = IcTools::new(agent, identities.clone(), slot.resolver()); let deploy = IcDeployTools::new(identities.clone(), slot.resolver()); + let deploy = match dirs::data_local_dir() { + Some(data) => deploy + .deploys_in(&data.join("imcp2-local").join("deploys")) + .map_err(anyhow::Error::msg)?, + None => deploy, + }; let auto_open = !truthy("IMCP2_NO_OPEN"); let login = login::LoginDriver::new(identities, slot, auto_open); diff --git a/crates/imcp2-local/src/server.rs b/crates/imcp2-local/src/server.rs index 1cfe69b..c1ffffe 100644 --- a/crates/imcp2-local/src/server.rs +++ b/crates/imcp2-local/src/server.rs @@ -279,9 +279,12 @@ impl ServerHandler for LocalServer { bundle --output `, built in the user's environment) to the environment it \ names, creating the canisters that do not exist yet from the user's cycles \ balance (`icp_cycles_balance` reports it) and upgrading those passed in \ - `canister_ids`. It deploys as the user's CLI identity (the principal `icp \ - identity link web` signs in as), not the management identity the canister \ - tools act as. It needs an \"Actions & questions\" session, and it returns the \ + `canister_ids`. It deploys as the user's default Internet Identity account at \ + https://cli.id.ai — the principal `icp identity link web` links the CLI to, and \ + so the CLI's own only when the CLI's selected identity is that link; a local key \ + or another account is a different principal, which `icp identity principal` \ + shows — not as the management identity the canister tools act as. It needs an \ + \"Actions & questions\" session, runs one deploy at a time, and returns the \ canister-id mapping to pass back next time. \ The `skill://deploy-with-mcp` resource walks through the loop: bundle, deploy, \ write the mapping back into the project." @@ -329,13 +332,18 @@ impl ServerHandler for LocalServer { request: Option, context: RequestContext, ) -> Result { + let first_page = request.as_ref().and_then(|r| r.cursor.as_ref()).is_none(); let mut result = self.tools.list_resources(request, context).await?; // The one skill this server adds, next to core's bundled IC skills: the - // deploy loop around `icp_deploy_bundle`, which only exists here. - result.resources.push(Resource::new( - format!("skill://{}", imcp2_deploy::DEPLOY_SKILL_NAME), - format!("IC skill: {}", imcp2_deploy::DEPLOY_SKILL_TITLE), - )); + // deploy loop around `icp_deploy_bundle`, which only exists here. Core + // lists everything on one page today; should it ever page, the skill + // belongs on the first page alone, not on every one. + if first_page { + result.resources.push(Resource::new( + format!("skill://{}", imcp2_deploy::DEPLOY_SKILL_NAME), + format!("IC skill: {}", imcp2_deploy::DEPLOY_SKILL_TITLE), + )); + } Ok(result) } @@ -436,7 +444,9 @@ mod tests { #[tokio::test] async fn a_real_mcp_client_sees_the_merged_surface_and_the_login_flow() { let (client_io, server_io) = tokio::io::duplex(64 * 1024); - let server = test_server().serve(server_io); + let local = test_server(); + let deploy_tools = local.deploy.clone(); + let server = local.serve(server_io); let client = ().serve(client_io); let (server, client) = tokio::join!(server, client); let server = server.expect("server up"); @@ -517,9 +527,10 @@ mod tests { assert!(is_error, "an invalid canister id is a tool error"); assert!(text.contains("invalid canister id"), "{text}"); - // The deploy tool dispatches to its own router through the wrapper: - // signed out, it refuses before reading the bundle (the path named here - // does not exist, and must not be reached for) or touching the network. + // The deploy tools dispatch to their own router through the wrapper: + // signed out, `icp_deploy_bundle` refuses before reading the bundle + // (the path named here does not exist, and must not be reached for) + // or touching the network. let (is_error, text, _) = call( "icp_deploy_bundle", Some(serde_json::json!({ @@ -531,6 +542,31 @@ mod tests { assert!(is_error, "a deploy without a session is a tool error"); assert!(text.contains("authenticated session"), "{text}"); + // While a deploy runs, a second is refused before anything else — + // before even the session check — so two cannot create two sets of + // canisters from one stale mapping. + let busy = deploy_tools.claim().expect("no deploy is running"); + let (is_error, text, _) = call( + "icp_deploy_bundle", + Some(serde_json::json!({ + "bundle_path": "/nonexistent/bundle.tar.gz", + "environment": "ic" + })), + ) + .await; + assert!(is_error, "a second deploy is a tool error"); + assert!(text.contains("already running"), "{text}"); + drop(busy); + let (_, text, _) = call( + "icp_deploy_bundle", + Some(serde_json::json!({ + "bundle_path": "/nonexistent/bundle.tar.gz", + "environment": "ic" + })), + ) + .await; + assert!(text.contains("authenticated session"), "released: {text}"); + // Deferral: a protocol/meta tool is not just unlisted — calling it // fails with the router's standard "tool not found" error (the // invalid-params shape any unknown tool name gets), rather than @@ -568,6 +604,16 @@ mod tests { let (server, client) = tokio::join!(server, client); let (server, client) = (server.expect("server up"), client.expect("client up")); + // The deploy skill joins the first page only: a client that follows a + // cursor must not meet it again. + let mut page = rmcp::model::PaginatedRequestParams::default(); + page.cursor = Some("next".into()); + let later = client.list_resources(Some(page)).await.expect("resources/list with a cursor"); + assert!( + !later.resources.iter().any(|r| r.uri == "skill://deploy-with-mcp"), + "the deploy skill must not repeat on a later page" + ); + let resources = client.list_all_resources().await.expect("resources/list"); let skills: Vec = resources.iter().map(|r| r.uri.clone()).filter(|u| u.starts_with("skill://")).collect(); From 5c60be8046ae78c86dacde34bcbcc036c1c6084e Mon Sep 17 00:00:00 2001 From: Raymond Khalife Date: Mon, 9 Nov 2026 15:55:36 +0000 Subject: [PATCH 3/5] Refuse to start without a local data directory for deploy records A deploy cut off mid-run leaves the ids it created in its working directory, under the local data directory the README points at. With none reported by the OS the server fell back to the system temporary directory in silence, where a cleanup may remove the record before the user looks; it now fails at startup, naming what to set. Co-Authored-By: Claude Fable 5.1 --- crates/imcp2-local/src/main.rs | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/crates/imcp2-local/src/main.rs b/crates/imcp2-local/src/main.rs index afce351..7fc666c 100644 --- a/crates/imcp2-local/src/main.rs +++ b/crates/imcp2-local/src/main.rs @@ -180,13 +180,20 @@ async fn serve() -> anyhow::Result<()> { // hands core answers every tool call's "which session?" from it. let slot = SessionSlot::new(); let tools = IcTools::new(agent, identities.clone(), slot.resolver()); - let deploy = IcDeployTools::new(identities.clone(), slot.resolver()); - let deploy = match dirs::data_local_dir() { - Some(data) => deploy - .deploys_in(&data.join("imcp2-local").join("deploys")) - .map_err(anyhow::Error::msg)?, - None => deploy, + // A deploy cut off mid-run leaves the ids it created in its working + // directory, where the README says to look; with no local data directory + // to keep it under there is nowhere to say, so the server does not start + // rather than keeping records somewhere the user is not told of. + let Some(data) = dirs::data_local_dir() else { + anyhow::bail!( + "no local data directory for this user, so a deploy cut off mid-run would have \ + nowhere to leave the canister ids it created: set HOME (on Linux, or \ + XDG_DATA_HOME) and start again" + ); }; + let deploy = IcDeployTools::new(identities.clone(), slot.resolver()) + .deploys_in(&data.join("imcp2-local").join("deploys")) + .map_err(anyhow::Error::msg)?; let auto_open = !truthy("IMCP2_NO_OPEN"); let login = login::LoginDriver::new(identities, slot, auto_open); From 09ed5080c80b876eabbb67c4bee2a2a22108d478 Mon Sep 17 00:00:00 2001 From: Raymond Khalife Date: Wed, 11 Nov 2026 22:14:31 +0000 Subject: [PATCH 4/5] Return the deploy skill from a tool too, for a client that reads no resources The skill around `icp_deploy_bundle` was served as the MCP resource `skill://deploy-with-mcp` alone, and not every client reads resources (review on #232). `icp_deploy_guide` returns the same document as a tool result, text and structured content, as core serves its Candid guide both ways. The server's instructions and README say so, and the round-trip test calls it. Co-Authored-By: Claude Fable 5.1 --- crates/imcp2-deploy/src/lib.rs | 54 ++++++++++++++++++++++++++------ crates/imcp2-local/README.md | 3 +- crates/imcp2-local/src/server.rs | 25 +++++++++++---- 3 files changed, 66 insertions(+), 16 deletions(-) diff --git a/crates/imcp2-deploy/src/lib.rs b/crates/imcp2-deploy/src/lib.rs index 018ed43..c01c186 100644 --- a/crates/imcp2-deploy/src/lib.rs +++ b/crates/imcp2-deploy/src/lib.rs @@ -76,8 +76,10 @@ pub const DEFAULT_CYCLES: u64 = 2_000_000_000_000; /// the project, call `icp_deploy_bundle` with the environment's canister-id /// mapping, write the returned mapping back — served as the MCP resource /// `skill://deploy-with-mcp` by the server that mounts the tool, next to -/// core's bundled IC skills. Compiled in, like those: what a client reads is -/// what was reviewed at build time. +/// core's bundled IC skills, and returned by `icp_deploy_guide` for a client +/// that does not read resources (as core serves its Candid guide both ways). +/// Compiled in, like those: what a client reads is what was reviewed at +/// build time. pub const DEPLOY_SKILL_NAME: &str = "deploy-with-mcp"; /// Its title, for the resource listing. pub const DEPLOY_SKILL_TITLE: &str = "Deploy with this MCP server"; @@ -318,8 +320,9 @@ pub fn request_from_args(args: DeployBundleArgs) -> Result Ok(err(e)), } } + + #[tool( + description = "Return the guide to deploying through this server: the loop around icp_deploy_bundle — bundle the project with `icp project bundle`, call the tool with the bundle and the environment's canister-id mapping, write the mapping it returns back into the project so the next deploy upgrades rather than creates — with what the tool needs and what it returns. The same text is served as the MCP resource `skill://deploy-with-mcp`; call this when the user asks you to deploy an icp project and you have not read that resource.", + annotations(title = "Get the deploy guide", read_only_hint = true, destructive_hint = false, open_world_hint = false), + output_schema = schema_for_output::(), + )] + async fn icp_deploy_guide( + &self, + Parameters(_args): Parameters, + ) -> Result { + let output = DeployGuideOutput { content: DEPLOY_SKILL_MD.to_string() }; + Ok(ok_structured(output.content.clone(), &output)) + } } -/// `icp_cycles_balance` takes no arguments. +/// `icp_cycles_balance` and `icp_deploy_guide` take no arguments. #[derive(Debug, Default, Deserialize, schemars::JsonSchema)] pub struct NoArgs {} +/// What `icp_deploy_guide` returns: the skill document, as the resource +/// serves it. +#[derive(Debug, Serialize, schemars::JsonSchema)] +pub struct DeployGuideOutput { + /// The guide, Markdown with its frontmatter. + pub content: String, +} + /// Same contract as the core surface's result builder: the human-readable /// text plus `structuredContent` conforming to the declared `outputSchema`. fn ok_structured(text: String, value: &T) -> CallToolResult { @@ -549,12 +573,24 @@ mod tests { // The group carries the deploy tool, with honest annotations (it creates // canisters and can wipe state) and an object-rooted outputSchema like - // every tool on the core surface, and the read-only balance check beside - // it. + // every tool on the core surface, the read-only balance check beside it, + // and the guide: the skill as a tool result, for a client that reads no + // resources. #[test] - fn the_router_carries_the_deploy_tool_and_the_balance_check() { + fn the_router_carries_the_deploy_tool_the_balance_check_and_the_guide() { let tools = IcDeployTools::tool_router().list_all(); - assert_eq!(tools.len(), 2); + assert_eq!(tools.len(), 3); + let guide = tools.iter().find(|t| &*t.name == "icp_deploy_guide").expect("guide"); + let ann = guide.annotations.clone().expect("annotations"); + assert_eq!(ann.read_only_hint, Some(true)); + assert!(guide + .description + .as_deref() + .unwrap_or_default() + .contains("skill://deploy-with-mcp")); + let output = + serde_json::to_value(guide.output_schema.as_ref().expect("outputSchema")).unwrap(); + assert!(output["properties"]["content"].is_object(), "{output}"); let balance = tools.iter().find(|t| &*t.name == "icp_cycles_balance").expect("balance"); let ann = balance.annotations.clone().expect("annotations"); assert_eq!(ann.read_only_hint, Some(true)); diff --git a/crates/imcp2-local/README.md b/crates/imcp2-local/README.md index 10f08ab..4c7e243 100644 --- a/crates/imcp2-local/README.md +++ b/crates/imcp2-local/README.md @@ -203,7 +203,8 @@ above from the agent's side: bundle the project with `icp project bundle`, call `icp_deploy_bundle` with the environment's `.icp/data/mappings/.ids.json` as `canister_ids`, and write the mapping the tool returns back into that file so the next deploy upgrades -rather than creates. The document lives at +rather than creates. Not every client reads resources, so `icp_deploy_guide` +returns the same document as a tool result. It lives at `crates/imcp2-deploy/static/skills/deploy-with-mcp.md`. Why only here: the tool is built on `icp-project`, a crate the icp CLI does not diff --git a/crates/imcp2-local/src/server.rs b/crates/imcp2-local/src/server.rs index c1ffffe..6c53ad9 100644 --- a/crates/imcp2-local/src/server.rs +++ b/crates/imcp2-local/src/server.rs @@ -2,9 +2,9 @@ //! two **local-only login tools** and the **deploy tool**. The login tools //! cannot live in `imcp2-core` — they drive this binary's loopback listener //! and browser opener, which core cannot call back into — so this wrapper -//! carries them in its own `#[tool_router]`. The deploy tool -//! (`icp_deploy_bundle` and `icp_cycles_balance`, [`IcDeployTools`]) cannot either, for a different -//! reason: it runs on the icp CLI's project library, a git dependency the +//! carries them in its own `#[tool_router]`. The deploy tools +//! (`icp_deploy_bundle`, `icp_cycles_balance` and `icp_deploy_guide`, +//! [`IcDeployTools`]) cannot either, for a different reason: it runs on the icp CLI's project library, a git dependency the //! published crates cannot carry. The wrapper answers `tools/list` with the //! merged list, dispatches `tools/call` to its own router, then the deploy //! router, and forwards everything else to the inner [`IcTools`] handler @@ -287,7 +287,8 @@ impl ServerHandler for LocalServer { \"Actions & questions\" session, runs one deploy at a time, and returns the \ canister-id mapping to pass back next time. \ The `skill://deploy-with-mcp` resource walks through the loop: bundle, deploy, \ - write the mapping back into the project." + write the mapping back into the project; `icp_deploy_guide` returns the same \ + text." )); info } @@ -463,9 +464,9 @@ mod tests { let tools = client.list_all_tools().await.expect("tools/list"); assert_eq!( tools.len(), - 14, + 15, "10 served core tools + authenticate + auth_status + icp_deploy_bundle + \ - icp_cycles_balance" + icp_cycles_balance + icp_deploy_guide" ); for expected in [ "get_canister_candid", @@ -474,6 +475,7 @@ mod tests { "auth_status", "icp_deploy_bundle", "icp_cycles_balance", + "icp_deploy_guide", ] { assert!(tools.iter().any(|t| &*t.name == expected), "missing {expected}"); } @@ -542,6 +544,17 @@ mod tests { assert!(is_error, "a deploy without a session is a tool error"); assert!(text.contains("authenticated session"), "{text}"); + // The guide needs no session and is the skill document itself, as + // text and as structured content, for a client that reads no + // resources. + let (is_error, text, structured) = call("icp_deploy_guide", None).await; + assert!(!is_error, "{text}"); + assert_eq!(text, imcp2_deploy::DEPLOY_SKILL_MD); + assert_eq!( + structured.expect("structuredContent")["content"], + imcp2_deploy::DEPLOY_SKILL_MD + ); + // While a deploy runs, a second is refused before anything else — // before even the session check — so two cannot create two sets of // canisters from one stale mapping. From 08516e7bc1964087b84f5108ed76a2bc122f3e76 Mon Sep 17 00:00:00 2001 From: Raymond Khalife Date: Wed, 11 Nov 2026 23:48:39 +0000 Subject: [PATCH 5/5] Make each deploy's directory its owner's alone Every deploy runs in a fresh, randomly named directory under the user's local data directory (or the system temporary directory), and nothing reads it but that run. Its mode followed the umask, though, so another user of the machine could read what the bundle holds and the ids the deploy records wherever the parent allowed it. On Unix the directory is now made with mode 0700. A test creates two and checks that they differ, sit under the parent, and are the owner's alone (review on #232). Co-Authored-By: Claude Fable 5.1 --- crates/imcp2-deploy/src/deploy.rs | 37 ++++++++++++++++++++++++++++--- 1 file changed, 34 insertions(+), 3 deletions(-) diff --git a/crates/imcp2-deploy/src/deploy.rs b/crates/imcp2-deploy/src/deploy.rs index 3807dfe..564ee55 100644 --- a/crates/imcp2-deploy/src/deploy.rs +++ b/crates/imcp2-deploy/src/deploy.rs @@ -193,9 +193,7 @@ pub async fn deploy_bundle( } None => std::env::temp_dir(), }; - let scratch = tempfile::Builder::new() - .prefix("imcp2-deploy-") - .tempdir_in(parent) + let scratch = deploy_directory(&parent) .map_err(|e| RequestError(format!("cannot create a scratch directory: {e}")))?; let dir = Utf8PathBuf::from_path_buf(scratch.path().to_path_buf()).map_err(|p| { RequestError(format!("scratch directory path is not UTF-8: {}", p.display())) @@ -472,6 +470,21 @@ fn describe(task: &Task) -> String { } } +/// A fresh directory for one deploy under `parent`, randomly named, and on +/// Unix readable by its owner alone: the bundle's files and the ids the +/// deploy records are the user's business, and the parent (the user's local +/// data directory, or the system temporary directory) may be more open. +fn deploy_directory(parent: &std::path::Path) -> std::io::Result { + let mut builder = tempfile::Builder::new(); + builder.prefix("imcp2-deploy-"); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + builder.permissions(std::fs::Permissions::from_mode(0o700)); + } + builder.tempdir_in(parent) +} + #[cfg(test)] mod tests { use super::*; @@ -537,6 +550,24 @@ mod tests { // phase of a bundle), then stop where `--no-create` stops `icp deploy`. // The outcome reports the failure, the targets, and the log of the phase // that succeeded before it. + // Each deploy gets its own fresh directory, and on Unix one no other + // user of the machine can read: the parent may be world-readable. + #[test] + fn a_deploy_directory_is_fresh_and_private() { + let parent = tempfile::tempdir().unwrap(); + let a = deploy_directory(parent.path()).unwrap(); + let b = deploy_directory(parent.path()).unwrap(); + assert_ne!(a.path(), b.path()); + assert!(a.path().starts_with(parent.path())); + assert!(a.path().file_name().unwrap().to_str().unwrap().starts_with("imcp2-deploy-")); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let mode = std::fs::metadata(a.path()).unwrap().permissions().mode() & 0o777; + assert_eq!(mode, 0o700, "mode {mode:o}"); + } + } + #[tokio::test(flavor = "multi_thread")] async fn a_bundle_deploys_up_to_the_create_phase_offline() { let (wasm, sha) = wasm();