Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions crates/imcp2-deploy/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ candid = { workspace = true }
# `rt-multi-thread`: the sync-plugin runtime runs a wasm component on a
# blocking thread (`block_in_place`), which only a multi-thread runtime has.
tokio = { workspace = true, features = ["macros", "rt-multi-thread", "sync", "fs", "io-util"] }
# The token a running deploy is stopped through.
tokio-util = "0.7"
serde = { workspace = true }
serde_json = { workspace = true }
tracing = { workspace = true }
Expand Down
262 changes: 198 additions & 64 deletions crates/imcp2-deploy/src/deploy.rs

Large diffs are not rendered by default.

72 changes: 64 additions & 8 deletions crates/imcp2-deploy/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -51,11 +51,13 @@ use camino::Utf8PathBuf;
use candid::Principal;
use ic_agent::{identity::DelegatedIdentity, Identity};
use imcp2_core::{cycles_balance_of, identities::Identities, CyclesBalance, SessionResolver};
#[allow(deprecated)]
use rmcp::model::{LoggingLevel, LoggingMessageNotificationParam};
use rmcp::{
handler::server::wrapper::Parameters,
model::{CallToolResult, ContentBlock},
model::{CallToolResult, ContentBlock, ProgressNotificationParam, ProgressToken},
schemars,
service::RequestContext,
service::{Peer, RequestContext},
tool, tool_router, ErrorData as McpError, RoleServer,
};
use serde::{Deserialize, Serialize};
Expand All @@ -65,7 +67,9 @@ mod deploy;
mod host;
mod identity;

pub use deploy::{deploy_bundle, BundleSource, DeployOutcome, DeployRequest, RequestError, Status};
pub use deploy::{
deploy_bundle, BundleSource, DeployOutcome, DeployRequest, RequestError, Status, Watch,
};
use identity::RefreshingIdentity;

/// The icp CLI's default funding for a canister it creates (`icp deploy
Expand Down Expand Up @@ -152,8 +156,9 @@ pub struct CreatedCanister {
#[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).
/// what landed before the failure), `interrupted` (the call was cancelled
/// or the client went away; likewise), or `nothing_to_deploy` (the
/// environment lists no canisters).
pub status: String,
/// The environment deployed.
pub environment: String,
Expand Down Expand Up @@ -210,6 +215,12 @@ impl DeployBundleOutput {
"Environment '{}' declares no canisters, so there was nothing to deploy.",
self.environment
),
"interrupted" => format!(
"Deploying environment '{}' to network '{}' was INTERRUPTED: {}",
self.environment,
self.network,
self.error.as_deref().unwrap_or("stopped before it finished")
),
_ => format!(
"Deploying environment '{}' to network '{}' FAILED: {}",
self.environment,
Expand Down Expand Up @@ -379,7 +390,7 @@ impl IcDeployTools {
}

#[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.",
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. The call lasts as long as the deploy and reports each phase as it completes, as a progress notification (send a progressToken) and a log message; cancelling the call interrupts the deploy at its next step. Returns the status, the resulting canister-id mapping, and a log of each phase; on failure or interruption 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::<DeployBundleOutput>(),
)]
Expand Down Expand Up @@ -417,13 +428,23 @@ impl IcDeployTools {
Ok(id) => id,
Err(e) => return Ok(err(e)),
};
match deploy_bundle(identity, request).await {
// Each phase reaches the client as it completes, and the client's
// cancellation of the request stops the deploy.
let (lines, receiver) = tokio::sync::mpsc::unbounded_channel();
let forwarder =
tokio::spawn(forward_lines(ctx.peer.clone(), ctx.meta.get_progress_token(), receiver));
let watch = Watch { stop: ctx.ct.child_token(), lines: Some(lines) };
let result = deploy_bundle(identity, request, watch).await;
// The sender went with the run; the forwarder ends once it has
// delivered what is left.
let _ = forwarder.await;
match result {
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::Failed | Status::Interrupted => err_structured(text, &output),
Status::Deployed | Status::NothingToDeploy => ok_structured(text, &output),
})
}
Expand Down Expand Up @@ -477,6 +498,41 @@ impl IcDeployTools {
}
}

/// Deliver each log line of a running deploy to the client as it is made: as
/// a progress notification when the request carried a progress token (which
/// is also what keeps a client's per-call timeout from running out during a
/// long deploy), else as a logging message, the one other thing a server may
/// say mid-call. A client that cannot be reached is not an error here; the
/// outcome carries every line regardless.
// MCP logging is deprecated (SEP-2577) but still delivered; it is the
// fallback for a client that sends no progress token, nothing more.
#[allow(deprecated)]
async fn forward_lines(
peer: Peer<RoleServer>,
progress_token: Option<ProgressToken>,
mut lines: tokio::sync::mpsc::UnboundedReceiver<String>,
) {
let mut progress = 0.0;
while let Some(line) = lines.recv().await {
progress += 1.0;
let _ = match &progress_token {
Some(token) => {
let param =
ProgressNotificationParam::new(token.clone(), progress).with_message(line);
peer.notify_progress(param).await
}
None => {
let param = LoggingMessageNotificationParam::new(
LoggingLevel::Info,
serde_json::Value::String(line),
)
.with_logger("icp_deploy_bundle");
peer.notify_logging_message(param).await
}
};
}
}

/// `icp_cycles_balance` and `icp_deploy_guide` take no arguments.
#[derive(Debug, Default, Deserialize, schemars::JsonSchema)]
pub struct NoArgs {}
Expand Down
32 changes: 23 additions & 9 deletions crates/imcp2-deploy/static/skills/deploy-with-mcp.md
Original file line number Diff line number Diff line change
Expand Up @@ -128,22 +128,33 @@ 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`.

### Watch it run

The call lasts as long as the deploy: creating canisters, installing code
and syncing a frontend's assets take from seconds to minutes. The tool
reports each phase and canister step the moment it completes, as a progress
notification when your call carries a `progressToken` (most clients attach
one; it is also what keeps your client's per-call timeout from expiring
mid-deploy), else as a server log message. Keep the call open until it
returns. Cancelling it interrupts the deploy at its next step, see pitfall 8.

### Read the answer

The tool returns:

- `status`: `deployed`, `failed`, or `nothing_to_deploy`.
- `status`: `deployed`, `failed`, `interrupted`, 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`.
- `error`: what failed, when the status is `failed` or `interrupted`.
- `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.
A deploy that fails, or is interrupted, still returns every canister it
created before that, 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

Expand Down Expand Up @@ -187,9 +198,12 @@ interchangeably for that environment.
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
8. **A call that never returned.** Cancelling the call, or losing the
connection, interrupts the deploy at its next step; a canister call
already in flight completes on the network. If the server stopped
mid-deploy, or you cancelled and never read the answer, 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
Expand Down
15 changes: 10 additions & 5 deletions crates/imcp2-local/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -163,10 +163,15 @@ It takes:
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
The call lasts as long as the deploy, and reports each phase and canister
step as it completes: as an MCP progress notification when the client's call
carries a progress token (which also keeps the client's per-call timeout from
expiring mid-deploy), else as a server log message. Cancelling the call
interrupts the deploy at its next step. It answers with the status
(`deployed`, `failed`, `interrupted`, `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 or interrupted deploy still reports
every canister it created before that, so the run can be picked up rather than
repeated.

Calls are signed as your **default Internet Identity account at
Expand All @@ -191,7 +196,7 @@ 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
mid-way) or is interrupted leaves its directory there, and inside it
`.icp/data/mappings/<environment>.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
Expand Down
10 changes: 8 additions & 2 deletions crates/imcp2-local/src/server.rs
Original file line number Diff line number Diff line change
Expand Up @@ -264,6 +264,10 @@ impl ServerHandler for LocalServer {
let mut info = self.tools.get_info();
info.server_info.name = "imcp2-local".into();
info.server_info.version = env!("CARGO_PKG_VERSION").into();
// A running deploy reports each phase mid-call: as progress when the
// request carried a token, else as a logging message, which a client
// only expects from a server that declares it.
info.capabilities.logging = Some(Default::default());
// The core instructions apply verbatim; add how signing in works here
// (the hosted server logs in via OAuth outside the tool surface, so
// core's text says nothing about it).
Expand All @@ -284,8 +288,10 @@ impl ServerHandler for LocalServer {
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. \
\"Actions & questions\" session, runs one deploy at a time, lasts as long as the \
deploy (send a progressToken to see each phase as it completes; cancelling the \
call interrupts the deploy at its next step), 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; `icp_deploy_guide` returns the same \
text."
Expand Down