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
6 changes: 6 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,12 @@ jobs:
- name: Test
run: cargo test --locked --workspace --all-targets

# The deploy tools on the hosted server are a feature of `imcp2` (off in
# the default build, on in the native deployment's), so they get their
# own run.
- name: Test the hosted deploy feature
run: cargo test --locked -p imcp2 --features deploy --all-targets

# Formatting, enforced rather than merely requested: CONTRIBUTING.md asks for
# `cargo fmt --all` and the coding guidelines ask for a rustfmt-clean tree,
# and this is what keeps that true. Without it the tree drifts out of shape
Expand Down
5 changes: 5 additions & 0 deletions Cargo.lock

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

17 changes: 15 additions & 2 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -99,9 +99,17 @@ serde = { workspace = true }
serde_json = { workspace = true }
tracing = { workspace = true }
tracing-subscriber = { workspace = true }
tokio-util = "0.7"
tokio-util = { version = "0.7", features = ["io"] }
uuid = { workspace = true }
tower-http = { version = "0.7", features = ["cors"] }
# The deploy tools on the hosted server (src/deploy.rs), behind the `deploy`
# feature. imcp2-deploy pins icp-cli crates by git, which crates.io refuses, so
# the feature keeps `cargo publish` from working until the icp CLI's crates are
# published; a release build with the feature is a deployment's choice.
imcp2-deploy = { path = "crates/imcp2-deploy", optional = true }
# The deploy endpoint: streamed bodies (the upload, the watched event stream).
futures-util = "0.3"
http-body-util = "0.1"
# Metrics exposition at /metrics. Pinned to the version dfinity/ic uses: these
# series are destined for the same Prometheus / Victoria Metrics clusters that
# scrape the IC, so matching the org's client keeps one exposition dialect in the
Expand All @@ -125,12 +133,17 @@ pocket-ic = { version = "15", optional = true }
# src/e2e_handshake.rs). Off by default: the standard `cargo test` neither
# builds `pocket-ic` nor touches the network.
e2e = ["dep:pocket-ic"]
# Serves the deploy tools (icp_deploy_bundle, icp_deploy_upload,
# icp_cycles_balance, icp_deploy_guide) on the MCP endpoint, with the bundle
# upload route and the event-stream transport for the deploy call. See
# src/deploy.rs and the note on imcp2-deploy above.
deploy = ["dep:imcp2-deploy"]

[dev-dependencies]
# Router-level contract tests (tests/routers.rs) drive the composed app with
# tower::ServiceExt::oneshot and read bodies via http-body-util.
tower = { version = "0.5", features = ["util"] }
http-body-util = "0.1"
tempfile = "3"
# `test-util` is what `#[tokio::test(start_paused = true)]` needs: the pending
# connect tests in `auth.rs` walk a connect past its TTL and retention on a
# paused clock instead of waiting them out. A feature on a dependency the crate
Expand Down
59 changes: 58 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -542,6 +542,60 @@ erroring. The design mirrors the reference IC connector's OQL primer (detect +
teach), adding an ergonomic executor suited to this server's structured-output
conventions.

## Deploying a project bundle (the `deploy` feature)

Built with `cargo build --features deploy`, the server also serves the
deploy tools of the `imcp2-deploy` crate on the MCP endpoint: `icp_deploy_bundle`
deploys an [icp CLI](https://github.com/dfinity/icp-cli) project bundle to the
Internet Computer as the user — every phase of `icp deploy`, creating the
canisters that do not exist yet from the user's cycles balance (`icp_cycles_balance`
reports it) and upgrading those named in `canister_ids` — and `icp_deploy_guide`
returns the skill that walks an agent through the loop (also the
`skill://deploy-with-mcp` resource). It signs as the user's default Internet
Identity account at `https://cli.id.ai`, the principal `icp identity link web`
links an icp CLI identity to; `crates/imcp2-local/README.md` says what that
means for sharing canisters and cycles with the CLI.

What the hosted composition adds to the local one:

- **The bundle arrives by upload.** The agent's machine cannot hand this server
a file, and a tool call carries only what the model writes, so
`icp_deploy_upload` mints a one-time ticket inside the authenticated call and
returns the `curl` command that `PUT`s the archive to
`{mcp_path}/deploy/upload`. The ticket is 256 random bits, single use, good
for five minutes, bound to the session that minted it (the upload it produces
is deployable by that session alone), and sent in a request header so it
stays out of proxy logs. The route is outside the bearer gate — the ticket is
its authentication — and streams the body to disk under
`$MCP_DEPLOY_MAX_UPLOAD_BYTES`, refusing a declared length over it unread. An
upload is held fifteen minutes for its deploy, and removed with it.
- **The deploy call is answered as an event stream.** The endpoint answers a
POST with plain JSON, the one message a handler returns; a deploy reports
each phase as a progress notification before its result, so a `tools/call`
of `icp_deploy_bundle` is dispatched to a second rmcp service configured for
Server-Sent-Events responses, still stateless. Every other request keeps the
JSON service. The stream carries each phase as it completes, which is also
what keeps a client's per-call timeout from expiring on a minutes-long
deploy (send a progress token).
- **A client that goes away stops its deploy.** The event stream's body is
wrapped so that its drop — the client disconnecting — cancels a token the
deploy watches; the deploy stops at its next step, and its working directory
stays under `{state_dir}/deploys` with the ids it recorded, for an operator
to hand the user.
- **Bounds.** One deploy per session and `$MCP_DEPLOY_MAX_CONCURRENT` across
sessions, refused past the cap rather than queued. Each deploy's directory is
made under `{state_dir}/deploys`, readable by the server's user alone, and
removed when the deploy returns. The archive and what it unpacks are bounded
by the `imcp2-deploy` host (archive, extracted bytes, entries, module sizes),
and its sync plugins run in WASI with read-only access to their own deploy
directory and no network.

The feature is off in the default build and on in the native deployment's
(`deploy/native/build.sh`). `imcp2-deploy` pins icp-cli crates by git, which
crates.io refuses, so the published `imcp2` crate cannot carry the feature
until the icp CLI's crates are published; `cargo publish` of this crate waits
on that.

## Connect from an MCP client

Add the server to Claude Code (replace the URL with wherever it's hosted):
Expand Down Expand Up @@ -569,7 +623,10 @@ cargo run
# serves http://0.0.0.0:8000 (MCP at /mcp against production II, OAuth under it; / redirects to the landing site)
# honours $PORT (default 8000), $PUBLIC_URL (default http://localhost:8000),
# $MCP_SERVE_BETA (set it to also serve the beta II instance at /mcp-beta, for staging),
# and $MCP_SERVE_METRICS (set it to serve the Prometheus exposition at /metrics)
# and $MCP_SERVE_METRICS (set it to serve the Prometheus exposition at /metrics).
# Built with `--features deploy` it also serves the deploy tools, with
# $MCP_DEPLOY_MAX_CONCURRENT deploys at once (default 2) and uploads of at most
# $MCP_DEPLOY_MAX_UPLOAD_BYTES (default 64 MiB); see "Deploying a project bundle".
```

The human-facing pages — the landing page and the `/privacy-policy`,
Expand Down
5 changes: 5 additions & 0 deletions crates/imcp2-deploy/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,11 @@ tracing = { workspace = true }
base64 = { workspace = true }
sha2 = { workspace = true }
hex = { workspace = true }
# Upload tickets and ids are 256 random bits each.
getrandom = { workspace = true }
# The HTTP request parts a transport leaves in the request context, where the
# hosted server says the client has gone.
http = { workspace = true }
url = { workspace = true }
async-trait = "0.1"
# `icp-project` spells every path as a UTF-8 path.
Expand Down
10 changes: 10 additions & 0 deletions crates/imcp2-deploy/src/deploy.rs
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,10 @@ pub enum BundleSource {
Bytes(Vec<u8>),
/// An absolute path to the archive on this machine.
Path(Utf8PathBuf),
/// An archive uploaded ahead of the call, by the id the upload answered
/// with (see [`crate::Uploads`]). The tool turns it into a [`Self::Path`]
/// before the deploy; one reaching the deploy as is, is a request error.
Upload(String),
}

// By hand so a request never prints megabytes of archive.
Expand All @@ -50,6 +54,7 @@ impl fmt::Debug for BundleSource {
match self {
BundleSource::Bytes(bytes) => write!(f, "Bytes({} bytes)", bytes.len()),
BundleSource::Path(path) => write!(f, "Path({path})"),
BundleSource::Upload(id) => write!(f, "Upload({id})"),
}
}
}
Expand Down Expand Up @@ -200,6 +205,11 @@ pub async fn deploy_bundle(
let bytes = match request.bundle {
BundleSource::Bytes(bytes) => bytes,
BundleSource::Path(path) => read_bundle_file(&path, host::MAX_BUNDLE_BYTES).await?,
BundleSource::Upload(id) => {
return Err(RequestError(format!(
"the upload '{id}' was not resolved to a file before the deploy"
)))
}
};

// The working directory holds the bundle's files, the ids the deploy
Expand Down
Loading