diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 12ff3d2..e55157b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -36,10 +36,10 @@ jobs: # `--locked` fails if Cargo.lock is out of date, so CI also guards the # dependency pins (e.g. ic-agent / ic-transport-types) from silent drift. - name: Build - run: cargo build --locked --all-targets + run: cargo build --locked --workspace --all-targets - name: Test - run: cargo test --locked --all-targets + run: cargo test --locked --workspace --all-targets # Block new internal network identifiers from entering the repository. Secret # scanning and push protection already cover credentials; this covers the diff --git a/.github/workflows/publish-crate.yml b/.github/workflows/publish-crate.yml index dc78c05..97d72be 100644 --- a/.github/workflows/publish-crate.yml +++ b/.github/workflows/publish-crate.yml @@ -31,8 +31,10 @@ # Environment name release <- must match the `environment:` below, # or be left blank to accept any # A trusted publisher can only be configured on a crate that already exists, so the -# FIRST release (0.1.0) has to be published by hand — `cargo publish --locked` with -# a personal token — and every release after it goes through this workflow. +# FIRST release of each crate has to be published by hand — `cargo publish --locked` +# with a personal token (imcp2's 0.1.0 was; imcp2-core's first release must be, and +# its trusted publisher configured with this same repository/workflow/environment) +# — and every release after it goes through this workflow. # # REQUIRED REPOSITORY SETTINGS, to be in place BEFORE the trusted publisher is # enabled. Neither can be expressed in this file, and without them the guards below @@ -101,20 +103,24 @@ jobs: rustup default stable rustc --version && cargo --version - # A tag that disagrees with the manifest publishes a version under a name - # nobody can check out — and crates.io cannot take it back. Read the version - # from cargo itself rather than grepping the manifest, so the check reads the - # same field `cargo publish` will. - - name: Check the tag matches Cargo.toml + # A tag that disagrees with the manifests publishes a version under a name + # nobody can check out — and crates.io cannot take it back. Read the versions + # from cargo itself rather than grepping the manifests, so the check reads the + # same field `cargo publish` will. Both workspace crates release in lockstep: + # one tag, one version, two packages (imcp2 depends on imcp2-core "=version" + # semantics via the workspace pin, so a skew would not resolve anyway). + - name: Check the tag matches both Cargo.toml versions run: | tag="${GITHUB_REF_NAME#v}" - manifest="$(cargo metadata --format-version 1 --no-deps | - jq -er '.packages[] | select(.name == "imcp2") | .version')" - echo "tag=$tag manifest=$manifest" - if [ "$tag" != "$manifest" ]; then - echo "::error::tag ${GITHUB_REF_NAME} does not match Cargo.toml version ${manifest}" >&2 - exit 1 - fi + for pkg in imcp2-core imcp2; do + manifest="$(cargo metadata --format-version 1 --no-deps | + jq -er --arg pkg "$pkg" '.packages[] | select(.name == $pkg) | .version')" + echo "tag=$tag $pkg=$manifest" + if [ "$tag" != "$manifest" ]; then + echo "::error::tag ${GITHUB_REF_NAME} does not match ${pkg}'s Cargo.toml version ${manifest}" >&2 + exit 1 + fi + done # A tag can name any commit in the repository, including one that never went # through review. This catches the mistake — tagging a topic branch, or a @@ -135,15 +141,21 @@ jobs: # immutable public release. `--locked` also guards Cargo.lock from drift, and # the lockfile is what ships in the .crate. - name: Test - run: cargo test --locked --all-targets + run: cargo test --locked --workspace --all-targets - # Rehearses the real thing — packages the crate and compiles the package in - # isolation — but stops short of uploading, so a package that only builds - # because of something untracked in the working tree fails here rather than - # after the upload is already permanent. This is also the verify build that - # lets the publish job skip its own; see `--no-verify` below. + # Rehearses the real thing — packages each crate, and compiles imcp2-core's + # package in isolation — but stops short of uploading, so a package that only + # builds because of something untracked in the working tree fails here rather + # than after the upload is already permanent. imcp2's package cannot be + # compile-verified before this release's imcp2-core exists on crates.io (it + # depends on it by version), so its rehearsal is `cargo package` without the + # verify build: the workspace test above already compiled exactly these + # sources. This is also the verify work that lets the publish job skip its + # own; see `--no-verify` below. - name: Package - run: cargo publish --locked --dry-run + run: | + cargo publish --locked --dry-run -p imcp2-core + cargo package --locked --no-verify -p imcp2 # Holds the credential, and compiles nothing. publish: @@ -177,10 +189,19 @@ jobs: # it `cargo publish` compiles the package — running the dependency tree's build # scripts and proc macros — while CARGO_REGISTRY_TOKEN sits in their # environment. The verify build is not skipped, only moved: the `verify` job - # ran `cargo publish --locked --dry-run` over this same commit, which packages - # and compiles exactly what is uploaded here. What remains in this job is - # reading files into a .crate and an HTTPS upload. - - name: Publish - run: cargo publish --locked --no-verify + # packaged both crates (and compiled imcp2-core's package) over this same + # commit, and the workspace test suite compiled exactly these sources. What + # remains in this job is reading files into a .crate and an HTTPS upload. + # + # Order matters: imcp2 depends on imcp2-core by version, so core publishes + # first; `cargo publish` waits for the just-published crate to appear in the + # index before returning, so the second publish resolves the first. + - name: Publish imcp2-core + run: cargo publish --locked --no-verify -p imcp2-core + env: + CARGO_REGISTRY_TOKEN: ${{ steps.auth.outputs.token }} + + - name: Publish imcp2 + run: cargo publish --locked --no-verify -p imcp2 env: CARGO_REGISTRY_TOKEN: ${{ steps.auth.outputs.token }} diff --git a/Cargo.lock b/Cargo.lock index a4da979..1e9e2ba 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -483,10 +483,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" dependencies = [ "iana-time-zone", - "js-sys", "num-traits", "serde", - "wasm-bindgen", "windows-link", ] @@ -1779,16 +1777,13 @@ dependencies = [ "axum", "base64 0.22.1", "candid", - "candid_parser", - "crc32fast", "getrandom 0.3.4", "hex", "http-body-util", "ic-agent", + "imcp2-core", "pocket-ic", "prometheus", - "regex", - "reqwest 0.13.4", "rmcp", "serde", "serde_json", @@ -1804,6 +1799,30 @@ dependencies = [ "uuid", ] +[[package]] +name = "imcp2-core" +version = "0.2.0" +dependencies = [ + "base64 0.22.1", + "candid", + "candid_parser", + "crc32fast", + "getrandom 0.3.4", + "hex", + "http", + "ic-agent", + "regex", + "reqwest 0.13.4", + "rmcp", + "serde", + "serde_json", + "sha2 0.11.0", + "tokio", + "tracing", + "url", + "urlencoding", +] + [[package]] name = "indexmap" version = "2.14.0" @@ -2210,25 +2229,6 @@ dependencies = [ "autocfg", ] -[[package]] -name = "oauth2" -version = "5.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "51e219e79014df21a225b1860a479e2dcd7cbd9130f4defd4bd0e191ea31d67d" -dependencies = [ - "base64 0.22.1", - "chrono", - "getrandom 0.2.17", - "http", - "rand 0.8.6", - "serde", - "serde_json", - "serde_path_to_error", - "sha2 0.10.9", - "thiserror 1.0.69", - "url", -] - [[package]] name = "object" version = "0.37.3" @@ -2931,11 +2931,9 @@ dependencies = [ "http", "http-body", "http-body-util", - "oauth2", "pastey", "pin-project-lite", "rand 0.10.1", - "reqwest 0.13.4", "rmcp-macros", "schemars 1.2.1", "serde", @@ -2947,7 +2945,6 @@ dependencies = [ "tokio-util", "tower-service", "tracing", - "url", "uuid", ] @@ -4074,7 +4071,6 @@ dependencies = [ "idna", "percent-encoding", "serde", - "serde_derive", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 1772c0d..321c1b3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -20,24 +20,27 @@ categories = [ # Everything the library and the bundled binary compile from ships; the # deployment scaffolding (container, systemd/native deploy, dashboards, CI, # submission docs) is repository-only and would just inflate the .crate. -# `src/assets` and `static` are NOT optional: both are pulled in at compile -# time with `include_str!`. +# `src/assets` is NOT optional: it is pulled in at compile time with +# `include_str!`. `crates/` holds the other workspace members (each packaged +# on its own), so it is excluded from THIS package. exclude = [ ".github/", ".dockerignore", "Dockerfile", + "crates/", "deploy/", "docs/", "monitoring/", ] -[dependencies] -rmcp = { version = "1.7", features = [ - "server", - "macros", - "transport-streamable-http-server", - "auth", -] } +[workspace] +members = [".", "crates/imcp2-core"] + +# Single source of truth for the version pins both crates share, so the agent, +# codec, and candid lines can never drift between imcp2-core and imcp2. +[workspace.dependencies] +imcp2-core = { path = "crates/imcp2-core", version = "0.2.0" } +rmcp = "1.7" # The Agent is INJECTED by the embedding application (see McpServer::new), so a # host shares one boundary-node client and one ic-agent across the process. # Deliberately a direct dependency for now: sourcing ic-agent through ic-bn-lib @@ -51,12 +54,40 @@ ic-agent = "0.49" # worker-blocking CPU (ICPBB-382); 0.10.31 made it O(n). candid = { version = "0.10.31", features = ["value"] } candid_parser = "0.3.2" -tokio = { version = "1", features = ["macros", "rt-multi-thread", "signal", "time"] } -axum = "0.8" -anyhow = "1" +tokio = "1" serde = { version = "1", features = ["derive"] } serde_json = "1" tracing = "0.1" +sha2 = "0.11.0" +base64 = "0.22.1" +hex = "0.4.3" +crc32fast = "1" +regex = "1.12.4" +reqwest = { version = "0.13", default-features = false, features = ["rustls"] } +getrandom = "0.3.4" +urlencoding = "2.1.3" +url = "2" +http = "1" + +[dependencies] +# The transport/OAuth-agnostic components (the tool surface, the II session +# engine, the connect-handshake primitives) live in imcp2-core; this crate adds +# the hosted composition: the OAuth 2.1 authorization server, the +# streamable-HTTP transport, and the axum routers. +imcp2-core = { workspace = true } +rmcp = { workspace = true, features = [ + "server", + "macros", + "transport-streamable-http-server", +] } +ic-agent = { workspace = true } +candid = { workspace = true } +tokio = { workspace = true, features = ["macros", "rt-multi-thread", "signal", "time"] } +axum = "0.8" +anyhow = "1" +serde = { workspace = true } +serde_json = { workspace = true } +tracing = { workspace = true } tracing-subscriber = { version = "0.3", features = ["env-filter"] } tokio-util = "0.7" uuid = { version = "1", features = ["v4"] } @@ -67,15 +98,12 @@ tower-http = { version = "0.7", features = ["cors"] } # estate. The `process` feature adds CPU / RSS / file-descriptor collectors on # Linux, which is the deploy target. prometheus = { version = "0.14", features = ["process"] } -sha2 = "0.11.0" -base64 = "0.22.1" -hex = "0.4.3" -crc32fast = "1" -regex = "1.12.4" -reqwest = { version = "0.13", default-features = false, features = ["rustls"] } -getrandom = "0.3.4" -urlencoding = "2.1.3" -url = "2" +sha2 = { workspace = true } +base64 = { workspace = true } +hex = { workspace = true } +getrandom = { workspace = true } +urlencoding = { workspace = true } +url = { workspace = true } # Only for the PocketIC-backed end-to-end test (src/e2e_handshake.rs), behind # the `e2e` feature so the default build never compiles it. Client library for a # local Internet Identity canister; version tracks the `pocket-ic` server binary. diff --git a/Dockerfile b/Dockerfile index 5b3ffc1..67b13cc 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,5 +1,5 @@ -# Multi-stage build for the MCP server. The WASM Candid codec under static/wasm -# is prebuilt and committed, so no wasm toolchain is needed here. +# Multi-stage build for the MCP server. Everything the binary serves is +# compiled in via include_str!, so the runtime image carries only the binary. FROM rust:1-slim-bookworm AS build WORKDIR /app # GIT_SHA / BUILD_TIME are baked into the binary (option_env! in main.rs) and @@ -11,8 +11,9 @@ ENV GIT_SHA=${GIT_SHA} ENV BUILD_TIME=${BUILD_TIME} COPY Cargo.toml Cargo.lock ./ COPY src ./src -# static/ is needed at build time too: tools.rs include_str!s the candid/OQL references. -COPY static ./static +# The workspace member crates (imcp2-core carries the tool surface and its +# compiled-in candid/OQL references under crates/imcp2-core/static). +COPY crates ./crates RUN cargo build --release FROM debian:bookworm-slim @@ -21,8 +22,6 @@ WORKDIR /app RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates \ && rm -rf /var/lib/apt/lists/* COPY --from=build /app/target/release/imcp2 /usr/local/bin/imcp2 -# Static assets (signing frontend + WASM codec) are served relative to the workdir. -COPY static ./static # See deploy/native/imcp2.service: the per-request log line is debug-level, and # is worth keeping on a deployed host. ENV RUST_LOG=info,imcp2::metrics=debug diff --git a/crates/imcp2-core/Cargo.toml b/crates/imcp2-core/Cargo.toml new file mode 100644 index 0000000..e8f4296 --- /dev/null +++ b/crates/imcp2-core/Cargo.toml @@ -0,0 +1,36 @@ +[package] +name = "imcp2-core" +version = "0.2.0" +edition = "2021" +description = "Transport-agnostic components of the IMCP2 Internet Computer MCP server: the tool surface, the Internet Identity session engine, and the II connect-handshake primitives." +license = "Apache-2.0" +repository = "https://github.com/dfinity/imcp2" +homepage = "https://mcp.internetcomputer.org" +keywords = ["mcp", "internet-computer", "icp", "candid"] +categories = ["network-programming", "api-bindings"] + +[dependencies] +# No transport feature: each binary picks its own (streamable-HTTP in imcp2, +# stdio in imcp2-local). `server` + `macros` carry the tool router, the +# ServerHandler machinery, and the schemars re-export every tool schema +# derives against. +rmcp = { workspace = true, features = ["server", "macros"] } +ic-agent = { workspace = true } +candid = { workspace = true } +candid_parser = { workspace = true } +tokio = { workspace = true, features = ["macros", "rt-multi-thread", "sync", "net"] } +serde = { workspace = true } +serde_json = { workspace = true } +tracing = { workspace = true } +sha2 = { workspace = true } +base64 = { workspace = true } +hex = { workspace = true } +crc32fast = { workspace = true } +regex = { workspace = true } +reqwest = { workspace = true } +getrandom = { workspace = true } +urlencoding = { workspace = true } +url = { workspace = true } +# The request-extension type the Bearer session lookup reads (axum re-exports +# this same type; depending on `http` directly keeps axum out of this crate). +http = { workspace = true } diff --git a/src/assets/connect-callback.html b/crates/imcp2-core/src/assets/connect-callback.html similarity index 100% rename from src/assets/connect-callback.html rename to crates/imcp2-core/src/assets/connect-callback.html diff --git a/src/assets/connect.css b/crates/imcp2-core/src/assets/connect.css similarity index 100% rename from src/assets/connect.css rename to crates/imcp2-core/src/assets/connect.css diff --git a/src/assets/dfinity-logo.svg b/crates/imcp2-core/src/assets/dfinity-logo.svg similarity index 100% rename from src/assets/dfinity-logo.svg rename to crates/imcp2-core/src/assets/dfinity-logo.svg diff --git a/src/calls.rs b/crates/imcp2-core/src/calls.rs similarity index 100% rename from src/calls.rs rename to crates/imcp2-core/src/calls.rs diff --git a/src/discover.rs b/crates/imcp2-core/src/discover.rs similarity index 100% rename from src/discover.rs rename to crates/imcp2-core/src/discover.rs diff --git a/src/identities.rs b/crates/imcp2-core/src/identities.rs similarity index 99% rename from src/identities.rs rename to crates/imcp2-core/src/identities.rs index 4cd1c7c..891110d 100644 --- a/src/identities.rs +++ b/crates/imcp2-core/src/identities.rs @@ -86,7 +86,7 @@ const REDERIVE_MARGIN_NS: u64 = 30 * 1_000_000_000; /// entries out instead of blocking sign-in, and granted sessions are never /// touched. Also the cap on `AuthStore.authz`, which holds one entry per /// pending connect (see `crate::auth`), so the two maps stay in step. -pub(crate) const MAX_PENDING_CONNECTS: usize = 1_024; +pub const MAX_PENDING_CONNECTS: usize = 1_024; /// Hard ceiling on the session map. Only sessions holding a redeemed grant can /// push toward it (pending ones are bounded far lower by @@ -592,7 +592,7 @@ impl Identities { /// Errors when the session map is at capacity ([`make_room`]) — the connect /// cannot be started, and `/oauth/authorize` surfaces that as a "try again in /// a moment" screen rather than handing II a key it isn't tracking. - pub(crate) async fn registration_pubkey_b64(&self, session_id: &str) -> Result { + pub async fn registration_pubkey_b64(&self, session_id: &str) -> Result { self.ensure_session(session_id).await?; let mut sessions = self.sessions.write().await; let s = sessions.get_mut(session_id).ok_or("no such session")?; @@ -933,7 +933,7 @@ impl Identities { /// > (decoded as [`McpRegisterV2Reply`]; [`McpRegisterV2Ok`] is the `Ok` /// > payload). Re-verify the shapes if II's `.did` ever moves; the read-only /// > `opt text`/`variant` outage (#40) is the standing lesson against drift. - pub(crate) async fn redeem_registration_delegation( + pub async fn redeem_registration_delegation( &self, session_id: &str, reg_user_key: Vec, @@ -1327,13 +1327,13 @@ fn map_delegation_error(e: AccountDelegationError) -> String { /// what II returned from `mcp_register_v2`. Surfaced to the connect handler so /// it can log the access level; the values are also recorded on the session. #[derive(Debug)] -pub(crate) struct RegistrationOutcome { +pub struct RegistrationOutcome { /// Grant expiration (ns since the Unix epoch). - pub(crate) expiration_ns: u64, + pub expiration_ns: u64, /// The recorded access level, in the delegation vocabulary: `"queries"` = /// read-only, `"all"` = full (always present — `McpRegistrationV2`'s /// `permissions` is non-optional). - pub(crate) permissions: &'static str, + pub permissions: &'static str, } // ---- II candid contract for the mcp_* delegation methods -------------------- diff --git a/crates/imcp2-core/src/iiconnect.rs b/crates/imcp2-core/src/iiconnect.rs new file mode 100644 index 0000000..4c41758 --- /dev/null +++ b/crates/imcp2-core/src/iiconnect.rs @@ -0,0 +1,507 @@ +//! Internet Identity **connect-handshake primitives**, shared by every binary +//! that logs a user in with II's registration-delegation flow: building the II +//! `/mcp` connect link, rendering the pinned fragment-reading callback page, +//! parsing the fragment's delegation chain, and the `#4091` auth-callback +//! allow-list path. Everything here is parameterised on plain values (URLs, +//! strings) and returns plain values — the embedding server wraps them in its +//! own HTTP handlers and supplies its own state (an OAuth authorization server +//! in the hosted `imcp2` binary; a transient loopback listener in a local one). + +use base64::Engine; +use candid::Principal; +use ic_agent::identity::{Delegation, SignedDelegation}; +use serde::Deserialize; + +/// Build Internet Identity's `/mcp` connect link for one connection. Everything +/// is in the URL **fragment** (never sent to II's servers): `callback` (the +/// pinned callback URL on the caller's origin), the single-use `state`, the +/// requested grant `ttl` in SECONDS, and `registration_key` — this connect's +/// registration public key `pub(X)` (DER, base64url), toward which II builds +/// the registration chain `P_reg -> Y -> X` (param name per +/// dfinity/internet-identity#4093; its presence selects the connect flow). II +/// navigates the tab back to `callback` — validated against the origin's +/// [`AUTH_CALLBACKS_WELL_KNOWN`] allow-list (#4091) — with the delegation in +/// the fragment; the callback page rendered by [`pinned_callback_page`] is the +/// sole fragment reader. No `priv(X)` is ever put in the link — only its +/// public half. +pub fn ii_mcp_url(ii_url: &str, callback_url: &str, state: &str, ttl_secs: u64, reg_pubkey_b64: &str) -> String { + format!( + "{ii_url}/mcp#callback={cb}&state={st}&ttl={ttl_secs}®istration_key={rk}", + cb = urlencoding::encode(callback_url), + st = urlencoding::encode(state), + rk = urlencoding::encode(reg_pubkey_b64), + ) +} + +// ---- Callback allow-list (II #4091) --------------------------------------- + +/// The well-known path Internet Identity fetches a server's **auth-callback +/// allow-list** from (dfinity/internet-identity#4091): before contacting the +/// connect callback named in the (attacker-craftable) link fragment, II fetches +/// `` + this path — `redirect: "error"`, no credentials, +/// `no-store`, 8 KB cap, `application/json` required — and rejects the connect +/// unless the callback URL is EXACTLY (string-equal) one of the declared +/// entries. **Fail-closed**: a missing/unfetchable file fails every connect for +/// this origin, so serving this document is mandatory once #4091 ships. +pub const AUTH_CALLBACKS_WELL_KNOWN: &str = "/.well-known/ii-auth-callbacks"; + +/// A fresh CSP nonce: 128 bits from the OS CSPRNG, **standard** base64. CSP3's +/// `base64-value` grammar also admits base64url, but CSP2's does not (`-`/`_` +/// absent), so use the standard alphabet for maximum parser compatibility — a +/// strict-CSP2 parser that rejected the nonce source would block the inline +/// script and break the callback page. `+`/`/`/`=` are all safe where the nonce +/// rides (a quoted HTML attribute and a header value). +pub fn csp_nonce() -> String { + let mut bytes = [0u8; 16]; + getrandom::fill(&mut bytes).expect("getrandom"); + base64::engine::general_purpose::STANDARD.encode(bytes) +} + +/// Styling for the pinned callback page, following the DFINITY brand guidelines +/// (Parchment/Ink/Rust palette, an editorial serif display over a UI sans, a +/// grid-paper surface, and the official gradient-infinity logo). A full-bleed +/// screen: the status stage (a spinner on a soft elevated tile plus an accessible +/// serif headline) fills and centres the viewport, with a foot-of-page "Hosted +/// by" mark; the spinner is CSS-only (disabled under `prefers-reduced-motion`). +/// Light/dark theming via `prefers-color-scheme` with a `data-theme` override, +/// using the brand's Bark/Bone/Ember dark palette. Fully self-contained (no +/// external fonts, images, or stylesheets; the logo is inlined into the served +/// HTML), so it renders identically under the pinned page's strict +/// `default-src 'none'` CSP. The stylesheet lives in `assets/connect.css` and is +/// compiled into the binary via `include_str!` (no runtime file I/O), so it is +/// authored as a real `.css` file rather than a Rust string literal. The pinned +/// page serves it in a `