diff --git a/README.md b/README.md index 935a196..0d6bcf1 100644 --- a/README.md +++ b/README.md @@ -12,13 +12,14 @@ encoding/decoding and signing against the IC via 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` and `icp_create_canister` return `icp` CLI instructions -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. +mechanically (`canister_update_call` rejects the ICRC-1/ICRC-2 and related +ledger-standard transfer/approval methods on every canister), and the +connector serves no funding or management tools: creating, funding, and topping up +canisters is done by the user with the +[`icp` CLI](https://github.com/dfinity/icp-cli) in their own terminal. +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 @@ -147,9 +148,6 @@ 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. 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 | | `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` | @@ -157,17 +155,8 @@ results). | `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` | -| `icp_list_skills` | — | The official [IC skills](https://skills.internetcomputer.org) (Motoko, mops/icp CLIs, cycles, stable memory, security, …), grouped by category | -| `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 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 | -| `icp_start_canister` / `icp_stop_canister` / `icp_uninstall_code` / `icp_delete_canister` | `canister_id` | Canister lifecycle | -| `icp_top_up_canister` | `canister_id`, `cycles?` / `icp?` | **Instructions only — nothing is executed and no funds move.** Returns the step-by-step [`icp` CLI](https://github.com/dfinity/icp-cli) commands — including where to get the CLI — for the user to add cycles to a canister themselves; `cycles`/`icp` are substituted into the printed commands. Provided for completeness, to reflect the platform capability | `open_app` (its `app` argument takes a name **or** a URL) is the one-call entry point when the user names or links an app: it resolves the Internet Identity @@ -196,8 +185,8 @@ Acting **for the user** at an app: 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 (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). + **`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 remember it). 4. **`get_app_principal`** — only when you need the principal *value* itself; @@ -217,9 +206,8 @@ Genuinely public reads via a `canister_query` Candid `method` query or the public-metadata tools (`get_canister_candid`, `discover_app_canisters`) skip steps 3/4 and need no origin; OQL reads always require one. The per-canister inspection (5) is independent of the identity steps (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 this server's origin — a *different* identity than the -per-app principals above. +**own** canisters is not part of this connector: create and manage them with the +[`icp` CLI](https://github.com/dfinity/icp-cli) in your own terminal. ### App-declared canister metadata (App Connect) @@ -271,15 +259,10 @@ above). It is the only authoritative way for `open_app` / `resolve_app` to learn so an app that uses one should declare it here; otherwise the connector assumes the derivation origin equals the application origin and flags that assumption. -When the user names a **token, project, or service** (e.g. `ckUSDC`) rather than a -website or id, `icp_find_canister_by_name` resolves it via -[`dashboard.internetcomputer.org`](https://dashboard.internetcomputer.org)'s public -APIs — the ICRC token registry and the SNS catalog — to the matching canister id(s). -`icp_lookup_canister_info_by_id` goes the other way: given a bare id, it returns the dashboard's -label, type, controllers, subnet, and module hash, so a raw principal becomes an -identified service. (`discover_app_canisters` results are annotated with these labels -inline.) There is no public name-search over arbitrary canisters, so `icp_find_canister_by_name` -covers the IC's labelled services, which is where the meaningful ones live. +When the user names a **token, project, or service** rather than a website or +id, web search the canister id or ask the user for it. +(`discover_app_canisters` results are annotated with the dashboard's labels +inline.) `canister_query` and `canister_update_call` run anonymously by default; pass a `derivation_origin` to call as @@ -335,15 +318,15 @@ token (see Auth). ### Skills awareness -`icp_list_skills` / `icp_get_skill` expose the official Internet Computer +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, -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 -listed as MCP **resources** (`skill://`) alongside the `candid://` -references. Override the registry origin with `SKILLS_URL`. +auth, …) — are served as MCP **resources** (`skill://`) alongside the +`candid://` references. 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. +Override the registry origin with `SKILLS_URL`. ### OQL query surfaces @@ -389,36 +372,6 @@ erroring. The design mirrors the reference IC connector's OQL primer (detect + teach), adding an ergonomic executor suited to this server's structured-output conventions. -### Creating & managing canisters - -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, 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 -compiled Wasm as base64/hex and uploads it via the chunk store automatically when -it exceeds the single-message limit. - -Together these make the end-to-end flow work: *"create a Motoko canister that does -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 Add the server to Claude Code (replace the URL with wherever it's hosted): @@ -434,9 +387,10 @@ the tools become available. All clients use the same **authorization-code + PKCE flow. > II's consent screen makes you choose an access level: **"Questions only"** or -> **"Actions & questions"**. Choose **Actions & questions** if you want to create -> or manage canisters — a Questions-only session makes every management tool -> inert (see [Read-only sessions](#read-only-sessions) below). +> **"Actions & questions"**. Choose **Actions & questions** if you want the +> agent to make state-changing calls (`canister_update_call`) — a +> Questions-only session rejects them at ingress (see +> [Read-only sessions](#read-only-sessions) below). ## Run @@ -848,9 +802,8 @@ can cost in memory and disk, not the request rate. Put a rate limiter in front II's consent screen requires an explicit access-level choice: **"Questions only"** or **"Actions & questions"**. A user who picks Questions only gets a session whose per-app delegations are `permissions = "queries"`, -and the IC **rejects update calls made through them at ingress**. That makes the -entire canister-management surface inert — `create`/`install`/`start`/`stop`/ -`uninstall`/`delete`, and even `icp_canister_status`, are update calls. To handle this +and the IC **rejects update calls made through them at ingress**. That makes +`canister_update_call` inert. To handle this without opaque low-level errors: - The `mcp_register_v2` reply carries `permissions: "queries" | "all"`, so the diff --git a/crates/imcp2-core/src/compliance.rs b/crates/imcp2-core/src/compliance.rs index b2abffb..2166895 100644 --- a/crates/imcp2-core/src/compliance.rs +++ b/crates/imcp2-core/src/compliance.rs @@ -91,9 +91,8 @@ 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; icp_create_canister is instructions-only (it prints - // those steps and executes nothing), so naming it does not offer an - // executing route (per review). + // with the icp CLI directly (the dedicated instructions-only creation + // tool is deferred from this version's served surface). (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,9 +124,9 @@ 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 — 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 \ + 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 \ + skill://icp-cli and skill://cycles-management resources carry the \ full guide." } else { "Recommend that the user performs this operation themselves, in a wallet \ @@ -219,16 +218,17 @@ 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. 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). + // with the icp CLI (install pointer included) — never at a wallet (the + // wrong venue for creation) and never at a connector tool (the dedicated + // instructions-only creation tool is deferred from this version). #[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("executes nothing"), "{msg}"); + assert!(msg.contains("npm install -g @icp-sdk/icp-cli"), "{msg}"); + assert!(!msg.contains("icp_create_canister"), "no connector-tool redirect: {msg}"); assert!(!msg.contains("oisy.com"), "a wallet is the wrong venue for creation: {msg}"); } } diff --git a/crates/imcp2-core/src/identities.rs b/crates/imcp2-core/src/identities.rs index 891110d..57caafa 100644 --- a/crates/imcp2-core/src/identities.rs +++ b/crates/imcp2-core/src/identities.rs @@ -450,10 +450,10 @@ pub struct AccountsOutput { #[derive(Debug, Deserialize, schemars::JsonSchema)] pub struct ResolveAppArgs { /// The application's URL, e.g. "https://oisy.com". Must be a URL you actually - /// have — given by the user, returned by `icp_find_app_by_name`, or found by a - /// web search of the app's official site. NEVER a domain guessed from an app's - /// name (guessed lookalike domains are unrelated or squatted sites and are - /// refused). + /// have — given by the user, shown by `open_app` for a well-known app name, or + /// found by a web search of the app's official site. NEVER a domain guessed + /// from an app's name (guessed lookalike domains are unrelated or squatted + /// sites and are refused). pub app_url: String, } diff --git a/crates/imcp2-core/src/lib.rs b/crates/imcp2-core/src/lib.rs index 870939d..1a74aad 100644 --- a/crates/imcp2-core/src/lib.rs +++ b/crates/imcp2-core/src/lib.rs @@ -1,12 +1,14 @@ //! 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, 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 -//! `imcp2` server, stdio in a local one). +//! * [`tools::IcTools`] — the served MCP tool surface: [`tools::IcCanisterTools`] +//! (canister reads and writes in textual Candid, app/canister discovery, +//! OQL), an [`rmcp`] `ServerHandler` ready to serve over whichever +//! transport the binary picks (streamable-HTTP in the hosted `imcp2` +//! server, stdio in a local one). The protocol/meta half +//! ([`tools::IcProtocolTools`] — dashboard lookups, the IC skills tools, +//! canister management) is deferred from the default composition; we +//! anticipate it will come in a future version. //! * [`identities`] — the Internet Identity session engine: the per-connection //! session-key grant (registered with II via `mcp_register_v2`) and the //! short-lived per-app account delegations minted from it on demand. diff --git a/crates/imcp2-core/src/skills.rs b/crates/imcp2-core/src/skills.rs index 27358e1..b6941d6 100644 --- a/crates/imcp2-core/src/skills.rs +++ b/crates/imcp2-core/src/skills.rs @@ -209,7 +209,7 @@ impl SkillsCatalog { let entry = skills.iter().find(|s| s.name.eq_ignore_ascii_case(name)); if entry.is_none() { return Err(format!( - "no skill named `{name}` — call icp_list_skills to see the available skills" + "no skill named `{name}` — list the `skill://` resources to see the available skills" )); } // Use the manifest's markdown URL only when it stays on the configured diff --git a/crates/imcp2-core/src/tools.rs b/crates/imcp2-core/src/tools.rs index 0a0d250..6697bb2 100644 --- a/crates/imcp2-core/src/tools.rs +++ b/crates/imcp2-core/src/tools.rs @@ -51,9 +51,9 @@ const OQL_PRIMER_MD: &str = include_str!("../static/oql-primer.md"); /// 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`]. +/// OQL reads (with `icp_oql_guide`, the dialect guide those reads use), +/// update calls, app resolution/discovery, and the per-app identity tools. +/// This is the surface [`IcTools`] serves in this version. #[derive(Clone)] pub struct IcCanisterTools { agent: Agent, @@ -63,10 +63,15 @@ pub struct IcCanisterTools { 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`]. +/// The IC protocol / meta-level tools: dashboard name/id lookups, the +/// official IC skills, and canister creation/management as the standing +/// management principal. +/// +/// NOT served by the default [`IcTools`] composition in this version — we +/// anticipate this will come in a future version. Until then the type, its +/// tools, and their tests stay ready, and an embedder that wants the group +/// today can construct it with [`IcProtocolTools::new`] and serve its +/// `tool_router()` itself. #[derive(Clone)] pub struct IcProtocolTools { identities: Identities, @@ -76,17 +81,21 @@ pub struct IcProtocolTools { 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. +/// The served MCP tool surface: [`IcCanisterTools`] behind one +/// [`ServerHandler`], plus the candid/OQL/skill resources. The IC protocol / +/// meta-level half ([`IcProtocolTools`]) is split out and deliberately NOT +/// served in this version — we anticipate this will come in a future version; +/// re-enabling it means composing its router back into `all_tools`, +/// `call_tool`, and `get_tool` here (or dispatching [`IcProtocolTools`]'s +/// router from an embedder's own [`ServerHandler`] — the type has no handler +/// of its own but stays fully usable as library code). #[derive(Clone)] pub struct IcTools { canister: IcCanisterTools, - protocol: IcProtocolTools, + /// Backs the `skill://` resources (the skills TOOLS live on the deferred + /// [`IcProtocolTools`]; the documents themselves stay served as + /// resources). + skills: skills::SkillsCatalog, } /// The one seam between deployments: how a tool call finds the Internet @@ -111,27 +120,40 @@ impl IcTools { Self { canister: IcCanisterTools { agent, - identities: identities.clone(), - session: session.clone(), - }, - protocol: IcProtocolTools { identities, - skills, session, }, + skills, } } - /// Every tool on the served surface — the app/canister tools first, then - /// the protocol/meta tools — exactly the list `tools/list` returns. + /// Every tool on the served surface — the app/canister tools; exactly the + /// list `tools/list` returns. The protocol/meta tools are deferred (we + /// anticipate they will come in a future version) and are NOT listed. pub fn all_tools() -> Vec { - let mut tools = IcCanisterTools::tool_router().list_all(); - tools.extend(IcProtocolTools::tool_router().list_all()); - tools + IcCanisterTools::tool_router().list_all() } } -#[tool_router] +impl IcProtocolTools { + /// Build the protocol/meta half on its own. The default [`IcTools`] + /// composition does not serve these tools in this version (we anticipate + /// this will come in a future version); an embedder that wants the group + /// today can construct it here and serve its `tool_router()` alongside. + pub fn new( + identities: Identities, + skills: skills::SkillsCatalog, + session: SessionResolver, + ) -> Self { + Self { + identities, + skills, + session, + } + } +} + +#[tool_router(vis = "pub")] impl IcCanisterTools { /// The already-validated session id this tool call acts as, per the /// embedding binary's [`SessionResolver`]. @@ -163,7 +185,7 @@ impl IcCanisterTools { // set, the guidance pointer is emitted as a SEPARATE content // block, so the first block stays the raw `.did` (still valid, // copy-pastable Candid) — the full primer is served on demand - // (see icp_oql_guide / OQL_USAGE_URI), never inlined. + // (see the OQL_USAGE_URI resource), never inlined. let oql = calls::has_oql(&did); // Same predicate get_canister_api_doc uses, so the flag gates that // call: true means a `getApiDoc`/`get_api_doc` method exists. @@ -214,6 +236,19 @@ impl IcCanisterTools { } } + #[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), @@ -900,15 +935,15 @@ impl IcCanisterTools { Err(e) => return Ok(err(format!("could not derive principal for {}: {e}", target.origin))), }; // Surface a query-only session (H2) so the LLM won't attempt (and have the - // IC reject at ingress) canister-management updates. + // IC reject at ingress) state-changing update calls. let read_only = self.identities.is_read_only(&session_id).await == Some(true); let mut text = format!("{principal}\n\n[{}]", identity_annotation(&target, None)); if read_only { text.push_str( "\n\n(This Internet Identity session was authorized for \"Questions only\": reads work, \ - but canister management — create/install/start/stop/delete, and icp_canister_status — \ - needs update access. Ask the user to reconnect and choose \"Actions & questions\" on \ - Internet Identity's consent screen.)", + but state-changing calls (canister_update_call) are rejected by the network. Ask the \ + user to reconnect and choose \"Actions & questions\" on Internet Identity's consent \ + screen.)", ); } let output = identities::PrincipalOutput { @@ -959,7 +994,7 @@ impl IcCanisterTools { } #[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 (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).", + 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 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: 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::(), )] @@ -1102,7 +1137,7 @@ impl IcCanisterTools { } #[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, 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 open_app's known-app resolution, 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 open_app with the 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::(), )] @@ -1156,7 +1191,7 @@ impl IcCanisterTools { } #[tool( - description = "Discover the Internet Computer canisters behind a web domain (e.g. \"oisy.com\"). The domain must be one you actually have (from the user, icp_find_app_by_name, or a web search) — NEVER a domain guessed from an app's name; when you only know a NAME, call icp_find_app_by_name first. Returns every canister id found, with provenance, most authoritative first: app-declared metadata — the App Connect page's `ic:canister-id` meta at /ai-connect.html (the app's MAIN backend) and the app's own /.well-known/ic-app.json manifest (ALL its canisters, with roles) — then the `x-ic-canister-id` header (the frontend/asset canister), a `/env.json` runtime config (e.g. backend_canister_id), and labelled/bare canister-id literals mined from the JS bundle. App-declared entries are the app's own claim about itself; env.json/bundle entries are mined candidates: pick by label (prefer production/IC ids) and confirm with get_canister_candid before calling.", + description = "Discover the Internet Computer canisters behind a web domain (e.g. \"oisy.com\"). The domain must be one you actually have (from the user, open_app's known-app resolution, or a web search) — NEVER a domain guessed from an app's name; when you only know a NAME, call open_app with the name first. Returns every canister id found, with provenance, most authoritative first: app-declared metadata — the App Connect page's `ic:canister-id` meta at /ai-connect.html (the app's MAIN backend) and the app's own /.well-known/ic-app.json manifest (ALL its canisters, with roles) — then the `x-ic-canister-id` header (the frontend/asset canister), a `/env.json` runtime config (e.g. backend_canister_id), and labelled/bare canister-id literals mined from the JS bundle. App-declared entries are the app's own claim about itself; env.json/bundle entries are mined candidates: pick by label (prefer production/IC ids) and confirm with get_canister_candid before calling.", annotations(title = "Discover canisters behind a domain", read_only_hint = true, destructive_hint = false, open_world_hint = true), output_schema = schema_for_output::(), )] @@ -1214,7 +1249,7 @@ impl IcCanisterTools { } text.push_str( " If you GUESSED this domain from an app name, don't guess again: call \ - icp_find_app_by_name with the name, or WEB SEARCH the app's official URL, \ + open_app with the exact name, or WEB SEARCH the app's official URL, \ or ask the user for it.", ); let output = discover::DiscoverOutput::from((domain, d)); @@ -1229,7 +1264,7 @@ impl IcCanisterTools { } -#[tool_router] +#[tool_router(vis = "pub")] impl IcProtocolTools { /// The already-validated session id this tool call acts as, per the /// embedding binary's [`SessionResolver`]. @@ -1237,19 +1272,6 @@ impl IcProtocolTools { (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), @@ -1758,7 +1780,7 @@ fn app_url_error_with_guidance(app_url: &str, e: String) -> String { } msg.push_str( " If you arrived at this URL by guessing from an app name, don't guess again — call \ - icp_find_app_by_name with the name, WEB SEARCH the app's official URL, or ask the user.", + open_app with the name, WEB SEARCH the app's official URL, or ask the user.", ); msg } @@ -1787,7 +1809,7 @@ fn unverified_app_url_error(application_origin: &str) -> String { msg.push_str( "If you arrived at this URL by GUESSING from an app name, stop guessing — a lookalike \ domain is an unrelated or squatted site, and an identity derived there is wrong: \ - (1) call icp_find_app_by_name with the app's name (well-known apps resolve offline); \ + (1) call open_app with the app's name (well-known apps resolve offline); \ (2) if it's not known, WEB SEARCH the app's official URL; (3) or ask the user for it. \ If this origin genuinely IS the app's canonical Internet Identity origin (e.g. a \ non-IC-hosted app the user pointed you at), pass it explicitly as `derivation_origin` \ @@ -1808,7 +1830,7 @@ fn resolution_note(resolved: &discover::AppIdentity, effective: &str) -> Option< format!( " CAUTION: this host RESEMBLES the well-known app {} but is not one of its real \ origins — {}'s URL is {} (derivation origin {}). If you meant {}, use that URL; \ - if you guessed this URL from the app's name, use icp_find_app_by_name instead of \ + if you guessed this URL from the app's name, use open_app with the name instead of \ guessing.", m.name, m.name, m.app_url, m.derivation_origin, m.name ) @@ -1967,16 +1989,17 @@ fn identity_annotation(target: &IdentityTarget, acted_as: Option<&str>) -> Strin /// 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 \ - guide, and canister creation/management); `…_app…` names \ + the binary form. Tool names signal SCOPE: `…_app…` names \ (`open_app`, `discover_app_canisters`, `get_app_principal`, `list_app_accounts`, `resolve_app`) \ - act on a whole APP, keyed by its Internet Identity derivation origin or app URL; and \ + act on a whole APP, keyed by its Internet Identity derivation origin or app URL, and \ `…canister…` names (`get_canister_candid`, `get_canister_api_doc`, \ `get_canister_oql_schema`, `canister_query`, `canister_update_call`) act on ONE \ - specific canister. Before writing Candid args, consult the `candid://textual-syntax` \ + specific canister; `icp_oql_guide` serves the OQL dialect those reads use. \ + 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\ + reference, and the official IC \ + skills are served as `skill://` resources.\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 \ @@ -1995,13 +2018,11 @@ const SERVER_INSTRUCTIONS: &str = "Internet Computer tools. Every tool speaks TE discover_app_canisters. (open_app bundles `resolve_app` + `discover_app_canisters`; use \ those directly only for a single step.) RULE — names are not URLs: NEVER guess or \ fabricate a domain from an app's name (e.g. .com/.app); pass the NAME to open_app \ - (or `icp_find_app_by_name`) and let the connector resolve it, WEB SEARCH the official \ + and let the connector resolve it, WEB SEARCH the official \ URL, or ask the user. Lookalike domains are unrelated or squatted sites, and open_app / \ every URL-taking tool REFUSES an origin that shows no evidence of being an Internet \ - Computer app instead of resolving it to a wrong identity. When the user names a TOKEN, \ - PROJECT or SERVICE (e.g. \"ckUSDC\"), use `icp_find_canister_by_name` for its canister \ - id; `icp_lookup_canister_info_by_id(id)` tells you what a bare canister id IS (dashboard \ - label, type, controllers, subnet).\n\n\ + Computer app instead of resolving it to a wrong identity. (For a token/service \ + name or a canister id you don't have, web search or ask the user.)\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 open orders\") reads data the app gates \ by the CALLER's principal. An OQL read (get_canister_oql_schema, and canister_query \ @@ -2062,15 +2083,14 @@ const SERVER_INSTRUCTIONS: &str = "Internet Computer tools. Every tool speaks TE connecting (up to 30 days); reconnect when it expires. \ Internet Identity's consent screen asks the user to choose an access level, \ \"Questions only\" or \"Actions & questions\". On a Questions-only session reads work, but \ - the canister-management tools below make update calls the network rejects — if one fails \ + state-changing calls (canister_update_call) are rejected by the network — if one fails \ 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 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, \ - `resolve_app(url)` the origin, `discover_app_canisters(url)` the canisters; (3) \ + name→URL directory). If you want just one part, \ + `resolve_app(url)` does the origin, `discover_app_canisters(url)` the canisters; (3) \ `list_app_accounts` — if there is more than one account, ask which to use and remember \ it; (4) `get_app_principal` ONLY when you need the principal value itself (`canister_query` / \ `canister_update_call` act as the account without pre-fetching it); (5) inspect the \ @@ -2084,29 +2104,15 @@ const SERVER_INSTRUCTIONS: &str = "Internet Computer tools. Every tool speaks TE user. Public metadata (get_canister_candid, discover_app_canisters) and public \ 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_` 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 \ + run in parallel.\n\n\ + To AUTHOR and BUILD IC code, first consult the official IC skills, served as \ + `skill://` resources — especially \ `writing-motoko` (language), `mops-cli` (deps/build), `icp-cli` (build & deploy), \ `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 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`; \ - `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`."; + Motoko/Rust to Wasm happens in YOUR environment (guided by those skills), and \ + CREATING, FUNDING, DEPLOYING and MANAGING canisters is done by the USER with the icp \ + CLI in their own terminal, guided by the same skills."; impl ServerHandler for IcTools { async fn list_tools( @@ -2125,23 +2131,16 @@ impl ServerHandler for IcTools { 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 - } + // Only the canister/app router is served in this version (the + // protocol/meta tools are deferred); a miss produces the router's + // standard "tool not found" (invalid-params) error — the same shape + // any unknown tool name gets. + let tcc = ToolCallContext::new(&self.canister, request, context); + IcCanisterTools::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()) + IcCanisterTools::tool_router().get(name).cloned() } fn get_info(&self) -> ServerInfo { @@ -2168,7 +2167,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.protocol.skills.list().await { + if let Ok(skills) = self.skills.list().await { for s in skills { let title = if s.title.is_empty() { format!("IC skill: {}", s.name) @@ -2194,7 +2193,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.protocol.skills.get(name).await { + return match self.skills.get(name).await { Ok(md) => Ok(ReadResourceResult::new(vec![ResourceContents::text( md, request.uri, @@ -2470,8 +2469,13 @@ 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::all_tools(); - assert_eq!(tools.len(), 26, "expected 26 tools, got {}", tools.len()); + let served = super::IcTools::all_tools(); + assert_eq!(served.len(), 11, "expected 11 served tools, got {}", served.len()); + // The deferred protocol half keeps its annotation contracts too, so + // wiring it back in a future version can't regress them. + let mut tools = served; + tools.extend(super::IcProtocolTools::tool_router().list_all()); + assert_eq!(tools.len(), 26, "expected 26 tools across both halves, got {}", tools.len()); assert!( tools.iter().all(|t| t.annotations.is_some()), "every tool must carry annotations (else clients assume write/destructive)" @@ -2554,7 +2558,8 @@ mod tests { // Regression guard for that boundary. #[test] fn the_core_router_carries_no_local_login_tools() { - let tools = super::IcTools::all_tools(); + let mut tools = super::IcTools::all_tools(); + tools.extend(super::IcProtocolTools::tool_router().list_all()); for name in ["authenticate", "auth_status"] { assert!( tools.iter().all(|t| &*t.name != name), @@ -2563,18 +2568,42 @@ 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. + // The user-facing contract of THIS version: only the canister/app tools + // are served; the protocol/meta group is deferred, and the instructions + // describe the current surface only (per review: what isn't in this + // version isn't mentioned). + #[test] + fn the_default_composition_defers_the_protocol_tools() { + let served = super::IcTools::all_tools(); + assert_eq!(served.len(), 11, "{:?}", served.iter().map(|t| &t.name).collect::>()); + // icp_oql_guide is the one icp_-named tool that stays served: it is + // part of the canister OQL read flow (guide → schema → query). + assert!(served + .iter() + .all(|t| !t.name.starts_with("icp_") || &*t.name == "icp_oql_guide")); + assert!(served.iter().any(|t| &*t.name == "icp_oql_guide")); + assert_eq!(super::IcProtocolTools::tool_router().list_all().len(), 15); + // tools/call routes through the canister router alone, so a deferred + // name is not just unlisted — it is not routable at all. + assert!(!super::IcCanisterTools::tool_router().has_route("icp_get_skill")); + assert!(!super::IcCanisterTools::tool_router().has_route("icp_install_code")); + assert!(!super::SERVER_INSTRUCTIONS.contains("future version")); + } + + // The tool surface is split by scope: the protocol / meta-level tools + // live on IcProtocolTools, everything app-/canister-scoped on + // IcCanisterTools — no overlap, nothing dropped: every definition lives + // on exactly one router, while tools/list serves the canister half alone + // in this version. icp_oql_guide is the one icp_-named exception + // on the canister side: it serves the OQL dialect the canister read flow + // (guide → schema → query) depends on, so it belongs with those tools. #[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_"), + !t.name.starts_with("icp_") || &*t.name == "icp_oql_guide", "{} is icp_-prefixed and belongs on IcProtocolTools", t.name ); @@ -2586,12 +2615,17 @@ mod tests { t.name ); } + // In this version the served surface is the canister half alone: the + // protocol tools are deferred (we anticipate a future version serves + // them again), but stay intact and non-overlapping for the return. 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(); + assert_eq!(all.len(), canister.len()); + let mut names: Vec<&str> = + canister.iter().map(|t| &*t.name).chain(protocol.iter().map(|t| &*t.name)).collect(); names.sort_unstable(); + let total = names.len(); names.dedup(); - assert_eq!(names.len(), all.len(), "tool names must be unique across the split routers"); + assert_eq!(names.len(), total, "tool names must be unique across the split routers"); } // icp_top_up_canister and icp_create_canister are instructions-only, and @@ -2601,7 +2635,7 @@ mod tests { // Guards against a future edit quietly reverting either. #[test] fn top_up_tool_declares_itself_instructions_only() { - let tools = super::IcTools::all_tools(); + let tools = super::IcProtocolTools::tool_router().list_all(); let tool = tools .iter() .find(|t| &*t.name == "icp_top_up_canister") @@ -2614,7 +2648,7 @@ mod tests { #[test] fn create_tool_declares_itself_instructions_only() { - let tools = super::IcTools::all_tools(); + let tools = super::IcProtocolTools::tool_router().list_all(); let tool = tools .iter() .find(|t| &*t.name == "icp_create_canister") @@ -2631,7 +2665,8 @@ 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::all_tools(); + let mut tools = super::IcTools::all_tools(); + tools.extend(super::IcProtocolTools::tool_router().list_all()); for t in &tools { let schema = t .output_schema @@ -2649,7 +2684,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::all_tools(); + let tools = super::IcProtocolTools::tool_router().list_all(); let tool = tools .iter() .find(|t| &*t.name == "icp_find_canister_by_name") @@ -2836,14 +2871,14 @@ mod tests { // The exact wild-observed guess: multidex.com for MULTI/DEX (multidex.ai). let msg = super::unverified_app_url_error("https://multidex.com"); assert!(msg.contains("NO evidence"), "{msg}"); - assert!(msg.contains("icp_find_app_by_name"), "{msg}"); + assert!(msg.contains("open_app"), "{msg}"); assert!(msg.contains("WEB SEARCH"), "{msg}"); assert!(msg.contains("DID YOU MEAN MULTI/DEX"), "{msg}"); assert!(msg.contains("https://multidex.ai"), "{msg}"); assert!(msg.contains("`derivation_origin`"), "escape hatch missing: {msg}"); // A host resembling no known app still gets the redirect, just no repair. let plain = super::unverified_app_url_error("https://example.com"); - assert!(plain.contains("icp_find_app_by_name"), "{plain}"); + assert!(plain.contains("open_app"), "{plain}"); assert!(!plain.contains("DID YOU MEAN"), "{plain}"); } @@ -2859,11 +2894,11 @@ mod tests { assert!(msg.contains("could not resolve"), "{msg}"); assert!(msg.contains("DID YOU MEAN MULTI/DEX"), "{msg}"); assert!(msg.contains("https://multidex.ai"), "{msg}"); - assert!(msg.contains("icp_find_app_by_name"), "{msg}"); + assert!(msg.contains("open_app"), "{msg}"); // Unrelated hosts: guidance without a bogus suggestion. let plain = super::app_url_error_with_guidance("example.com", "timeout".to_string()); assert!(!plain.contains("DID YOU MEAN"), "{plain}"); - assert!(plain.contains("icp_find_app_by_name"), "{plain}"); + assert!(plain.contains("open_app"), "{plain}"); } // The human-readable identity annotation must surface a requested≠derived diff --git a/crates/imcp2-local/src/server.rs b/crates/imcp2-local/src/server.rs index 11946ab..2cb8b76 100644 --- a/crates/imcp2-local/src/server.rs +++ b/crates/imcp2-local/src/server.rs @@ -383,8 +383,8 @@ mod tests { "authenticate must declare its refresh flag: {schema}" ); // The merged surface keeps the core contract: every tool declares an - // object-rooted outputSchema (the core suite enforces it for the 26; - // this enforces it for the two added here). + // object-rooted outputSchema (the core suite enforces it for the core + // tools; this enforces it for the two added here). for t in &tools { let schema = t .output_schema @@ -410,7 +410,7 @@ mod tests { // A REAL MCP round-trip over an in-process duplex pipe (the same // byte-stream shape stdio serves): initialize; tools/list is the merged - // surface (all 26 core tools + the 2 login tools); tools/call dispatches + // surface (the 11 served core tools + the 2 login tools); tools/call dispatches // login tools to the wrapper and everything else to IcTools; the login // lifecycle (signed out → link → pending) runs through the MCP layer — // all with no network and no browser. @@ -440,8 +440,8 @@ mod tests { let tools = client.list_all_tools().await.expect("tools/list"); assert_eq!( tools.len(), - 28, - "26 core tools + authenticate + auth_status" + 13, + "11 served core tools + authenticate + auth_status" ); for expected in [ "get_canister_candid", @@ -509,6 +509,27 @@ mod tests { assert!(is_error, "an invalid canister id is a tool error"); assert!(text.contains("invalid canister id"), "{text}"); + // Deferral: a protocol/meta tool is not just unlisted — calling it + // fails with the router's standard "tool not found" error (the + // invalid-params shape any unknown tool name gets), rather than + // running — and rather than dying some other way: a disconnect or a + // different error must not pass this regression. + let mut params = rmcp::model::CallToolRequestParams::new("icp_get_skill"); + params.arguments = serde_json::json!({ "name": "writing-motoko" }) + .as_object() + .cloned(); + let err = client + .call_tool(params) + .await + .expect_err("deferred protocol tools must not be callable"); + match err { + rmcp::ServiceError::McpError(e) => { + assert_eq!(e.code, rmcp::model::ErrorCode::INVALID_PARAMS, "{e:?}"); + assert!(e.message.contains("tool not found"), "{e:?}"); + } + other => panic!("expected the router's tool-not-found error, got {other:?}"), + } + client.cancel().await.expect("client shutdown"); server.cancel().await.expect("server shutdown"); } diff --git a/docs/anthropic-directory-submission.md b/docs/anthropic-directory-submission.md index 1d3adc3..df07360 100644 --- a/docs/anthropic-directory-submission.md +++ b/docs/anthropic-directory-submission.md @@ -47,8 +47,8 @@ Verified against the live production deployment (2026-07-31): | Claude's hosted callback `https://claude.ai/api/mcp/auth_callback` accepted | ✅ seeded in the redirect allow-list ([`src/auth.rs`](../src/auth.rs), `DEFAULT_ALLOWED_REDIRECTS`) | | 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 | ✅ 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 | +| Every tool: `title` + `readOnlyHint`/`destructiveHint` (+ `idempotentHint`, `openWorldHint`) | ✅ on all 11 tools, enforced by a unit test ([`crates/imcp2-core/src/tools.rs`](../crates/imcp2-core/src/tools.rs)) | +| No catch-all read/write tool; reads and writes are separate tools | ✅ 10 of the 11 tools are read-only; the one write is `canister_update_call`. | | 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 | @@ -145,17 +145,12 @@ transactions**, and the portal's compliance step requires acknowledging this. 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 - 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)). +- **The connector has no funding or management tools.** The execution paths + that once moved funds are removed from the binary + ([#153](https://github.com/dfinity/imcp2/pull/153) / + [#154](https://github.com/dfinity/imcp2/pull/154)). Creating, funding, and + managing canisters is done by the user with the icp CLI in their own + terminal. - `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 @@ -171,9 +166,10 @@ both are merged):** **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. +Financial ledger methods are refused, and no funding or management tools are +served at all — users run those operations themselves with the icp CLI. 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 @@ -204,10 +200,10 @@ One tension to be ready for: the review criteria say *"Test credentials are required and must be a fully populated account."* Self-serve sign-up is a reasonable answer for an authentication system nobody can pre-provision into, but a reviewer may still ask for a populated account — most plausibly to -exercise the canister-management tools, which need an identity that actually -controls a canister and holds a cycles balance. If that comes back, the -fallback is a dedicated identity with a recovery phrase in the team vault, a -canister it controls, and a small cycles balance. +exercise `canister_update_call` against an app where the identity has data. +If that comes back, the fallback is a dedicated identity with a recovery +phrase in the team vault and an account at a demo app. (No controlled canister or +cycles balance is needed: the connector has no canister-management tools.) ### 4. Production is behind `main` @@ -254,11 +250,9 @@ Paste-and-adapt; portal limits in parentheses. > canister is, fetch its Candid interface, read app data via typed queries > 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. 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. + > specific app. Financial transactions are not supported: token-ledger + > transfer and approval methods are refused to protect you, and there are + > no funding or canister-management tools. > > On the Internet Identity consent screen you explicitly choose the session > duration (10 minutes to 30 days) and the access level: "Questions only" @@ -299,10 +293,11 @@ 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 gftcp-myaaa-aaaar-qcaaa-cai? Who controls it and - what's its interface?"* + 1. *"What interface does canister gftcp-myaaa-aaaar-qcaaa-cai expose, + and what can it do?"* 2. *"Open opencloud.org and list my accounts there."* - 3. *"How do I add cycles to canister gftcp-myaaa-aaaar-qcaaa-cai?"* + 3. *"Does the canister behind https://opencloud.org expose an API doc, and + what does its interface look like?"* 4. *"What canisters are behind https://opencloud.org, and which one holds the app's data?"* @@ -315,17 +310,19 @@ 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 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 -> error — that behavior is intended. Access is revocable at any time at +> "Questions only" exercises the 10 read-only-annotated tools; "Actions & +> questions" additionally allows state-changing calls +> (`canister_update_call`). +> 3. Try the example prompts above. On a Questions-only session a +> state-changing call (`canister_update_call`) is rejected by the +> network and the tool reports the failed call; the server +> instructions prime the assistant to explain the access level and +> recommend reconnecting under "Actions & questions" — 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, 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. +> 4. Canister-management tools are not part of this connector, so there is +> nothing to provision: creating and managing canisters happens outside +> the connector, with the icp CLI. > 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 @@ -337,15 +334,16 @@ Topics: directory guidelines, first-party API usage, financial transactions, AI media generation, prompt injection, conversation-data collection, public 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. +methods are refused, and no funding or management tools are served (users run +those operations with the icp CLI). **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 +instructions, but the `skill://` +resources intentionally return DFINITY-published how-to documents fetched live from `skills.internetcomputer.org` at the user's request — describe this in the submission so reviewers see documented, user-requested functionality rather than covertly pulled behavioral instructions. The rest @@ -356,7 +354,7 @@ conversation beyond tool arguments and generates no media. - [ ] Dedicated ICP MCP privacy policy live at `https://mcp.internetcomputer.org/privacy-policy` (page + landing-page link merged; needs the production release) and entered in the portal (blocker 1) - [ ] Reply received from mcp-review@anthropic.com settling the financial-transactions and first-party-API acknowledgments (asked 2026-07-31; blocker 2) -- [x] Reviewer access settled: self-serve Internet Identity, instructions in the test-credentials field (blocker 3) — provision a funded identity only if a reviewer asks +- [x] Reviewer access settled: self-serve Internet Identity, instructions in the test-credentials field (blocker 3) — if a reviewer asks for a populated account, provision a demo-app account (no funding needed: there are no funding or canister-management tools) - [ ] `release-*` tag cut; `/version` on production shows the intended commit (blocker 4) - [x] Square PNG icon exported — `docs/assets/icp-logo-{1024,512}.png` (blocker 5) - [ ] Every tool exercised once by the submitter (portal asks you to confirm this; MCP Inspector or a custom connector in Claude both count) diff --git a/docs/icp-mcp-privacy-policy-draft.md b/docs/icp-mcp-privacy-policy-draft.md index 922cc38..deb6184 100644 --- a/docs/icp-mcp-privacy-policy-draft.md +++ b/docs/icp-mcp-privacy-policy-draft.md @@ -138,9 +138,7 @@ keeps, for the remainder of the session: the application's domain, the account number used, the per-application key it generated, and Internet Identity's signed authorization for that application (itself valid for at most one hour). Keeping this avoids re-deriving an authorization on every -call to the same application. The Service's own origin is treated as one -such application when you use the canister-management tools, and the -identity derived for it is stable across your connections. All of this is +call to the same application. All of this is held in memory only, bounded in size, and discarded when the session ends or the Service restarts. Legal basis: performance of the service you requested. @@ -289,10 +287,11 @@ Internet Identity gives each application a different identity for you, so applications cannot recognise you across applications. The Service is a participant in that design and we want to be plain about what it can see. While a session is live it holds, in memory, the per-application identities -it has derived for you, including a stable identity for you at its own -origin that the canister-management tools act as; software holding that -stable identity could in principle associate separate sessions with the same -user. Three things limit that in practice: none of these identifiers is +it has derived for you. Internet Identity derives the same identity each time +for a given application and account, so software holding one of them could in +principle tell that two separate sessions visiting that application belong to +the same user. Three things limit what the Service could link +in practice: none of these identifiers is written to logs, which carry only a per-connection identifier that is new for every connection (section 5); the applications you visit are not written to logs either; and nothing the Service discloses to an application lets diff --git a/docs/openai-directory-submission.md b/docs/openai-directory-submission.md index 71a3a5a..ff01bde 100644 --- a/docs/openai-directory-submission.md +++ b/docs/openai-directory-submission.md @@ -54,7 +54,7 @@ add details not published in the docs. | Discovery documents (RFC 8414 AS metadata + RFC 9728 protected-resource) | ✅ all live, path-scoped + root fallback | | ChatGPT's callback `https://chatgpt.com/connector/oauth/{callback_id}` accepted | ✅ the redirect allow-list pins `("chatgpt.com", "/connector/oauth/")` as a prefix ([`src/auth.rs`](../src/auth.rs), `DEFAULT_ALLOWED_REDIRECTS`) | | No machine-to-machine grants (client credentials etc. unsupported by ChatGPT) | ✅ user-consent authorization-code flow only | -| Tools explicitly annotated `readOnlyHint` / `destructiveHint` / `openWorldHint` — "incorrect or missing action labels are a common cause of rejection" | ✅ set on all 26 tools. The unit test enforces annotation presence and the `readOnlyHint`/`destructiveHint` values; `openWorldHint` is declared everywhere but not asserted by the test, so re-check it in the portal's Scan Tools step | +| Tools explicitly annotated `readOnlyHint` / `destructiveHint` / `openWorldHint` — "incorrect or missing action labels are a common cause of rejection" | ✅ set on all 11 tools. The unit test enforces annotation presence and the `readOnlyHint`/`destructiveHint` values; `openWorldHint` is declared everywhere but not asserted by the test, so re-check it in the portal's Scan Tools step | | Tool names "human-readable, specific, and descriptive"; accurate descriptions; minimum-information requests | ✅ reviewed against the same bar for the Anthropic listing | | Public HTTPS production endpoint, stable and complete ("trial or demo plugins will not be accepted") | ✅ production deployment | | Privacy policy disclosing "categories of personal data collected, purposes of use, categories of recipients, data retention timelines" | ⏳ **pending the production release**: the rewritten policy matches these four required disclosures exactly and is live on staging, but `https://mcp.internetcomputer.org/privacy-policy` serves nothing until the next `release-*` tag ships it (see the checklist) | @@ -93,7 +93,9 @@ and self-serve. Position to state in the Testing tab: reviewers create their own Internet Identity in under a minute (instructions on the landing page); every read-only tool works with any identity because it reads public network state. Have the fallback ready (a provisioned identity with a recovery -phrase, a controlled canister, and a cycles balance) if review pushes back — +phrase in the team vault and an account at a demo app — no controlled +canister or cycles balance is needed: the plugin has no canister-management +tools) if review pushes back — and expect a higher chance of push-back than at Anthropic given the login-and-password wording. @@ -118,11 +120,11 @@ financial-transfers prohibition: 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 + the plugin has no funding or canister-management tools at all — the + execution paths that once moved funds are removed from the binary + ([#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. ### 4. Test cases (authoring work) @@ -131,29 +133,31 @@ At least 5 positive + 3 negative cases with expected outcomes, passing on web 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 canister gftcp-myaaa-aaaar-qcaaa-cai?" → returns step-by-step icp CLI - instructions; nothing is executed. +1. "What interface does canister gftcp-myaaa-aaaar-qcaaa-cai expose?" → + the Candid interface plus capability flags, via get_canister_candid. +2. "Does the canister behind https://opencloud.org expose an API doc, and + what does its interface look like?" → interface + capability flags via + get_canister_candid / get_canister_api_doc. 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. +5. "Resolve https://opencloud.org to its Internet Identity derivation + origin" → resolve_app returns the origin and how it was determined. Negative: 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. +2. A state-changing call (canister_update_call) on a "Questions only" + session → the network rejects it and the tool reports the failed call; + the server instructions prime the assistant to explain the access level + and recommend reconnecting under "Actions & questions". 3. Any authenticated tool with no sign-in → clean 401 → OAuth flow starts (no crash, no hang). -4. "Delete canister " → the network rejects it; the - error is surfaced legibly. +4. "Call an update method on a canister that rejects this caller" → the + canister/network rejects it; the error is surfaced legibly. ### 5. Decisions for the submitter diff --git a/docs/scoping-local-deployment.md b/docs/scoping-local-deployment.md index 2c35576..61fd774 100644 --- a/docs/scoping-local-deployment.md +++ b/docs/scoping-local-deployment.md @@ -106,9 +106,10 @@ minimal. `list_accounts` `:873`). - `calls.rs` (Candid textual↔binary codec, `raw_call`), `discover.rs` (discovery + SSRF guard), `management.rs`, `skills.rs`. - - `tools.rs` — the **entire** MCP tool surface. The `#[tool_router]` / 26×`#[tool]` / - `#[tool_handler] impl ServerHandler for IcTools` macros expand into one impl on - `IcTools` and **must stay co-located**; both binaries construct `IcTools`, differing + - `tools.rs` — the **entire** MCP tool surface. The 26 `#[tool]` definitions live on + two `#[tool_router]` impls — `IcCanisterTools` (11 served) and `IcProtocolTools` + (15 deferred) — behind a hand-written `impl ServerHandler for IcTools` that serves + the canister router; all of it **must stay co-located**; both binaries construct `IcTools`, differing in transport + session source (the local binary additionally wraps it in its login handler, component 6). The injected `SessionResolver` seam (component 7) lives here — authentication itself stays in the binaries. @@ -360,7 +361,7 @@ requested clients split cleanly along that line (verified against current docs, | claude.ai web / mobile / Cowork | no | remote connectors (OAuth) only | ❌ → hosted | | **Codex** CLI / IDE ext / desktop | yes | `~/.codex/config.toml` → `[mcp_servers.]` | ✅ | | Codex Cloud | no | HTTP MCP only | ❌ → hosted | -| **Cursor** | yes | `~/.cursor/mcp.json` or `.cursor/mcp.json` → `mcpServers` | ✅ (≤40 tools total; we expose ~26) | +| **Cursor** | yes | `~/.cursor/mcp.json` or `.cursor/mcp.json` → `mcpServers` | ✅ (≤40 tools total; we expose 13) | | **Perplexity** macOS app | yes (via a `PerplexityXPC` helper) | Settings → Connectors → Add → Advanced JSON | ✅ macOS only | | Perplexity web / Windows / remote | no | remote HTTPS URL + OAuth 2.1 + DCR + `/.well-known/mcp-connector.json` | ❌ → hosted | | **Antigravity** IDE / CLI / 2.0 | yes | `~/.gemini/config/mcp_config.json` or `.agents/mcp_config.json` → `mcpServers` | ✅ | @@ -422,7 +423,7 @@ that router first, forwarding everything else to the inner `IcTools` handler (rm `RequestContext` is handler-independent, so forwarding is verbatim). The hosted server thus contains no login-tool code by construction; a hosted-side test still asserts its `tools/list` stays login-free as a regression guard. Cursor's ~40-tool cap is -comfortable: the core exposes ~26 plus these two.)* +comfortable: the core exposes 11 plus these two.)* **Serving the cloud clients (hosted `imcp2`).** claude.ai web/mobile and Perplexity-web reach only a public HTTPS MCP endpoint with OAuth 2.1 — which hosted `imcp2` already is. Two @@ -466,8 +467,9 @@ middleware; a stdio pipe has no per-request middleware at all), so the middlewar with their transports and core only receives their outcome. Tools that are already session-free work unchanged locally: `get_canister_candid`, -`get_canister_api_doc`, `open_app`, `resolve_app`, `discover_app_canisters`, the skills/lookup -tools, and the anonymous path of `canister_query`. +`get_canister_api_doc`, `open_app`, `resolve_app`, `discover_app_canisters`, `icp_oql_guide`, +and the anonymous path of `canister_query`. (The skill documents are served as `skill://` +resources.) ### 8. Security model @@ -478,8 +480,8 @@ the transient login listener (component 5), and reaching it confers nothing: the page and the `#4091` well-known are static, and `/redeem` only advances the connect this process started (`state` must match) with a chain targeting the in-process `X` — it can neither invoke tools nor read the session. But the client then wields the user's -**real production II accounts** on mainnet (canister create/install/start/stop/delete, any -update call / cycles spend, per-app delegations for every origin). This must be stated plainly: +**real production II accounts** on mainnet (any update call under a per-app delegation, for +every origin). This must be stated plainly: **treat the binary and its client config like a wallet.** There is no revocable token — only the II grant (reconnect/expiry). diff --git a/src/assets/index.html b/src/assets/index.html index 4e487c4..e14e7fa 100644 --- a/src/assets/index.html +++ b/src/assets/index.html @@ -405,7 +405,7 @@ \ No newline at end of file diff --git a/src/assets/privacy-policy.html b/src/assets/privacy-policy.html index e79a835..91ae59f 100644 --- a/src/assets/privacy-policy.html +++ b/src/assets/privacy-policy.html @@ -272,9 +272,7 @@

1. Data Categories Processed, Purposes, and Legal Bases

domain, the account number used, the per-application key it generated, and Internet Identity's signed authorization for that application (itself valid for at most one hour). Keeping this avoids re-deriving an authorization on -every call to the same application. The Service's own origin is treated as -one such application when you use the canister-management tools, and the -identity derived for it is stable across your connections. All of this is +every call to the same application. All of this is held in memory only, bounded in size, and discarded when the session ends or the Service restarts. Legal basis: performance of the service you requested.

@@ -449,10 +447,11 @@

6. Identifiers, and What They Could Link

applications cannot recognise you across applications. The Service is a participant in that design and we want to be plain about what it can see. While a session is live it holds, in memory, the per-application identities -it has derived for you, including a stable identity for you at its own origin -that the canister-management tools act as; software holding that stable -identity could in principle associate separate sessions with the same user. -Three things limit that in practice: none of these identifiers is written to +it has derived for you. Internet Identity derives the same identity each time +for a given application and account, so software holding one of them could in +principle tell that two separate sessions visiting that application belong to +the same user. Three things limit what the Service could link +in practice: none of these identifiers is written to logs, which carry only a per-connection identifier that is new for every connection (section 5); the applications you visit are not written to logs either; and nothing the Service discloses to an application lets that