Skip to content

Add icp_deploy_bundle to the local MCP: deploy an icp project bundle via icp-project - #216

Closed
raymondk wants to merge 7 commits into
mainfrom
deploy-bundle-endpoint
Closed

raymondk wants to merge 7 commits into
mainfrom
deploy-bundle-endpoint

Conversation

@raymondk

@raymondk raymondk commented Oct 28, 2026 •

Copy link
Copy Markdown

Summary

Adds icp_deploy_bundle to the local MCP server: a tool that deploys an icp project bundle to the Internet Computer as the signed-in user, through the icp CLI's own project library. It takes a bundle (the archive icp project bundle writes, by path or inline), an environment name, and an optional canister-id mapping from an earlier deploy; it answers with the deployment status, the resulting canister-id mapping, the canisters created and a per-phase log. A failed run still reports every canister it created before failing, so the mapping can be passed back to resume rather than repeat. Next to it, icp_cycles_balance reports the balance and principal those deploys create canisters from.

The tool drives icp_project::operations::deploy::deploy unchanged, the same entry point icp deploy uses, so creation, id wiring, settings, install/upgrade with the Candid check, and the sync steps that upload a frontend's assets all behave as the CLI does.

Who deploys. 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. That 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, funded from the same cycles balance. It is deliberately not the management identity the canister lifecycle tools act as; the docs say a deployed canister is reached through those tools only after adding their principal as a controller. A per-app delegation lives five minutes and a deploy can run longer, so the agent signs through a RefreshingIdentity that swaps in a fresh delegation from the session store every ten seconds, for the same principal, until the deploy ends.

The bundle is untrusted input. A bundle is a tar.gz an agent hands over, so the host treats it as exactly that and nothing more:

  • Extraction refuses symlinks and other non-file entries, anything under .icp/ (the id and artifact state a deploy seeds itself), and bounds both the compressed size (256 MiB) and the decompressed stream (1 GiB). A bundle_path must be a regular file; an inline bundle is bounded on its base64 length before decoding, after the session check.
  • The project library reads the bundle through BundleFs, a filesystem backed by the index extraction wrote: a path is resolved by normalizing it against the bundle root (.. clamped, absolute re-anchored) and looking it up in the index. A path the archive did not carry is "not in the bundle", whatever exists on disk at that spelling. Writes land only in scratch directories the filesystem created.
  • check_bundle validates the whole bundle before any phase runs: every canister built by exactly one pre-built step naming a wasm in the archive that hashes to its recorded checksum, every sync plugin in the archive, no script build or sync steps (the Build and ScriptRunner seams refuse them too), and an environment on the ic network. The network seam answers with the pinned mainnet root key and refuses everything else.

Why a new crate, and why local-only. icp-project and its siblings are publish = false upstream, so they can only be git dependencies, and crates.io refuses any crate that carries one. imcp2-core and imcp2 publish there, so the deploy code lives in a new publish = false crate, crates/imcp2-deploy, composed into imcp2-local alone. The four icp-cli crates are pinned to one rev and must be bumped together. icp-sync-plugin brings wasmtime into the build, so workspace compiles are slower. Most of host.rs exists because icp-project ships no host for a bundle; dfinity/icp-cli#863 asks for one upstream, with filesystem and browser backings, so this crate could shrink to the MCP glue.

A skill for the agent. The server also serves skill://deploy-with-mcp next to core's bundled IC skills, only where the tool is. It walks through the loop from the agent's side: bundle the project with icp project bundle, call icp_deploy_bundle with the environment's .icp/data/mappings/<environment>.ids.json as canister_ids, write the returned mapping back into that file so the next deploy upgrades rather than creates. The server instructions point at it.

Policy note for reviewers. Core's server instructions say no tool creates, funds or deploys canisters. This tool creates canisters from the user's cycles-ledger balance (2T each by default). The local server's instructions call it out as the one exception; the wording in core is untouched and may want revisiting.

Related issues

Changes

  • crates/imcp2-deploy (new, publish = false):
    • lib.rs: DeployBundleArgs / DeployBundleOutput, argument validation (exactly one bundle source, bounded base64, mode, principals), the IcDeployTools router with icp_deploy_bundle and icp_cycles_balance, CLI_ORIGIN, structured ok/error results.
    • identity.rs: RefreshingIdentity, the ic-agent identity a deploy signs with, refreshed from the session store while it runs.
    • calls.rs: CanisterCalls over ic-agent. Mirrors icp-app's implementation minus proxy routing; certified reads via read_state.
    • host.rs: the Host for a bundle. BundleFs (index-backed filesystem), IcMainnet (network seam, ic only, pinned root key), BundledWasm (local sources from the index, sha256 verified), NoScripts (pre-built steps only), check_bundle, extract_bundle with its limits.
    • static/skills/deploy-with-mcp.md + skill_document: the skill, in the registry's SKILL.md shape, compiled in like core's.
    • deploy.rs: read the bundle source, extract to a scratch dir, load the project, check the bundle, seed the id store from the mapping, resolve_targets + deploy, read the ids back (always merged with the canisters created), render the event stream into the log.
  • crates/imcp2-core: cycles_balance_of(agent, principal) split out of cycles_balance, and Identities::agent_as made public, so the deploy tools can read a balance as an identity other than the management one.
  • crates/imcp2-local: server.rs composes the deploy router (list/call/get) next to the login tools, serves the deploy skill through list_resources/read_resource ahead of core's, and adds a DEPLOYING paragraph to the instructions; main.rs wires it; README gains a "Deploying a project bundle" section.
  • Workspace Cargo.toml adds the member; Cargo.lock gains the icp-cli crates and their tree (plus semver-compatible patch bumps of anyhow, toml, wasm-encoder/wasmparser/wast).

Testing

  • cargo build --locked --workspace --all-targets
  • cargo test --locked --workspace --all-targets (71 + 8 + 10 + 206 + 27 + 21 + 1; the 27 are new)
  • cargo fmt --all -- --check / cargo clippy --locked --workspace --all-targets (clean)
  • npm test --prefix monitoring/mcp-status (dashboard unchanged)

The new tests run the real pipeline offline: a hand-built bundle (manifest + (module) wasm + its sha256) is extracted, loaded through icp-project, resolved to the implicit ic environment, checked, and stopped at the create phase with no_create, exactly where icp deploy --no-create stops. Also covered, each as a request error before any phase: a checksum mismatch, a script build or sync step, a managed network and a testnet, a manifest path the bundle does not hold, an unknown environment or canister. Extraction: no manifest, not an archive, oversize compressed and decompressed, a symlink entry, an entry under .icp/, a bundle_path that is not a regular file, an oversized inline bundle refused on length. The filesystem: a file planted under the root outside the index is unreachable, absolute and climbing paths resolve inside the bundle, build output lands in scratch and nowhere else. Plus the pinned root key, the refreshing identity's swap and same-principal check, log rendering, error chains, argument validation and the structured error shape. The local server's round-trip test expects 14 tools and checks a signed-out icp_deploy_bundle call is refused before any file is read; its resource round-trip expects the deploy skill in the listing and reads it whole through a real client.

Not covered: an actual on-chain deploy, and the Internet Identity round trip that mints a delegation at cli.id.ai. Nothing creates a canister, installs code or runs a sync plugin, so calls.rs is exercised only by compilation. The existing PocketIC fixture has no cycles ledger or engine registry, which the create phase needs. A follow-up could add a PocketIC test using mode: install with no_create against a pre-created canister to exercise the install and metadata-read paths.

Checklist

  • I have read the Contributing guidelines.
  • Docs (README / comments) updated for any user-visible change.
  • No secrets, credentials, or internal-only information are included.

🤖 Generated with Claude Code

@raymondk
raymondk requested review from a team and a balanced review from Copilot October 28, 2026 15:41

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 Changes recommended

Crafted bundles can execute arbitrary local scripts, exfiltrate files outside the extraction directory, and exhaust temporary storage because execution restrictions, path containment, and extraction quotas are not enforced.

4 open issues
What changed in this PR

Adds icp_deploy_bundle to the local MCP server, deploying project bundles through the icp CLI’s project library as the signed-in user. The deployment code lives in a separate, unpublished crate to keep git dependencies out of the crates.io-published components.

Changes:

  • Adds bundle handling, deployment orchestration, signed canister calls, and structured results with logs and canister IDs.
  • Integrates the deploy tool into local server routing, authentication, documentation, and tests.
  • Adds pinned icp-cli dependencies and the Wasmtime sync-plugin runtime.
File Description
crates/​imcp2-local/​src/​server.rs Routes the deploy tool and extends server instructions and tests.
crates/​imcp2-local/​src/​main.rs Constructs the deploy tools with shared session access.
crates/​imcp2-local/​README.md Documents deployment inputs, results, and authentication.
crates/​imcp2-local/​Cargo.toml Adds the local deployment dependency.
crates/​imcp2-deploy/​src/​lib.rs Defines tool arguments, validation, authentication, and results.
crates/​imcp2-deploy/​src/​host.rs Implements bundle extraction and project host adapters.
crates/​imcp2-deploy/​src/​deploy.rs Orchestrates deployment, ID recovery, logging, and offline tests.
crates/​imcp2-deploy/​src/​calls.rs Implements signed calls and certified reads through ic-agent.
crates/​imcp2-deploy/​Cargo.toml Defines the unpublished crate and pins icp-cli dependencies.
Cargo.toml Registers the deployment crate in the workspace.
Cargo.lock Locks the deployment dependency tree and related updates.

Review effort: Balanced

Comment thread crates/imcp2-deploy/src/host.rs Resolved
Comment thread crates/imcp2-deploy/src/host.rs Resolved
Comment thread crates/imcp2-deploy/src/host.rs Resolved
Comment thread crates/imcp2-deploy/src/deploy.rs Resolved
Comment thread crates/imcp2-deploy/src/calls.rs Resolved
Copilot AI balanced review requested due to automatic review settings October 28, 2026 16:21

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 Changes recommended

Unresolved critical flaws let crafted bundles execute host commands, read outside the extraction root, and exhaust local resources, while incomplete canister ID recovery can cause duplicate deployments and repeated cycle spending.

6 open issues

Review effort: Balanced

Comment thread crates/imcp2-deploy/src/host.rs Resolved
Comment on lines +41 to +42
otherwise. `icp_cycles_balance` reports the balance and the principal
it belongs to. Before a deploy that will create canisters, tell the user how
raymondk and others added 2 commits November 2, 2026 22:29
…via icp-project

A new non-published crate, imcp2-deploy, wraps icp-project's deploy
operation behind an MCP tool. It takes a bundle (path or base64), an
environment name and an optional canister id mapping, and answers with
the deployment status, the resulting canister id mapping, the canisters
created and a per-phase log.

The crate is composed only into imcp2-local: icp-project and its
siblings are git dependencies, and crates.io refuses crates that carry
one, so they cannot enter imcp2-core or the hosted server.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The agent-side loop around icp_deploy_bundle, as a skill resource next to
core's bundled IC skills: bundle the project with `icp project bundle`,
call the tool with the environment's `<env>.ids.json` as canister_ids,
write the returned mapping back into that file. Served only where the
tool is, as `skill://deploy-with-mcp`; the server instructions and the
README point at it.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@raymondk
raymondk force-pushed the deploy-bundle-endpoint branch from d1d0a00 to 65f73db Compare November 2, 2026 22:50
Copilot AI balanced review requested due to automatic review settings November 2, 2026 22:50

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Comment thread crates/imcp2-deploy/src/lib.rs Resolved
raymondk and others added 2 commits November 2, 2026 23:04
…ound its extraction

The archive icp_deploy_bundle receives arrives in a tool call, and the
agent passing it may have fetched it from anywhere; the deploy host now
enforces the shape `icp project bundle` writes instead of assuming it.

* Extraction writes only regular files and directories (a symlink is how
  an archive reaches past its directory), and the entries' sizes may not
  add up to more than 1 GiB — the 256 MiB archive limit bounded nothing
  on disk, gzip expanding by up to a thousand to one. The decompressed
  stream is bounded too, independently of what the headers claim.
* Every path the manifest names (a wasm, an argument file, a variable
  file) is resolved against the extracted root, symlinks followed, and
  refused if it leads outside it or to anything but a regular file; the
  project library reads them all through its FileSystem seam, so one
  confined adapter covers them, and the build scratch moves under the
  root so a step's output stays readable through the same fence.
* Script build and sync steps are refused by name before any phase runs
  (a sync step would otherwise be reached only after canisters were
  created), and the build/sync seams refuse them again as a backstop.

Also: a created canister's id is merged into the returned mapping
whether or not the id store could record or report it, so a retry
cannot create it twice; and the one field of AgentCalls that caches the
agent's principal says why (the trait's `caller` is infallible, the
agent's `get_principal` is not).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The deploy docs and skill told the agent to check the balance with
icp_cycles_balance before a deploy that creates canisters, but that tool
belongs to the core's protocol/meta half, which the local server does not
serve — the call answered "tool not found". The deploy tool group now
carries a tool of that name over the same logic (re-exported from core),
so the deploy loop's balance check exists on the surface it runs on.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Copilot AI balanced review requested due to automatic review settings November 2, 2026 23:04

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Comment thread crates/imcp2-deploy/src/deploy.rs Resolved
// Seed the id store with the mapping the caller passed: from here on the
// operation sees those canisters as existing, exactly as it would see the
// ids a previous `icp deploy` recorded in the project's `.icp` directory.
for (name, id) in &request.canister_ids {
Comment thread crates/imcp2-deploy/src/lib.rs Resolved
raymondk and others added 3 commits November 3, 2026 00:05
…le for `ic` up front

The deploy host no longer fences the extracted directory by canonicalizing
every path against the disk. Extraction keeps the list of files it wrote,
and that list is the filesystem the project library reads through: a path
the manifest names is normalized against the bundle root — `..` cannot
climb above it, an absolute path is re-anchored at it — and looked up in
the index. Present, it is read from where extraction put it; absent, it is
"not in the bundle", whatever the disk holds at that spelling. Confinement
is by construction, the way the browser bundle deployer's in-memory
filesystem gets it, while the disk stays the byte store because the
sync-plugin runtime mounts real directories. The extraction rules (regular
files and directories only, within the size budget) are what make the
index trustworthy, so they stay.

The bundle is also checked whole before any phase runs, in the shape `icp
project bundle` writes: each canister built by exactly one pre-built wasm
in the archive that hashes to its recorded checksum, each sync plugin in
the archive, no script steps, and the environment on the `ic` network.
Anything else is a request error that names the canister, instead of a
deploy that fails at the phase that reaches it — after canisters were
created. Deploying is to the Internet Computer only now: the network seam
answers with the pinned mainnet key and nothing else, and the
trust-on-first-use root-key fetch is gone.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…eb` signs in as

The deploy tools signed as the management identity: the default account
at the server's own origin, which only this server knows. A canister it
created was therefore controlled by a principal the user's icp CLI has
never heard of, and funded from a cycles balance separate from the
CLI's.

Derive the deploy identity at `https://cli.id.ai` instead, the origin
`icp identity link web` (with no `--app`) signs in at. Internet Identity
gives one principal per anchor per origin, so this is the same principal
the CLI deploys as on the user's machine: canisters created here are the
ones `icp` lists and controls, and they draw on the same cycles.
`icp_cycles_balance` follows, reporting that principal's balance.

The canister lifecycle tools keep the management identity. The docs now
say a deployed canister is reached through them only after adding their
principal as a controller.

`cycles_balance_of(agent, principal)` is split out of core's
`cycles_balance` so a caller acting as another identity can read a
balance, and `Identities::agent_as` is made public for the same reason.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…ments may not supply

Four more findings from review, each a way for a deploy's input to reach
past what a bundle is:

- A per-app delegation lives five minutes, and a deploy can run longer
  (an asset sync, several installs). The agent now signs through a
  `RefreshingIdentity` that swaps in a fresh delegation from the session
  store every ten seconds while the deploy runs, keeping the principal
  it started as.
- An archive could carry `.icp/data/mappings/<env>.ids.json`, which the
  id store would read as existing canisters the caller never named.
  Entries under `.icp/` are refused at extraction; a bundle never has
  them.
- A `bundle_path` naming a device or a pipe passed the size check (a
  zero-length `/dev/zero`) and then read without end. Only a regular
  file is read.
- An inline bundle was decoded in full, before the limit and before the
  session check. The base64 text is bounded by length first, the session
  is checked before any decoding, and the decoded size is checked too.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@raymondk

raymondk commented Nov 3, 2026

Copy link
Copy Markdown
Author

Superseded by #230, #231 and #232.

@raymondk raymondk closed this Nov 3, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants