diff --git a/README.md b/README.md
index 056d560..f66a339 100644
--- a/README.md
+++ b/README.md
@@ -147,14 +147,14 @@ results).
| Tool | Args | Returns |
|------|------|---------|
| `open_app` | `app` (name **or** URL) | **One-call entry point** when a user names/links an app: resolves the Internet Identity `derivation_origin` *and* discovers the canisters behind it, together. A name or bare host is matched to the known-app registry first (so a wrong-TLD guess repairs to the canonical URL); an explicit `https://` URL is resolved as given. An unknown bare name is *refused*, and so is a URL that would need its own origin assumed as the derivation origin while showing no IC evidence (never guessed). Also probes the app's own canisters and reports per-canister `oql`/`api_doc_available` capability flags — for **up to eight** eligible canisters, with both fields *omitted* (not false) on any beyond that — plus a data-access note (which canister is read through the OQL path, and the origin that path requires). 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 |
+| `discover_app_canisters` | `domain` | Canister ids behind a web domain — the app's own `/.well-known/ic-architecture` manifest first (the [ICP service-discoverability protocol](#the-icp-service-discoverability-protocol)'s composition layer: every canister the app *declares*, with names and roles), then the superseded `/.well-known/ic-app.json` manifest as a read-only fallback, 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. Only the `ic-architecture` manifest is a declaration the write gate accepts; every other source here is a read-only hint that [cannot authorize a write](#update-call-authorization) |
| `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 for every documentation outcome — `available` + the doc on success, else `available:false` with `expected`/`retry`/`next`, so "no compatible method was detected" is distinct from "no answer was obtained". An unusable `canister_id` is rejected before any lookup and is a plain error, not that shape; and `expected:true` is not proof of absence, since an interface the parser cannot read also comes up empty |
| `canister_query` | `canister_id`, `method?` **or** `oql?`, `args?` (textual Candid), `derivation_origin?`, `account?`, `candid?` | READ a canister — provide EITHER a Candid `query` `method` (with `args`) OR an `oql` query (a JSON object string, run against `execute`). A Candid `method` query may be anonymous or as your account and returns textual Candid; an `oql` query **requires** `derivation_origin` and returns `columns` + `rows` (a table) with `has_more`, validating `start` against the schema on an empty result. On an OQL canister a Candid `method` query is rejected — use `oql`. `candid` is a fallback: the `.did` interface text to encode/decode against when the canister exposes no `candid:service` metadata. Echoes `derived_for_origin` / `requested` / `acted_as_principal` |
-| `canister_update_call` | `canister_id`, `method`, `args` (textual Candid), `derivation_origin?`, `account?`, `candid?` | Make an UPDATE (state-changing) call; reply as textual Candid; anonymous, or as your account at an app (identified by its canonical II `derivation_origin`, obtained once from `open_app`/`resolve_app`). **Financial transactions are refused**: the ICRC-standard transfer/approval methods (ICRC-1/ICRC-2 and the ICRC-4/-7/-37 equivalents) and the NNS/SNS governance method `manage_neuron` (neuron staking and disbursement) are disallowed on every canister, and the ICP and cycles ledgers' own value-moving methods (the legacy `transfer`, `withdraw`, the `create_canister` spends) and the cycles-minting canister's funding-completion methods (`notify_top_up`, `notify_create_canister`, `notify_mint_cycles`, `create_canister`) on those canisters; and **every** update call is refused on the financial-service canisters the guard carries — all to protect the user. The refusal directs the user to perform the operation outside the connector, in a trusted interface they control — or, for canister creation and funding, with the [icp CLI](https://github.com/dfinity/icp-cli) in their own terminal. The policy is stated in the server-level instructions, deliberately not in any tool description. `candid` is the same `.did` fallback as on `canister_query`, used when the interface isn't published on-chain. Echoes `derived_for_origin` / `requested` / `acted_as_principal` |
+| `canister_update_call` | `canister_id`, `method`, `args` (textual Candid), **`application_origin`**, `derivation_origin?`, `account?`, `candid?` | Make an UPDATE (state-changing) call; reply as textual Candid; anonymous, or as your account at an app (identified by its canonical II `derivation_origin`, obtained once from `open_app`/`resolve_app`). **Two layers of authorization, both of which must pass** (see [Update-call authorization](#update-call-authorization)). *Layer 1 — registration:* `application_origin` is **required**; the origin must be a registered application whose developer accepted the [ICP MCP Developer Terms](#the-developer-terms), and its own `/.well-known/ic-architecture` manifest — re-read on every call — must declare the target canister. A canister id found any other way (header, `/env.json`, JS bundle) cannot be written to, and every failure refuses. *Layer 2 — financial transactions are refused* even inside that surface: the ICRC-standard transfer/approval methods (ICRC-1/ICRC-2 and the ICRC-4/-7/-37 equivalents) and the NNS/SNS governance method `manage_neuron` (neuron staking and disbursement) are disallowed on every canister, and the ICP and cycles ledgers' own value-moving methods (the legacy `transfer`, `withdraw`, the `create_canister` spends) on those ledgers; and **every** update call is refused on a curated list of known financial-service canisters (token ledgers and minters, exchanges, wallet backends, staking/governance) — all to protect the user. The refusal directs the user to perform the operation outside this connector, in a trusted interface they control — or, for canister creation and funding, with the [icp CLI](https://github.com/dfinity/icp-cli) in their own terminal — and deliberately names no venue. `candid` is the same `.did` fallback as on `canister_query`, used when the interface isn't published on-chain. Echoes `derived_for_origin` / `requested` / `acted_as_principal` |
| `get_app_principal` | `derivation_origin`, `account?` | The principal you act as at an app, without a call. Identify the app by its `derivation_origin` (from `open_app`/`resolve_app`). Echoes `derived_for_origin` / `requested` so an origin mismatch is visible |
| `list_app_accounts` | `derivation_origin` | The user's Internet Identity accounts at an app — the default account plus any named ones — with name, number, last-used, and the derivation origin they were listed for. Identify the app by its `derivation_origin` (from `open_app`/`resolve_app`) |
-| `resolve_app` | `app_url` | Resolve an app URL to its Internet Identity derivation context: `application_origin`, the `derivation_origin` to use (declared in `/.well-known/ic-app.json`, else a built-in known-app value, else assumed = app origin — flagged via `derivation_origin_source`: `declared`/`known`/`app_url_default`, with `application_is_ic` echoing the gateway evidence), and the app's `alternative_origins` (informational). An origin with **no IC evidence** that would need the `app_url_default` assumption is **refused** (guessed-domain guard, with a "did you mean" repair when the host resembles a well-known app). Does not return a principal (no account chosen) or require auth — pass the `derivation_origin` to `get_app_principal`/`list_app_accounts` |
+| `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/ii-derivation-origin`, 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_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**: this server rejects an anonymous read (for now) with guidance — its own rule, not an inference about the canister — rather than calling `schema` anonymously and returning an empty list |
@@ -165,10 +165,15 @@ canisters behind the app together (see [Typical flow](#typical-flow)).
`discover_app_canisters` is the canister-only path underneath it, used directly
when you already have the app's domain or URL (its `domain` argument accepts
either) and only need the canister ids. Its sources are listed in the table row
-above (app-declared metadata first, then the `x-ic-canister-id` frontend header,
-then backend candidates mined from `/env.json` + the JS bundle); among the mined
-candidates, pick by label, prefer production/`IC_` ids, and confirm with
-`get_canister_candid`.
+above (the app's own architecture manifest first, then the `x-ic-canister-id`
+frontend header, then backend candidates mined from `/env.json` + the JS
+bundle); among the mined candidates, pick by label, prefer production/`IC_` ids,
+and confirm with `get_canister_candid`.
+
+Discovery and **authorization** are deliberately different things. Finding a
+canister id behind a domain tells you what you might be able to *read*; it never
+authorizes a *write*. Only the app's own architecture manifest does that — see
+[Update-call authorization](#update-call-authorization).
### Typical flow
@@ -182,7 +187,7 @@ Acting **for the user** at an app:
unrelated or squatted site. The tool enforces this: a bare *unknown* name is
refused (find the real URL — web-search or ask the user), and a URL that resolves
to `app_url_default` while showing **no IC evidence** (no valid `x-ic-canister-id`
- gateway header, no `ic-app.json` derivation origin) is refused too; when the host
+ gateway header, no declared `ii-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:
**`resolve_app(url)`** (origin only), **`discover_app_canisters(url)`**
@@ -209,35 +214,192 @@ independent of the identity steps (3/4), so they can run in parallel. Managing y
**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)
+### The ICP service-discoverability protocol
+
+An application on the Internet Computer describes itself to an agent through the
+[ICP service-discoverability protocol][protocol]. This server speaks all five of
+its layers:
-Apps that adopt **Internet Computer App Connect** serve a bridge page at
-`/ai-connect.html` whose ` ` declares the app's
-**main backend** canister (spec §4.7/§6.1). Discovery reads that meta from the
-raw served markup (no JavaScript is executed) and reports it as the
-top-priority finding, labelled `main backend (App Connect)`.
+| Layer | Where | What this server does with it |
+|-------|-------|-------------------------------|
+| **Composition** | `/.well-known/ic-architecture` at the application origin | `discover_app_canisters` / `open_app` report every declared canister with its name and role — and it is what **authorizes an update call** (below) |
+| **Interface** | the canister's `candid:service` metadata | `get_canister_candid` fetches it; args and replies are encoded against it |
+| **Behaviour** | the `getApiDoc` / `get_api_doc` query method | `get_canister_api_doc`, gated on the `api_doc_available` flag |
+| **Data** | the OQL `schema` / `execute` query convention | `icp_oql_guide` → `get_canister_oql_schema` → `canister_query` with `oql` (see [OQL query surfaces](#oql-query-surfaces)) |
+| **Identity** | `/.well-known/ii-derivation-origin` at the application origin | `open_app` / `resolve_app` read the app's declared Internet Identity derivation origin from it (`derivation_origin_source: declared`), falling back to the superseded `ic-app.json` key when an app serves no such file |
-The App Connect spec **defers** multi-canister enumeration (§6.3: how an app
-lists *all* the canisters it comprises, with roles). To fill that gap, this
-server also reads a proposed convention: a `/.well-known/ic-app.json` manifest
-the app serves itself —
+The composition manifest is a JSON document the app serves itself:
```json
{
- "derivation_origin": "https://.icp0.io",
+ "version": "1.0.0",
"canisters": [
- { "id": "aaaaa-…-cai", "role": "backend", "description": "orders + inventory API" },
- { "id": "bbbbb-…-cai", "role": "ledger" }
+ { "id": "hcv4s-…-cai", "name": "frontend", "role": "the frontend" },
+ { "id": "hmxr2-…-cai", "name": "backend", "role": "the backend",
+ "description": "orders + inventory API; call getApiDoc() first" }
]
}
```
-Each entry needs an `id` (a canister principal); `role` and `description` are
-optional and become the finding's label (`role — description`). Unknown fields
-are ignored, so the format can grow. Both sources are the app's own claim about
-its composition — stronger than anything mined from client code — but an
-SPA catch-all serving HTML at these paths simply yields no findings (no meta
-tag; JSON parse fails), and every id is still validated as a principal.
+DFINITY's earlier `/.well-known/ic-app.json` proposal is superseded by this
+document. It is still *read*, as a read-only discovery fallback so apps that
+shipped it stay discoverable while they migrate (findings from it are stamped
+`ic-app.json` and rank below `ic-architecture`), and its top-level
+`derivation_origin` key is still consulted when an app serves no
+`ii-derivation-origin` — but it authorizes nothing: only `ic-architecture` can
+[authorize a write](#update-call-authorization).
+
+`version` and each entry's `id` are required; `name`, `role`, and `description`
+are optional and become the finding's label. Unknown fields are ignored (the
+`1.x` line is accepted, so the schema can grow additively), every id is
+validated as a canister principal, and app-supplied text is sanitized before
+display. A body that isn't this schema — an SPA catch-all serving `index.html`
+at the path is the usual cause — yields nothing; serve the path as
+`application/json`, exempt from catch-all rewrites.
+
+The **identity** file is one line naming the origin Internet Identity derives
+the user's principal against:
+
+```
+https://.icp.net
+```
+
+Omit it when the app derives against its own visible origin. The `https://`
+scheme is required — a bare host is *not* accepted here, so an SPA catch-all
+answering this path with a single token is read as "no declaration" rather than
+as a bogus origin. It is the only authoritative way for `open_app` /
+`resolve_app` to learn a **custom** derivation origin from an app URL — there is
+no reverse lookup — so an app that pins one should serve it; otherwise the
+connector assumes the derivation origin equals the application origin and flags
+that assumption. A **cross-origin** declaration
+is honoured only when the declared origin's own
+`/.well-known/ii-alternative-origins` lists the application origin (the
+browser/II rule); an unauthorized claim is refused rather than resolved to a
+wrong identity. Note the direction: alternative-origins is the *inverse*
+relation and is never used to infer a derivation origin.
+
+[protocol]: https://docs.internetcomputer.org/guides/frontends/service-discoverability/
+
+### Update-call authorization
+
+Reads are open: `canister_query`, `get_canister_candid`, and the OQL tools work
+against any canister. **Writes are not.** `canister_update_call` is authorized in
+two layers, and a call runs only when every check in both passes — otherwise it
+is refused, with no fallback path.
+
+**Layer 1 — the registration gate.** An update call must name an
+`application_origin`, and that application must be *registered*:
+
+1. Layer 2 (below) does not refuse the method.
+2. `application_origin` is supplied (an https origin, canonicalized).
+3. That exact origin is in the server's registry of applications whose
+ developers accepted the current [ICP MCP Developer Terms](#the-developer-terms) —
+ and any `derivation_origin` the call would be signed as is one that
+ registration records for that application.
+4. That origin serves a well-formed `/.well-known/ic-architecture` manifest,
+ fetched fresh on **every** call — nothing is cached.
+5. The target canister is **pinned by the registration** *and* **declared in
+ that live manifest**.
+6. Only then does the call execute.
+
+All six must pass, so the order decides only which refusal the caller reads.
+Layer 2 is evaluated first because it is offline and its refusal is the more
+useful one: "transfer 1 ICP" should be answered with *do it in a wallet you
+control*, not with *that application isn't registered* — the latter reads as
+though registering would make the transfer possible. A value-moving request
+therefore also never triggers an outbound fetch, and never reveals whether the
+named origin is registered.
+
+The consequence is the point: **discovery does not authorize writes.** A
+canister id from the `x-ic-canister-id` header, from an `/env.json`, or mined out
+of a JS bundle can be read, and can never be written to. Those signals are
+evidence about bytes a frontend happened to ship; the manifest is the
+application declaring, at its own origin, what it comprises.
+
+`application_origin` is a **different argument** from `derivation_origin`, and
+one cannot stand in for the other:
+
+* A derivation origin is **shared** by design — this connector's own registry
+ maps five NNS frontends onto one derivation origin and eight Oisy hosts onto
+ another, and `identities::target_origin` collapses `.icp0.io` /
+ `.icp.net` onto `.ic0.app`. Keying authorization on it would let any
+ frontend in such a set write against a sibling's manifest.
+* The manifest is served at the **application** origin, so the derivation origin
+ isn't even where it would be fetched from.
+
+So they do separate jobs: `application_origin` says *which application this call
+belongs to* (and authorizes it), `derivation_origin` says *whose identity to act
+as*. `open_app` and `resolve_app` return both.
+
+Separate, but not unrelated. Nothing in the identity path ties them together, so
+the gate requires the *pair* to match what registration recorded: a registered
+application may only act as an identity recorded for it. Otherwise a registered
+application could have the server sign a call — to a canister it had listed — as
+the user's principal at an unrelated app, which that app's canisters may well
+trust.
+
+Refusals distinguish their causes, because the fixes differ: no
+`application_origin` supplied; the origin's developer has no current Terms
+acceptance; the manifest could not be read (retryable); the manifest does not
+declare this canister. Every one of them points at the read path as the
+available alternative.
+
+**Layer 2 — the financial guard.** Inside the authorized surface, the
+standardized value-moving methods and every update call to a known
+finance-related canister are refused anyway (the `canister_update_call` row in
+[Tools](#tools) has the detail). This layer is deliberately origin-blind, so no
+registration can launder a financial call through it — registration buys an
+application access to its *own* declared canisters, never the right to move
+value.
+
+Both layers apply to **every** deployment composing `imcp2-core`, the hosted
+server and the local stdio binary alike: the gate lives in the shared tool
+implementation, and there is no configuration that turns it off.
+
+#### The Developer Terms
+
+The protocol proves *composition* and nothing more. Serving a manifest does not
+establish that the publisher accepted any terms, that it is entitled to expose
+every canister it lists, which of its update methods are safe to call, or that
+its behaviour stays inside this server's policies. That is what the
+[ICP MCP Developer Terms](https://internetcomputer.org/icp-mcp/developer-terms/)
+carry: registering an application is the
+publisher's representation that it may expose every canister its manifest lists,
+and that the operations reachable through this server move no value, are safe
+for an assistant to call on a user's behalf, and handle personal data lawfully.
+
+Registration is therefore two facts, both required: the protocol's manifest
+(technical, published by the app) and the Terms acceptance (contractual,
+recorded here). The registry lives in `authorization.rs` as a reviewed table —
+who accepted, which revision, when — so the set of applications that may receive
+writes is public and auditable. **It ships empty**, which means no application
+can receive an update call until a publisher accepts the Terms and a reviewed
+change adds it.
+
+One row is one **origin**: an app served at both its own domain and its
+`.icp0.io` gateway origin needs a row (and a manifest) at each — an
+acceptance is not inherited across origins. Revocation is removal of the row,
+and a Terms revision bump invalidates every acceptance stamped against the old
+revision. Because the table is compiled in, either is a *release* rather than a
+runtime switch; what "nothing is cached" buys is that the change is complete the
+moment it is deployed — no TTL to wait out, no state to reconcile.
+
+A row **pins what was reviewed** — the canister ids, and the derivation origins
+the application acts as. The live manifest can then only ever *narrow* that pin,
+never widen it: dropping a canister from the manifest stops writes to it at once
+(the app's own signal), while adding one grants nothing until a reviewed change
+records it too. So a publisher that later edits its manifest — or is compromised
+into editing it — cannot give itself a canister nobody reviewed, and cannot have
+the server act as the user's identity at an application it does not own. The
+Developer Terms carry the matching promise, but the code no longer depends on
+that promise holding.
+
+Canister *management* (installing code, settings, lifecycle) is a different
+surface with a different basis — it acts on canisters the **user** controls,
+authenticated as them — and is not part of this connector's served tools; use
+the [`icp` CLI](https://github.com/dfinity/icp-cli) in your own terminal.
+
+### Hardened discovery fetches
Discovery fetches are **SSRF-hardened** (CWE-918). Only `https` URLs with a real
host are fetched, and every outbound fetch runs under a redirect guard (a 3xx may only
@@ -252,12 +414,22 @@ mid-flight. Fixed public-host enrichment (the IC dashboard)
uses the redirect guard but is not separately address-pinned. No JavaScript is
executed, and every extracted id is validated as a principal.
-The optional top-level **`derivation_origin`** is the app's declaration of the
-Internet Identity derivation origin its frontends pin (see the identity section
-above). It is the only authoritative way for `open_app` / `resolve_app` to learn a
-**custom** derivation origin from an app URL — there is no reverse lookup from an app URL to it —
-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.
+The authorization manifest fetch reuses exactly those guards, and adds two more.
+The body is read **strictly** — an incomplete or over-cap body is an error, not a
+prefix the gate would decide on (a truncated manifest could only ever deny a
+canister, but a gate should not rule on a document it did not fully receive). And
+the response must have come from the origin that was asked, not a redirect
+target — the shared redirect policy permits same-host different-port hops, so
+without that check a neighbouring origin's manifest could be read as this one's
+declaration. The same attribution rule now applies to the identity files
+(`/.well-known/ii-derivation-origin` and the superseded manifest's key): a
+declaration served by a different origin is ignored rather than read as this
+application's. Because the fetch happens only *after* the registry check, the set
+of origins the **authorization path** will fetch is the curated registry: a
+caller cannot use an update call to steer this server's client at an origin of
+their choosing. (Discovery still reads the same path from any origin a caller
+names — that is its job, under the same guards — so the property is about the
+write path, not about the process never fetching an arbitrary origin.)
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.
@@ -266,7 +438,11 @@ inline.)
`canister_query` and `canister_update_call` run anonymously by default; pass a
`derivation_origin` to call as
-your account at that app. The server mints a **short-lived account delegation on
+your account at that app. (`canister_update_call` additionally **requires**
+`application_origin`, which is what authorizes the write — see
+[Update-call authorization](#update-call-authorization). Calling anonymously is
+about *identity*, not authorization: an anonymous update still needs a
+registered application origin.) The server mints a **short-lived account delegation on
demand** using the connection's registered Internet Identity session key (see
[Domain identities](#domain-identities-on-demand)) — there is no per-app sign-in
step. `get_app_principal` returns that account's principal
@@ -291,7 +467,7 @@ token (see Auth).
> URL. A derivation origin is a *stable per-app value*, so you **resolve it once**
> and reuse it: `open_app` (or `resolve_app`) turns an app name/URL into it and
> reports how — `derivation_origin_source`: **declared**
-> (`/.well-known/ic-app.json` → `derivation_origin`), else a built-in **known-app**
+> (`/.well-known/ii-derivation-origin`), else a built-in **known-app**
> value for a few apps that pin a custom origin without declaring it (an app's
> own declaration always overrides this), else the app origin *assumed*
> (**app_url_default**). Feeding that resolved origin to an identity tool records
@@ -408,13 +584,16 @@ cargo run
# and $MCP_SERVE_METRICS (set it to serve the Prometheus exposition at /metrics)
```
-The human-facing pages — the landing page and the `/privacy-policy`,
-`/support`, and `/terms` documents the connector directories require — are
-maintained in [dfinity/internetcomputer-org] (`public/icp-mcp/`) and served at
- , so the content exists exactly once.
-This origin answers their old paths (`/`, `/privacy-policy`, `/support`,
-`/terms`) with permanent redirects there, keeping every published link
-working. `GET /version` is the operations probe (see [Auth](#auth-oauth-21-login-via-internet-identity)).
+The human-facing pages — the landing page, the `/privacy-policy`, `/support`,
+and `/terms` documents the connector directories require, and the publisher-facing
+`/developer-terms` — are maintained in [dfinity/internetcomputer-org]
+(`public/icp-mcp/`) and served at , so the
+content exists exactly once. This origin answers their paths (`/`,
+`/privacy-policy`, `/support`, `/terms`, `/developer-terms`) with permanent
+redirects there, keeping every published link working. The Developer Terms'
+source text lives here, in [`docs/icp-mcp-developer-terms-draft.md`](docs/icp-mcp-developer-terms-draft.md),
+alongside the privacy policy's: it carries the revision the write gate enforces,
+and a test fails if the two drift apart. `GET /version` is the operations probe (see [Auth](#auth-oauth-21-login-via-internet-identity)).
[dfinity/internetcomputer-org]: https://github.com/dfinity/internetcomputer-org
@@ -913,7 +1092,7 @@ mcp_get_delegation :
*domain-based* derivation: a raw `derivation_origin` is canonicalized and used
verbatim, with no recovery of a custom derivation origin from it. When an
`app_url` is passed instead, `resolve_app` resolves the derivation origin by
- precedence **declared** (`/.well-known/ic-app.json` `derivation_origin`) >
+ precedence **declared** (`/.well-known/ii-derivation-origin`) >
built-in **known-app** registry > application origin, so a custom origin an app
declares (or that ships in the registry, e.g. `oisy.com`) **is** honoured, and
the app's `/.well-known/ii-alternative-origins` list is fetched and surfaced by
diff --git a/crates/imcp2-core/src/architecture.rs b/crates/imcp2-core/src/architecture.rs
new file mode 100644
index 0000000..04b15cc
--- /dev/null
+++ b/crates/imcp2-core/src/architecture.rs
@@ -0,0 +1,485 @@
+//! The **ICP service-discoverability protocol** — the canonical way an
+//! Internet Computer application describes itself to an agent, specified at
+//! .
+//!
+//! The protocol has five layers, and this server speaks all of them:
+//!
+//! 1. **Composition** — `/.well-known/ic-architecture`, served at the
+//! application origin: the app enumerates the canisters it comprises,
+//! each with an `id` and human-readable `name`/`role`. THIS module.
+//! 2. **Interface** — the canister's own `candid:service` metadata
+//! (`get_canister_candid`, [`crate::calls`]).
+//! 3. **Behaviour** — the `getApiDoc`/`get_api_doc` query method
+//! (`get_canister_api_doc`).
+//! 4. **Data** — the OQL `schema`/`execute` query convention
+//! (`get_canister_oql_schema`, `canister_query`'s `oql` argument).
+//! 5. **Identity** — `/.well-known/ii-derivation-origin`, the one line
+//! naming the origin Internet Identity derives the user's principal
+//! against. Parsed here, resolved in [`crate::discover`].
+//!
+//! Layer 1 is load-bearing beyond discovery: it is what an **update call**
+//! is authorized against ([`crate::authorization`]). An app's architecture
+//! manifest is the app's own signed-by-serving statement of which canisters
+//! belong to it, fetched from the exact application origin over HTTPS — so
+//! unlike a canister id mined out of a JS bundle, an `/env.json`, or a
+//! response header, it cannot be attributed to an app that never claimed it.
+//! Everything here therefore **fails closed**: an unreachable origin, a
+//! missing file, a body that isn't the declared schema, or an entry whose id
+//! isn't a canister principal all yield "not declared", never "assume yes".
+//!
+//! The manifest is deliberately **not cached**. Each authorization decision
+//! re-reads the live file, so an app that removes a canister from its
+//! manifest loses write access to it on the next call rather than at the end
+//! of a TTL.
+
+use candid::Principal;
+use serde::Deserialize;
+
+use crate::discover;
+
+/// Layer 1: where the composition manifest lives. Path-exact, per the spec —
+/// no extension, no alternate spelling, no fallback path.
+pub const ARCHITECTURE_WELL_KNOWN: &str = "/.well-known/ic-architecture";
+
+/// Layer 5: where an app declares the Internet Identity derivation origin its
+/// frontends pin. A single line holding that origin; absent when the app
+/// derives against the visible origin itself.
+pub const II_DERIVATION_ORIGIN_WELL_KNOWN: &str = "/.well-known/ii-derivation-origin";
+
+/// The schema version this server understands. The spec's `version` field
+/// identifies the manifest schema; we accept the `1.x` line (unknown fields
+/// are ignored for forward compatibility, which is what a minor bump is for)
+/// and refuse anything else rather than guessing at a future shape.
+const SUPPORTED_SCHEMA_MAJOR: &str = "1";
+
+/// Cap on manifest entries. Generous — the body itself is capped at
+/// [`discover::MAX_META_BYTES`], so this only bounds a hostile manifest that
+/// packs the cap full of tiny entries.
+///
+/// Exceeding it rejects the WHOLE manifest rather than truncating it.
+/// Truncation is the wrong failure here: a legitimately declared canister
+/// past the cut would be silently refused, and the app developer would see
+/// one canister mysteriously not working with nothing to go on. A whole-
+/// manifest refusal names the cap, so the signal is actionable.
+const MAX_ARCHITECTURE_CANISTERS: usize = 1000;
+
+/// The `/.well-known/ic-architecture` document. Unknown fields are ignored
+/// (the spec mandates forward compatibility); `version` is validated rather
+/// than defaulted, so a body that merely happens to carry a `canisters` array
+/// is not mistaken for a manifest.
+#[derive(Debug, Deserialize)]
+pub struct Architecture {
+ /// The manifest schema version, e.g. `"1.0.0"`.
+ pub version: String,
+ #[serde(default)]
+ pub canisters: Vec,
+}
+
+/// One canister the app declares itself to comprise.
+#[derive(Debug, Deserialize)]
+pub struct ArchitectureCanister {
+ /// The canister's principal id — the only required field.
+ pub id: String,
+ /// A short identifier for the canister within the app, e.g. `"backend"`.
+ #[serde(default)]
+ pub name: Option,
+ /// What the canister does in the app, e.g. `"the backend"`.
+ #[serde(default)]
+ pub role: Option,
+ /// Optional longer prose, e.g. `"orders + inventory API"`.
+ #[serde(default)]
+ pub description: Option,
+}
+
+impl ArchitectureCanister {
+ /// The entry's id as a principal, or `None` when it isn't one. App-supplied
+ /// text, so never assumed valid: a membership test compares parsed
+ /// principals, never raw strings, so `" aaaaa-aa "` and `"AAAAA-AA"` cannot
+ /// smuggle a different target past the comparison.
+ fn principal(&self) -> Option {
+ Principal::from_text(self.id.trim()).ok()
+ }
+
+ /// The human label for this entry — `name`, `role`, and `description`
+ /// folded into one display string, each sanitized (app-supplied text,
+ /// never markup or unbounded).
+ pub fn label(&self) -> Option {
+ let clean = |s: &Option| {
+ s.as_deref()
+ .map(discover::clean_label)
+ .filter(|s| !s.is_empty())
+ };
+ let (name, role, desc) = (
+ clean(&self.name),
+ clean(&self.role),
+ clean(&self.description),
+ );
+ // `role` is the richer of the two identifiers ("the backend" vs
+ // "backend"), so it leads when both are present.
+ let head = match (name, role) {
+ (Some(n), Some(r)) if r.eq_ignore_ascii_case(&n) => Some(r),
+ (Some(n), Some(r)) => Some(format!("{r} ({n})")),
+ (Some(n), None) => Some(n),
+ (None, Some(r)) => Some(r),
+ (None, None) => None,
+ };
+ match (head, desc) {
+ (Some(h), Some(d)) => Some(format!("{h} — {d}")),
+ (Some(h), None) => Some(h),
+ (None, Some(d)) => Some(d),
+ (None, None) => None,
+ }
+ }
+}
+
+impl Architecture {
+ /// The declared entry for `canister_id` — the membership test an update
+ /// call is authorized against: `Some(label)` when the app lists it (the
+ /// label itself may be absent, hence the nested `Option`), `None` when it
+ /// doesn't. Compares parsed principals, so only a genuine id match counts.
+ pub fn role_of(&self, canister_id: &Principal) -> Option> {
+ self.canisters
+ .iter()
+ .find(|c| c.principal().as_ref() == Some(canister_id))
+ .map(|c| c.label())
+ }
+
+ /// The declared canisters as `(id, label)` pairs for discovery output.
+ /// Entries whose id isn't a canister principal are dropped — the app said
+ /// something we can't act on, so we don't surface it as a finding.
+ pub fn findings(&self) -> Vec<(String, Option)> {
+ self.canisters
+ .iter()
+ .filter_map(|c| c.principal().map(|p| (p.to_text(), c.label())))
+ .collect()
+ }
+}
+
+/// Parse an `/.well-known/ic-architecture` body. `Err` carries why the body is
+/// not a usable manifest, for the refusal message — every failure is a
+/// fail-closed "this app declares nothing", never a soft default.
+pub fn parse_architecture(text: &str) -> Result {
+ let arch: Architecture = serde_json::from_str(text).map_err(|e| {
+ // A frontend's SPA catch-all serves index.html for unknown paths, which
+ // is the overwhelmingly common reason this isn't JSON — say so, since
+ // the fix (exempt the path from the rewrite) is in the app's hands.
+ format!(
+ "the body is not the declared JSON schema ({e}) — an SPA catch-all \
+ serving HTML at this path is the usual cause; the spec requires \
+ {ARCHITECTURE_WELL_KNOWN} to be exempt from catch-all rewrites and \
+ served as application/json"
+ )
+ })?;
+ let major = arch.version.trim().split('.').next().unwrap_or_default();
+ if major != SUPPORTED_SCHEMA_MAJOR {
+ return Err(format!(
+ "manifest schema version {:?} is not supported (this server reads the \
+ {SUPPORTED_SCHEMA_MAJOR}.x line)",
+ arch.version.trim()
+ ));
+ }
+ if arch.canisters.len() > MAX_ARCHITECTURE_CANISTERS {
+ return Err(format!(
+ "the manifest declares {} canisters, past the {MAX_ARCHITECTURE_CANISTERS} this \
+ server reads — the whole manifest is refused rather than silently truncated",
+ arch.canisters.len()
+ ));
+ }
+ Ok(arch)
+}
+
+/// The app's declared Internet Identity derivation origin from a
+/// `/.well-known/ii-derivation-origin` body: the file's single line, reduced
+/// to a canonical bare `https://host[:port]` origin. `None` when the file is
+/// blank or the line is not an explicit https origin — so a malformed
+/// declaration falls back to the application origin instead of deriving
+/// against garbage.
+///
+/// The `https://` scheme is REQUIRED here, unlike the scheme-tolerant
+/// [`discover::normalize_origin`] used for interactively-supplied origins. The
+/// spec's file holds a full origin, and accepting a bare host would read any
+/// one-word 200 body as a declaration: an SPA catch-all answering this path
+/// with a single token would become a bogus CROSS-origin claim, which the
+/// alternative-origins check then refuses — turning a missing file into a hard
+/// failure to resolve the app at all, instead of the application-origin default
+/// the spec prescribes.
+pub fn parse_derivation_origin(text: &str) -> Option {
+ // "Single line" per the spec; tolerate a trailing newline, a UTF-8 BOM, and
+ // stray surrounding whitespace, but not a second line of content — a file
+ // with more than one origin in it is not something to guess at.
+ let mut lines =
+ text.trim_start_matches('\u{feff}').lines().map(str::trim).filter(|l| !l.is_empty());
+ let first = lines.next()?;
+ if lines.next().is_some() {
+ return None;
+ }
+ // The `https://` scheme is REQUIRED (`get`, not slicing, so a multi-byte
+ // first character can't panic). See the doc above for why a bare host must
+ // not be accepted here.
+ if !first.get(..8).is_some_and(|p| p.eq_ignore_ascii_case("https://")) {
+ return None;
+ }
+ discover::normalize_origin(first)
+}
+
+/// The outcome of reading an origin's architecture manifest. Both failure
+/// variants deny authorization; they are distinct only so the refusal can tell
+/// the agent whether the app's manifest said no or the app's origin couldn't be
+/// read at all — two very different things for the developer to fix.
+pub enum ArchitectureFetch {
+ /// The exact origin served a well-formed manifest.
+ Served(Architecture),
+ /// The origin answered, but not with a usable manifest (404, a redirect off
+ /// the origin, a catch-all HTML page, an unsupported schema version).
+ NotDeclared(String),
+ /// The origin could not be read at all (DNS, TLS, timeout, or the SSRF
+ /// guard refusing a non-public target).
+ Unreachable(String),
+}
+
+/// How long the whole manifest read may take before the call is refused as
+/// unreadable. Deliberately shorter than the shared site-fetch timeout: this
+/// one sits in front of every state-changing call, so a slow origin must cost
+/// the caller a prompt "retry" rather than a long stall. The refusal says it is
+/// retryable, so a transient slow patch costs a round trip, not a wrong answer.
+const FETCH_BUDGET: std::time::Duration = std::time::Duration::from_secs(5);
+
+/// Fetch `origin`'s architecture manifest from the **exact** origin, within
+/// [`FETCH_BUDGET`].
+///
+/// `origin` must already be canonical (see [`discover::normalize_origin`]).
+/// Reuses the site-fetch guards every caller-supplied fetch in this crate
+/// carries: the target is resolved to public addresses and pinned into the
+/// client before the request (SSRF, CWE-918), the body is size-capped — and read
+/// STRICTLY, so an incomplete or over-cap body is an error rather than a prefix
+/// this gate would decide on — and the response must have come from the origin we
+/// asked — the shared redirect
+/// policy permits same-host different-PORT hops, so a manifest served after a
+/// redirect could otherwise come from a neighbouring origin and be read as
+/// this one's declaration.
+pub async fn fetch_architecture(origin: &str) -> ArchitectureFetch {
+ match tokio::time::timeout(FETCH_BUDGET, read_architecture(origin)).await {
+ Ok(fetched) => fetched,
+ Err(_) => ArchitectureFetch::Unreachable(format!(
+ "reading {origin}{ARCHITECTURE_WELL_KNOWN} took longer than {}s",
+ FETCH_BUDGET.as_secs()
+ )),
+ }
+}
+
+async fn read_architecture(origin: &str) -> ArchitectureFetch {
+ let (url, pinned) = match discover::resolve_public_url(origin).await {
+ Ok(v) => v,
+ Err(e) => return ArchitectureFetch::Unreachable(e),
+ };
+ let host = url.host_str().unwrap_or_default().to_ascii_lowercase();
+ let client = match discover::site_client(&host, &pinned) {
+ Ok(c) => c,
+ Err(e) => return ArchitectureFetch::Unreachable(e),
+ };
+ let expected = url.origin().ascii_serialization();
+ let resp = match client
+ .get(format!("{expected}{ARCHITECTURE_WELL_KNOWN}"))
+ .send()
+ .await
+ {
+ Ok(r) => r,
+ Err(e) => {
+ return ArchitectureFetch::Unreachable(format!(
+ "could not read {expected}{ARCHITECTURE_WELL_KNOWN}: {e}"
+ ))
+ }
+ };
+ let served_by = resp.url().origin().ascii_serialization();
+ if served_by != expected {
+ return ArchitectureFetch::NotDeclared(format!(
+ "{expected}{ARCHITECTURE_WELL_KNOWN} redirected to {served_by} — the \
+ manifest must be served by the application origin itself"
+ ));
+ }
+ if !resp.status().is_success() {
+ return ArchitectureFetch::NotDeclared(format!(
+ "{expected}{ARCHITECTURE_WELL_KNOWN} answered {} — the application serves \
+ no architecture manifest",
+ resp.status().as_u16()
+ ));
+ }
+ // STRICTLY read: an incomplete body is an error here, not a prefix. A
+ // truncated manifest could only ever deny a canister (a prefix cannot add an
+ // entry), but a gate must not decide on a document it did not fully receive.
+ let text = match discover::read_strict(resp, discover::MAX_META_BYTES).await {
+ Ok(text) => text,
+ Err(e) => {
+ return ArchitectureFetch::Unreachable(format!(
+ "{expected}{ARCHITECTURE_WELL_KNOWN} could not be read in full: {e}"
+ ))
+ }
+ };
+ match parse_architecture(&text) {
+ Ok(arch) => ArchitectureFetch::Served(arch),
+ Err(e) => ArchitectureFetch::NotDeclared(format!(
+ "{expected}{ARCHITECTURE_WELL_KNOWN} is not readable as a manifest: {e}"
+ )),
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ // The spec's own example manifest, verbatim from the guide.
+ const SPEC_EXAMPLE: &str = r#"{
+ "version": "1.0.0",
+ "canisters": [
+ { "id": "hcv4s-uaaaa-aaabq-qaaba-cai", "name": "frontend", "role": "the frontend" },
+ { "id": "hmxr2-pqaaa-aaabq-qaaaa-cai", "name": "backend", "role": "the backend",
+ "description": "orders + inventory API; call getApiDoc() first" }
+ ]
+ }"#;
+
+ fn p(s: &str) -> Principal {
+ Principal::from_text(s).unwrap()
+ }
+
+ #[test]
+ fn parses_the_spec_example_and_answers_membership() {
+ let arch = parse_architecture(SPEC_EXAMPLE).expect("spec example must parse");
+ assert_eq!(arch.version, "1.0.0");
+ assert!(arch.role_of(&p("hcv4s-uaaaa-aaabq-qaaba-cai")).is_some());
+ assert!(arch.role_of(&p("hmxr2-pqaaa-aaabq-qaaaa-cai")).is_some());
+ // A canister the app does NOT list is not declared, however real it is.
+ assert!(arch.role_of(&p("ryjl3-tyaaa-aaaaa-aaaba-cai")).is_none());
+ // Labels fold name/role/description for display.
+ assert_eq!(
+ arch.role_of(&p("hcv4s-uaaaa-aaabq-qaaba-cai")),
+ Some(Some("the frontend (frontend)".to_string()))
+ );
+ assert_eq!(
+ arch.findings()
+ .into_iter()
+ .map(|(id, _)| id)
+ .collect::>(),
+ vec!["hcv4s-uaaaa-aaabq-qaaba-cai", "hmxr2-pqaaa-aaabq-qaaaa-cai"]
+ );
+ }
+
+ // Every malformed body is an error, not an empty-but-usable manifest: a
+ // membership test against a silently-empty manifest would refuse, but a
+ // membership test against a body we misread as a manifest could ALLOW.
+ #[test]
+ fn parse_fails_closed() {
+ for (body, why) in [
+ ("", "empty"),
+ (
+ "SPA catch-all",
+ "an SPA catch-all page",
+ ),
+ (r#"{"canisters":[{"id":"aaaaa-aa"}]}"#, "no version field"),
+ (
+ r#"{"version":"2.0.0","canisters":[{"id":"aaaaa-aa"}]}"#,
+ "a future schema",
+ ),
+ (
+ r#"{"version":"1.0.0","canisters":"aaaaa-aa"}"#,
+ "canisters not a list",
+ ),
+ ] {
+ assert!(
+ parse_architecture(body).is_err(),
+ "{why} must not parse: {body}"
+ );
+ }
+ }
+
+ // An entry whose id is not a canister principal is inert: it can neither
+ // authorize a call nor appear as a finding. So a manifest cannot smuggle a
+ // target past the membership test by spelling it oddly.
+ #[test]
+ fn junk_ids_authorize_nothing() {
+ let arch = parse_architecture(
+ r#"{"version":"1.0.0","canisters":[
+ {"id":"not-a-principal"},
+ {"id":""},
+ {"id":" ryjl3-tyaaa-aaaaa-aaaba-cai ","role":"padded"}]}"#,
+ )
+ .expect("parses");
+ assert_eq!(arch.findings().len(), 1, "only the real id is a finding");
+ // A padded id is trimmed to the same principal — the comparison is on
+ // parsed principals, so whitespace cannot fork the identity.
+ assert!(arch.role_of(&p("ryjl3-tyaaa-aaaaa-aaaba-cai")).is_some());
+ }
+
+ // Forward compatibility: unknown fields (top-level and per entry) and a
+ // minor/patch bump are accepted, because the spec says consumers must
+ // ignore what they don't know.
+ #[test]
+ fn unknown_fields_and_minor_bumps_are_accepted() {
+ let arch = parse_architecture(
+ r#"{"version":"1.4.2","future_key":{"x":1},"canisters":[
+ {"id":"ryjl3-tyaaa-aaaaa-aaaba-cai","role":"ledger","future_entry_key":true}]}"#,
+ )
+ .expect("a 1.x manifest with unknown fields must parse");
+ assert!(arch.role_of(&p("ryjl3-tyaaa-aaaaa-aaaba-cai")).is_some());
+ }
+
+ // Past the entry cap the WHOLE manifest is refused, not truncated: a
+ // truncated manifest would silently deny a canister the app declared,
+ // leaving the developer with one canister that mysteriously doesn't work.
+ #[test]
+ fn entry_cap_rejects_the_whole_manifest_rather_than_truncating() {
+ let entry = r#"{"id":"ryjl3-tyaaa-aaaaa-aaaba-cai"}"#;
+ let at_cap = format!(
+ r#"{{"version":"1.0.0","canisters":[{}]}}"#,
+ vec![entry; MAX_ARCHITECTURE_CANISTERS].join(",")
+ );
+ assert!(
+ parse_architecture(&at_cap).is_ok(),
+ "exactly at the cap is fine"
+ );
+ let over_cap = format!(
+ r#"{{"version":"1.0.0","canisters":[{}]}}"#,
+ vec![entry; MAX_ARCHITECTURE_CANISTERS + 1].join(",")
+ );
+ let msg = parse_architecture(&over_cap).expect_err("over the cap must be refused");
+ assert!(msg.contains("truncated"), "the refusal must say why: {msg}");
+ }
+
+ // Layer 5: the identity file is one origin, canonicalized, or nothing.
+ #[test]
+ fn derivation_origin_file_parses_one_origin_or_nothing() {
+ assert_eq!(
+ parse_derivation_origin("https://hcv4s-uaaaa-aaabq-qaaba-cai.icp.net\n").as_deref(),
+ Some("https://hcv4s-uaaaa-aaabq-qaaba-cai.icp.net")
+ );
+ // Canonicalized: case-normalized host, default port dropped.
+ assert_eq!(
+ parse_derivation_origin("HTTPS://Example.COM:443").as_deref(),
+ Some("https://example.com")
+ );
+ // A BOM-prefixed file still reads (deployment tooling adds them).
+ assert_eq!(
+ parse_derivation_origin("\u{feff}https://example.com\n").as_deref(),
+ Some("https://example.com")
+ );
+ for bad in [
+ "",
+ "\n\n",
+ "http://example.com", // not https
+ "https://user@example.com", // user-info
+ "not a url", // unparseable
+ "https://a.com\nhttps://b.com", // two origins: don't guess
+ // A bare host is NOT accepted here: an SPA catch-all answering this
+ // path with one token would otherwise become a bogus cross-origin
+ // claim, and the alternative-origins check would then refuse to
+ // resolve the app at all rather than defaulting to its own origin.
+ "example.com",
+ "maintenance",
+ "app",
+ ] {
+ assert!(
+ parse_derivation_origin(bad).is_none(),
+ "{bad:?} must not resolve"
+ );
+ }
+ }
+}
diff --git a/crates/imcp2-core/src/authorization.rs b/crates/imcp2-core/src/authorization.rs
new file mode 100644
index 0000000..22c664e
--- /dev/null
+++ b/crates/imcp2-core/src/authorization.rs
@@ -0,0 +1,1219 @@
+//! **Who may write.** The authorization boundary for state-changing canister
+//! calls (`canister_update_call`), in two layers.
+//!
+//! ## Layer 1 — the registration gate (this module)
+//!
+//! An update call is authorized only when ALL of the following hold. Any one
+//! of them failing refuses the call; there is no default-allow path:
+//!
+//! 1. Layer 2 (below) does not refuse the method.
+//! 2. The caller supplies an **`application_origin`** — the https origin of
+//! the application the call belongs to.
+//! 3. That origin appears in [`REGISTERED_APPLICATIONS`] with an acceptance
+//! of the **current** ICP MCP Developer Terms
+//! ([`DEVELOPER_TERMS_VERSION`]), and any `derivation_origin` the call
+//! would be signed as is one that registration records for it.
+//! 4. That exact origin serves a well-formed
+//! `/.well-known/ic-architecture` manifest — the composition layer of
+//! the [ICP service-discoverability protocol].
+//! 5. The target canister is **pinned by the registration** AND **declared
+//! in that live manifest** — so the manifest can narrow the reviewed
+//! surface but never widen it.
+//! 6. Only then does the call execute.
+//!
+//! All six must pass, so the ORDER only decides which refusal the caller
+//! reads. Layer 2 is evaluated first because it is offline and its refusal is
+//! the more specific and more useful one: "transfer 1 ICP" should be answered
+//! with "do it in a wallet you control", not with "that application isn't
+//! registered" — the latter reads as though a registration would make the
+//! transfer possible. It also means a value-moving request never triggers an
+//! outbound fetch, and never reveals whether the named origin is registered.
+//!
+//! ### Why this and not the older discovery signals
+//!
+//! This server can also *find* canisters behind a domain from a response
+//! header, an `/env.json`, or literals mined out of a JS bundle (see
+//! [`crate::discover`]). Those are useful for reading, and they remain — but
+//! they are **evidence about bytes a frontend happened to ship**, not a
+//! statement by the application about what it comprises. A canister id in a
+//! bundle says nothing about who operates it, and anything that can serve a
+//! header can claim any id. None of them can authorize a write. The
+//! architecture manifest can: the application publishes it at its own origin,
+//! over HTTPS, as its own declaration.
+//!
+//! ### Why `derivation_origin` cannot stand in for `application_origin`
+//!
+//! They are different things and the difference is load-bearing:
+//!
+//! * A derivation origin is **shared** by design. This crate's own registry
+//! maps five NNS frontends onto one derivation origin and eight Oisy hosts
+//! onto another, and [`crate::identities::target_origin`] additionally
+//! collapses `.icp0.io` and `.icp.net` onto `.ic0.app`. Keying
+//! authorization on it would let any frontend in such a set write against
+//! a sibling's manifest.
+//! * The manifest is served at the **application** origin, so the
+//! derivation origin is not even where it would be fetched from.
+//!
+//! So the two are separate arguments with separate jobs:
+//! `application_origin` says *which application this call belongs to* (and is
+//! what authorizes it); `derivation_origin` says *whose identity to act as*.
+//! Separate, but not unrelated: because nothing in the identity path ties them
+//! together, this gate requires the pair to match what registration recorded —
+//! otherwise a registered application could borrow an unrelated app's identity
+//! to write to a canister it had listed.
+//!
+//! ### What the protocol does NOT establish
+//!
+//! Serving a manifest is a technical statement, not a promise. It does not
+//! establish that the publisher accepted any terms, that it is entitled to
+//! expose every canister it lists, which of its update methods are safe to
+//! call, or that its behaviour stays inside this server's policies. Those
+//! come from the **ICP MCP Developer Terms** ([`DEVELOPER_TERMS_URL`]), which the
+//! publisher accepts out of band; [`REGISTERED_APPLICATIONS`] is this
+//! server's record of who has. Hence step 2: the protocol proves composition,
+//! the Terms carry the obligations, and an update call needs both.
+//!
+//! ## Layer 2 — the financial guard ([`crate::compliance`])
+//!
+//! Inside the authorized surface, standardized value-moving methods and calls
+//! to known finance-related canisters are refused anyway. Layer 2 is
+//! deliberately origin-blind — `disallowed_update_method` takes no origin — so
+//! no amount of registration can launder a financial call through it.
+//!
+//! [ICP service-discoverability protocol]: https://docs.internetcomputer.org/guides/frontends/service-discoverability/
+
+use candid::Principal;
+
+use crate::{
+ architecture::{self, Architecture, ArchitectureFetch, ARCHITECTURE_WELL_KNOWN},
+ compliance, discover,
+};
+
+/// The revision of the ICP MCP Developer Terms an acceptance must be against
+/// for update calls to be authorized. Bumping this **invalidates every
+/// acceptance stamped with an older revision** — each publisher's row has to
+/// be re-stamped after they accept the new revision, which is the intended
+/// behaviour: a materially changed obligation nobody has agreed to yet must
+/// not keep authorizing writes. Kept in step with the revision and effective
+/// date of the published Terms, whose source text is
+/// `docs/icp-mcp-developer-terms-draft.md` in this repository (pinned by a test
+/// in the serving binary, so the two cannot drift).
+pub const DEVELOPER_TERMS_VERSION: &str = "2026-08-28";
+
+/// Where a publisher reads the obligations it is accepting. Named in every
+/// refusal this module produces, so an agent can tell the user what the
+/// application's developer would have to do. The page is served from the
+/// landing site, which is where every human-facing page moved; this origin's
+/// own `/developer-terms` permanently redirects there, so either spelling
+/// reaches it.
+pub const DEVELOPER_TERMS_URL: &str = "https://internetcomputer.org/icp-mcp/developer-terms/";
+
+/// One application whose publisher has accepted the ICP MCP Developer Terms.
+pub struct RegisteredApplication {
+ /// The application origin, in canonical form — exactly what
+ /// [`discover::normalize_origin`] produces (https, lowercased host,
+ /// default port dropped, no path, no user-info). Pinned by a test.
+ ///
+ /// Keyed by **origin**, not by host — deliberately unlike
+ /// [`crate::discover`]'s host-keyed derivation-origin registry: an
+ /// acceptance is for the exact origin whose manifest was reviewed, and a
+ /// different port is a different deployment that must not inherit it.
+ pub origin: &'static str,
+ /// Who accepted, for the audit trail.
+ pub publisher: &'static str,
+ /// The Developer Terms revision they accepted. Authorizes writes only
+ /// while it equals [`DEVELOPER_TERMS_VERSION`].
+ pub accepted_terms_version: &'static str,
+ /// When the acceptance was recorded (ISO date).
+ pub accepted_on: &'static str,
+ /// The canisters REVIEWED at registration, pinned here. A call must clear
+ /// both this list and the application's live manifest, so the manifest can
+ /// **narrow** the surface (dropping a canister stops writes to it at once,
+ /// on the app's own signal) but can never **widen** it: a publisher that
+ /// later adds a canister — its own, someone else's, or one it was
+ /// compromised into listing — gains nothing until a reviewed change adds it
+ /// here too. Without this pin, "reviewed at registration" would mean
+ /// reviewed against a document the registrant can rewrite at will.
+ pub canisters: &'static [&'static str],
+ /// The Internet Identity derivation origins this application legitimately
+ /// acts as, in the CANONICAL EFFECTIVE form [`crate::identities::target_origin`]
+ /// produces (so the gateway remap is already applied). A call passing a
+ /// `derivation_origin` outside this list is refused.
+ ///
+ /// This is what stops one registered origin from borrowing another app's
+ /// identity: nothing else ties `application_origin` to `derivation_origin`
+ /// — they are separate arguments resolved independently — so without it a
+ /// registered application could have the server sign a call to a canister
+ /// it lists as the user's principal AT AN UNRELATED APP, which that
+ /// canister may well trust. Usually one entry, equal to `origin`.
+ pub derivation_origins: &'static [&'static str],
+}
+
+/// Applications whose publishers have accepted the ICP MCP Developer Terms,
+/// and whose update surface is therefore reachable through this server.
+///
+/// **This table is empty, and that is the shipped default.** An empty registry
+/// means no application can receive an update call through this server — the
+/// gate fails closed for everyone until a publisher actually accepts the
+/// Developer Terms and is added here. Adding a row is a reviewed change to
+/// this file, which is also the audit record: who accepted, which revision,
+/// and when.
+///
+/// Before adding a row, confirm — and record in the review — that:
+///
+/// * the publisher accepted revision [`DEVELOPER_TERMS_VERSION`], including
+/// the clauses that it is entitled to expose every canister its manifest
+/// lists and that its MCP-reachable operations stay inside this server's
+/// financial and data policies;
+/// * the origin is exactly the one whose `/.well-known/ic-architecture` was
+/// reviewed, in canonical form;
+/// * every id in `canisters` is one the publisher operates, taken from the
+/// manifest as reviewed — not copied from it unread;
+/// * every entry in `derivation_origins` is an origin this application really
+/// derives against (check `resolve_app`'s `derivation_origin` for it), in
+/// the canonical effective form.
+///
+/// One row is one ORIGIN. An application served at several origins (its own
+/// domain and its `.icp0.io` gateway origin, say) needs a row per
+/// origin it will be called with, each with a manifest at that origin — an
+/// acceptance is not inherited across origins, and neither is a manifest.
+///
+/// **Revocation is removal**: deleting a row (or bumping
+/// [`DEVELOPER_TERMS_VERSION`] past what a row carries) closes the gate for
+/// that application from the first call after the change is deployed. This
+/// table is compiled in, so revocation is a release, not a runtime switch;
+/// what "no cache" buys is that nothing survives the release — there is no
+/// TTL to wait out and no state to reconcile.
+///
+/// A row pins what was reviewed: `canisters` and `derivation_origins`. The live
+/// manifest can only ever NARROW that pin, never widen it, so a publisher who
+/// later edits its manifest — or is compromised into editing it — cannot grant
+/// itself a canister nobody reviewed, and cannot have the server act as the
+/// user's identity at an application it does not own. The Developer Terms carry
+/// the matching promise (that the publisher may expose everything it lists), and
+/// removing the row remains the remedy; but the pins mean the code no longer
+/// depends on that promise holding.
+pub const REGISTERED_APPLICATIONS: &[RegisteredApplication] = &[];
+
+/// What authorized a call, echoed back to the caller so an agent can see
+/// exactly which application and which declared canister it acted on — and
+/// catch an `application_origin` that resolved to the wrong app.
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct Authorization {
+ /// The canonical application origin the call was authorized against.
+ pub application_origin: String,
+ /// How the application's own manifest describes the target canister
+ /// (`name`/`role`/`description`, folded); `None` when it declares the id
+ /// with no labels.
+ pub canister_role: Option,
+}
+
+/// The registered application at `origin`, looked up in `registry`. Both sides
+/// are canonical origins, so the comparison is exact — no host-only match, no
+/// case or port slack. Split from [`registration`] so tests can exercise the
+/// lookup against their own table instead of the shipped one.
+fn registration_in<'a>(
+ registry: &'a [RegisteredApplication],
+ origin: &str,
+) -> Option<&'a RegisteredApplication> {
+ registry.iter().find(|a| a.origin == origin)
+}
+
+/// The refusal for a call that arrived with no `application_origin`. Says what
+/// to pass, where to get it, and — because an agent holding a
+/// `derivation_origin` will otherwise try it here — why that is not the same
+/// value.
+fn missing_application_origin() -> String {
+ format!(
+ "`application_origin` is required for an update call and was not supplied. An update \
+ call is authorized against the application it belongs to: pass the application's https \
+ origin (e.g. `https://example.com` — scheme and host, no path), as returned by \
+ open_app / resolve_app in `application_origin`. This is NOT the same value as \
+ `derivation_origin`: several frontends can share one derivation origin, and the \
+ `{ARCHITECTURE_WELL_KNOWN}` manifest that authorizes the call is served at the \
+ application origin. Reads (canister_query) need no application origin — only \
+ state-changing calls do."
+ )
+}
+
+/// The refusal for an origin with no current acceptance on file. Deliberately
+/// says the same thing whether the origin is absent or carries a stale
+/// revision — both mean "no current acceptance", and the recovery is identical.
+fn not_registered(registry: &[RegisteredApplication], origin: &str) -> String {
+ // When NOTHING is registered, say so: otherwise an agent reads a
+ // single-origin refusal as "try another origin" and burns a loop
+ // rediscovering the same answer.
+ let scope = if registry.is_empty() {
+ concat!(
+ " No applications are registered with this server at present, so this is the",
+ " answer for every application — do not retry with a different origin or",
+ " canister id."
+ )
+ } else {
+ ""
+ };
+ format!(
+ "Update calls to {origin} are not available: its developer has not accepted the current \
+ ICP MCP Developer Terms (revision {DEVELOPER_TERMS_VERSION}). State-changing calls \
+ through this server are limited to applications that publish a \
+ `{ARCHITECTURE_WELL_KNOWN}` manifest under the ICP service-discoverability protocol AND \
+ whose developer has accepted those Terms — everything else is refused, including \
+ canisters this server can otherwise discover behind the domain.{scope} Reading the \
+ application is unaffected: use canister_query (and the OQL tools) instead. If you are \
+ the application's developer, the Terms and how to register are at {DEVELOPER_TERMS_URL}."
+ )
+}
+
+/// Steps 2–3, offline: turn a caller-supplied `application_origin` into a
+/// registered application with a current acceptance, or a refusal.
+///
+/// Note the order: the registry is consulted BEFORE anything is fetched, so a
+/// caller can never steer this server's HTTP client at an origin of its
+/// choosing — the only origins ever fetched are ones already curated into
+/// [`REGISTERED_APPLICATIONS`].
+fn authorized_origin_in<'a>(
+ registry: &'a [RegisteredApplication],
+ application_origin: Option<&str>,
+) -> Result<(String, &'a RegisteredApplication), String> {
+ // 2. The argument is required. An empty or whitespace-only string counts
+ // as absent, so a client that "passes" the field blank gets the
+ // instructive refusal rather than an origin-not-registered one.
+ let raw = application_origin.map(str::trim).filter(|s| !s.is_empty());
+ let Some(raw) = raw else {
+ return Err(missing_application_origin());
+ };
+ // Canonicalize with the same function every site fetch in this crate uses:
+ // https only, real host, no user-info, default port dropped. NOT
+ // `identities::target_origin` — that one remaps gateway domains for
+ // IDENTITY derivation, which would fetch the manifest from a different
+ // host than the caller named.
+ let Some(origin) = discover::normalize_origin(raw) else {
+ return Err(format!(
+ "`application_origin` must be an https origin (scheme + host, e.g. \
+ `https://example.com`); {raw:?} is not one. Pass the application origin from \
+ open_app / resolve_app."
+ ));
+ };
+ // 3. A current acceptance of the Developer Terms, on the exact origin.
+ let app = registration_in(registry, &origin)
+ .ok_or_else(|| not_registered(registry, &origin))?;
+ if app.accepted_terms_version != DEVELOPER_TERMS_VERSION {
+ return Err(not_registered(registry, &origin));
+ }
+ Ok((origin, app))
+}
+
+/// Steps 4–5, pure: decide against a manifest that has already been fetched
+/// (or failed to be). Separated from the fetch so the whole decision is
+/// testable offline — the fetch itself adds no policy.
+fn decide(
+ app: &RegisteredApplication,
+ application_origin: &str,
+ fetched: &ArchitectureFetch,
+ canister_id: &Principal,
+) -> Result {
+ // 4. The exact origin's manifest. Both failure modes deny; they differ
+ // only in what the developer would have to fix, so say which it is
+ // (a fetch failure is worth retrying, a denial is not).
+ let arch: &Architecture = match fetched {
+ ArchitectureFetch::Served(arch) => arch,
+ ArchitectureFetch::Unreachable(why) => {
+ return Err(format!(
+ "Update calls to {application_origin} could not be authorized: its \
+ `{ARCHITECTURE_WELL_KNOWN}` manifest could not be read ({why}). The manifest \
+ is re-read on every state-changing call and no call proceeds without it, so \
+ this is worth retrying; if it keeps failing, the application's origin is not \
+ serving the manifest reachably. Reads are unaffected — use canister_query."
+ ))
+ }
+ ArchitectureFetch::NotDeclared(why) => {
+ return Err(format!(
+ "Update calls to {application_origin} are not available: {why}. Under the ICP \
+ service-discoverability protocol an application declares the canisters it \
+ comprises in `{ARCHITECTURE_WELL_KNOWN}`, and this server authorizes a \
+ state-changing call only against that declaration. Reads are unaffected — use \
+ canister_query."
+ ))
+ }
+ };
+ // 5. The target must be one of the canisters the application declares.
+ // This is the step that makes discovery non-authorizing: an id mined
+ // from a bundle, an `/env.json`, or a response header reaches this
+ // check with no standing whatsoever.
+ // 5a. The pin from the registration review. Checked BEFORE the manifest so
+ // an id nobody reviewed is refused as unreviewed, whatever the live
+ // manifest now says about it — the manifest may narrow this list, never
+ // widen it.
+ let pinned = canister_id.to_text();
+ if !app.canisters.iter().any(|c| *c == pinned) {
+ return Err(format!(
+ "{canister_id} is not among the canisters reviewed for {application_origin}. A \
+ state-changing call is authorized only against the canisters recorded when the \
+ application was registered — an application cannot widen that set by editing its \
+ own `{ARCHITECTURE_WELL_KNOWN}` manifest afterwards. If the application has added a \
+ canister, its developer needs it reviewed and recorded ({DEVELOPER_TERMS_URL}). \
+ Reading this canister is unaffected — use canister_query."
+ ));
+ }
+ // 5b. …and the application's LIVE manifest must still declare it, so
+ // removing it from the manifest stops writes at once.
+ let Some(canister_role) = arch.role_of(canister_id) else {
+ let declared = declared_ids(arch);
+ return Err(format!(
+ "{canister_id} is not declared by {application_origin}: its \
+ `{ARCHITECTURE_WELL_KNOWN}` manifest lists {declared}. A state-changing call is \
+ authorized only against the application's own declaration — finding a canister id \
+ behind a domain some other way (a response header, an `/env.json`, a JS bundle) \
+ does not authorize writing to it. Check the canister id, or call the application \
+ origin that does declare it. Reading this canister is unaffected — use \
+ canister_query."
+ ));
+ };
+ Ok(Authorization {
+ application_origin: application_origin.to_string(),
+ canister_role,
+ })
+}
+
+/// The declared ids, for the "not declared" refusal — bounded so a large
+/// manifest can't turn one refusal into a wall of principals.
+fn declared_ids(arch: &Architecture) -> String {
+ const MAX_LISTED: usize = 12;
+ let ids: Vec = arch.findings().into_iter().map(|(id, _)| id).collect();
+ if ids.is_empty() {
+ return "no canisters".to_string();
+ }
+ if ids.len() > MAX_LISTED {
+ format!(
+ "{} (and {} more)",
+ ids[..MAX_LISTED].join(", "),
+ ids.len() - MAX_LISTED
+ )
+ } else {
+ ids.join(", ")
+ }
+}
+
+/// The whole gate: steps 1–6 for one update call. `Ok` means the call is
+/// authorized and may execute; `Err` is the complete refusal text for the
+/// caller. Fails closed at every step.
+///
+/// A thin binding of [`authorize_with`] to the shipped registry and the real
+/// manifest fetch. The policy itself lives there, and the tests drive THAT
+/// function — with their own registry and their own fetch — so no test
+/// re-implements the chain this function walks.
+pub async fn authorize_update_call(
+ application_origin: Option<&str>,
+ derivation_origin: Option<&str>,
+ canister_id: &Principal,
+ method: &str,
+) -> Result {
+ authorize_with(
+ REGISTERED_APPLICATIONS,
+ application_origin,
+ derivation_origin,
+ canister_id,
+ method,
+ |origin| async move { architecture::fetch_architecture(&origin).await },
+ )
+ .await
+}
+
+/// The gate's six steps, with the registry and the manifest fetch injected.
+///
+/// `fetch` is called with the canonical application origin, and — this is a
+/// property, not an implementation detail — is called at most once, and ONLY
+/// after steps 1–3 have passed. That is what keeps the set of origins this
+/// server will ever fetch from equal to the curated registry: a caller cannot
+/// make it request an origin of their choosing, whatever they pass.
+async fn authorize_with(
+ registry: &[RegisteredApplication],
+ application_origin: Option<&str>,
+ derivation_origin: Option<&str>,
+ canister_id: &Principal,
+ method: &str,
+ fetch: F,
+) -> Result
+where
+ F: FnOnce(String) -> Fut,
+ Fut: std::future::Future,
+{
+ // 1. Layer 2 first: offline, and the more specific refusal for the request
+ // the caller actually made (see the module docs). A value-moving call is
+ // therefore answered without reaching the network or the registry at all.
+ if let Some(refusal) = compliance::disallowed_update_method(canister_id, method) {
+ return Err(refusal);
+ }
+ // 2–3. The argument, and a current acceptance on that exact origin.
+ let (origin, app) = authorized_origin_in(registry, application_origin)?;
+ // 3a. The identity the call would be signed as must be one this application
+ // actually acts as. `application_origin` and `derivation_origin` are
+ // resolved independently and nothing else relates them, so without this
+ // a registered application could have the server sign a call to a
+ // canister it lists as the user's principal at an UNRELATED app — an
+ // identity that app's canisters may trust. Offline, and before the fetch.
+ if let Some(acting_as) = derivation_origin {
+ if !app.derivation_origins.contains(&acting_as) {
+ return Err(format!(
+ "{origin} does not act as the identity {acting_as}. An update call is signed as \
+ your account at the application being called, so `derivation_origin` must be one \
+ of the origins recorded for {origin} when it was registered — pairing one \
+ application's origin with another application's identity is refused. Use the \
+ `derivation_origin` that open_app / resolve_app returns for {origin}, or omit it \
+ to call anonymously. Reads are unaffected."
+ ));
+ }
+ }
+ // 4–5. The manifest, fetched fresh with no cache — so a manifest change or
+ // a revocation takes effect on the next call, not at the end of a TTL.
+ let fetched = fetch(origin.clone()).await;
+ let authorization = match decide(app, &origin, &fetched, canister_id) {
+ Ok(a) => a,
+ Err(refusal) => {
+ // Refused after the caller cleared registration: the operator's
+ // signal that a REGISTERED application's manifest is unreadable or
+ // has stopped declaring a canister its users are calling. The
+ // refusal text goes to the caller; this is the operational half.
+ tracing::info!(
+ application_origin = %origin,
+ publisher = %app.publisher,
+ canister_id = %canister_id,
+ method = %method,
+ "refused an update call at a registered application's manifest"
+ );
+ return Err(refusal);
+ }
+ };
+ // 6. One line per authorized write, naming what authorized it: the
+ // operator's record of which registration admitted a state-changing call.
+ tracing::info!(
+ application_origin = %origin,
+ publisher = %app.publisher,
+ terms_version = %app.accepted_terms_version,
+ accepted_on = %app.accepted_on,
+ canister_id = %canister_id,
+ method = %method,
+ "authorized an update call against a registered application"
+ );
+ Ok(authorization)
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::architecture::parse_architecture;
+
+ // A registry standing in for the shipped one. Tests must not depend on
+ // curation: the shipped table is empty by design, and a real acceptance is
+ // a legal fact, not a fixture.
+ const TEST_REGISTRY: &[RegisteredApplication] = &[
+ RegisteredApplication {
+ origin: "https://example-app.test",
+ publisher: "Example App GmbH",
+ accepted_terms_version: DEVELOPER_TERMS_VERSION,
+ accepted_on: "2026-08-28",
+ canisters: &[APP_BACKEND, APP_FRONTEND, ICP_LEDGER],
+ derivation_origins: &["https://example-app.test"],
+ },
+ RegisteredApplication {
+ origin: "https://stale-app.test",
+ publisher: "Stale App GmbH",
+ accepted_terms_version: "2026-01-01",
+ accepted_on: "2026-01-01",
+ canisters: &[APP_BACKEND],
+ derivation_origins: &["https://stale-app.test"],
+ },
+ ];
+
+ const REGISTERED: &str = "https://example-app.test";
+
+ // Deliberately NOT the ids from the spec's example manifest: those belong
+ // to a real exchange and are on the finance list, so Layer 2 would refuse
+ // them and mask what these tests are checking. These are ordinary app
+ // canisters on no list.
+ const APP_BACKEND: &str = "dmp3l-2yaaa-aaaae-aamva-cai";
+ const APP_FRONTEND: &str = "bkyz2-fmaaa-aaaaa-qaaaq-cai";
+ const ICP_LEDGER: &str = "ryjl3-tyaaa-aaaaa-aaaba-cai";
+
+ fn p(s: &str) -> Principal {
+ Principal::from_text(s).unwrap()
+ }
+
+ /// A served manifest declaring exactly `ids`.
+ fn manifest(ids: &[&str]) -> ArchitectureFetch {
+ let entries: Vec = ids
+ .iter()
+ .map(|id| format!(r#"{{"id":"{id}","name":"backend","role":"the backend"}}"#))
+ .collect();
+ let body = format!(r#"{{"version":"1.0.0","canisters":[{}]}}"#, entries.join(","));
+ ArchitectureFetch::Served(parse_architecture(&body).expect("fixture manifest parses"))
+ }
+
+ /// A served manifest from a literal body.
+ fn served(body: &str) -> ArchitectureFetch {
+ ArchitectureFetch::Served(parse_architecture(body).expect("fixture parses"))
+ }
+
+ /// Drive the PRODUCTION gate — [`authorize_with`], the very function
+ /// [`authorize_update_call`] binds — against a chosen registry and a canned
+ /// manifest, reporting how many times the fetch was reached. No test
+ /// re-implements the chain, so a step added to or reordered in the gate
+ /// cannot slip past these.
+ async fn gate(
+ registry: &[RegisteredApplication],
+ application_origin: Option<&str>,
+ fetched: ArchitectureFetch,
+ canister_id: &Principal,
+ method: &str,
+ ) -> (Result, usize) {
+ gate_as(registry, application_origin, None, fetched, canister_id, method).await
+ }
+
+ /// As [`gate`], but signing as a specific derivation origin.
+ async fn gate_as(
+ registry: &[RegisteredApplication],
+ application_origin: Option<&str>,
+ derivation_origin: Option<&str>,
+ fetched: ArchitectureFetch,
+ canister_id: &Principal,
+ method: &str,
+ ) -> (Result, usize) {
+ let fetches = std::rc::Rc::new(std::cell::Cell::new(0usize));
+ let counter = std::rc::Rc::clone(&fetches);
+ let result = authorize_with(
+ registry,
+ application_origin,
+ derivation_origin,
+ canister_id,
+ method,
+ |origin| async move {
+ counter.set(counter.get() + 1);
+ assert_eq!(
+ discover::normalize_origin(&origin).as_deref(),
+ Some(origin.as_str()),
+ "the gate must hand the fetch a canonical origin"
+ );
+ fetched
+ },
+ )
+ .await;
+ (result, fetches.get())
+ }
+
+ /// The common case: the gate over [`TEST_REGISTRY`], result only.
+ async fn authorize(
+ application_origin: Option<&str>,
+ fetched: ArchitectureFetch,
+ canister_id: &Principal,
+ method: &str,
+ ) -> Result {
+ gate(TEST_REGISTRY, application_origin, fetched, canister_id, method).await.0
+ }
+
+ // (c) The happy path: a registered application, a canister its own manifest
+ // declares, an ordinary method — authorized, the echo names what authorized
+ // it, and the manifest was actually read.
+ #[tokio::test]
+ async fn registered_app_can_update_a_declared_canister() {
+ let (result, fetches) = gate(
+ TEST_REGISTRY,
+ Some(REGISTERED),
+ manifest(&[APP_FRONTEND, APP_BACKEND]),
+ &p(APP_BACKEND),
+ "place_order",
+ )
+ .await;
+ let auth = result.expect("a registered app's declared canister must be authorized");
+ assert_eq!(auth.application_origin, REGISTERED);
+ assert_eq!(auth.canister_role.as_deref(), Some("the backend (backend)"));
+ assert_eq!(fetches, 1, "the manifest is read once per call, not zero or twice");
+ }
+
+ // The origin argument is canonicalized before the lookup, so the same
+ // application reached with a differently-spelled origin still authorizes —
+ // and a non-https or malformed value is refused outright rather than
+ // silently upgraded.
+ #[tokio::test]
+ async fn application_origin_is_canonicalized_then_matched_exactly() {
+ for spelling in [
+ REGISTERED,
+ "HTTPS://Example-App.TEST",
+ "https://example-app.test:443",
+ " https://example-app.test/ ",
+ "example-app.test", // bare host: https is prepended
+ ] {
+ let r = authorize(Some(spelling), manifest(&[APP_BACKEND]), &p(APP_BACKEND), "ping")
+ .await;
+ assert!(r.is_ok(), "{spelling} must resolve to the registered origin");
+ }
+ for bad in [
+ "http://example-app.test", // not https
+ "https://user@example-app.test", // user-info
+ "https://example-app.test:8443", // a different origin, not registered
+ "not a url",
+ ] {
+ let (r, fetches) = gate(
+ TEST_REGISTRY,
+ Some(bad),
+ manifest(&[APP_BACKEND]),
+ &p(APP_BACKEND),
+ "ping",
+ )
+ .await;
+ assert!(r.is_err(), "{bad} must not authorize");
+ assert_eq!(fetches, 0, "{bad} must not become a fetch target");
+ }
+ }
+
+ // (a) Provenance cannot authorize. A canister the application does not
+ // declare is refused however this server found it — the gate never sees a
+ // `sources` list (its signature has no way to receive one), so a bundle
+ // literal, an `/env.json` key, a response header, or any other heuristic
+ // has no path to an authorization.
+ #[tokio::test]
+ async fn only_the_manifest_authorizes_never_discovery() {
+ // Stand in for the OISY backend as this server really discovers it: from
+ // a labelled JS-bundle constant and the gateway header, never from a
+ // manifest. It is a real canister, reachable, and the app it belongs to
+ // is not the one being called.
+ let mined = p("be2us-64aaa-aaaaa-qaabq-cai");
+ // Neither reviewed nor declared: refused, and the id is named so the
+ // caller can see which canister it asked for.
+ let msg = authorize(
+ Some(REGISTERED),
+ manifest(&[APP_FRONTEND, APP_BACKEND]),
+ &mined,
+ "set_name",
+ )
+ .await
+ .expect_err("an unreviewed, undeclared canister must be refused");
+ assert!(msg.contains(&mined.to_text()), "the refusal names the id: {msg}");
+ // Even if the application ADDS it to its live manifest — the case a
+ // mined id most plausibly reaches — the registration pin still refuses.
+ let msg = authorize(
+ Some(REGISTERED),
+ manifest(&[APP_BACKEND, "be2us-64aaa-aaaaa-qaabq-cai"]),
+ &mined,
+ "set_name",
+ )
+ .await
+ .expect_err("declaring it after review must not authorize it");
+ assert!(msg.contains("not among the canisters reviewed"), "{msg}");
+ // A REVIEWED canister the application does not declare is refused too,
+ // by the manifest half — so the two checks are independent, and the
+ // "provenance authorizes nothing" property does not rest on either alone.
+ let msg = authorize(Some(REGISTERED), manifest(&[APP_BACKEND]), &p(APP_FRONTEND), "set_name")
+ .await
+ .expect_err("reviewed but undeclared must be refused");
+ assert!(msg.contains(ARCHITECTURE_WELL_KNOWN), "the manifest path: {msg}");
+ assert!(msg.contains("does not authorize writing"), "and why: {msg}");
+ // An application declaring NOTHING authorizes nothing.
+ assert!(authorize(Some(REGISTERED), manifest(&[]), &p(APP_BACKEND), "set_name")
+ .await
+ .is_err());
+ }
+
+ // Membership is decided on parsed principals, so no spelling of a declared
+ // id can be mistaken for a different canister — and a padded entry still
+ // matches the canister it names.
+ #[tokio::test]
+ async fn membership_compares_parsed_principals() {
+ let padded = served(&format!(
+ r#"{{"version":"1.0.0","canisters":[{{"id":" {APP_BACKEND} "}}]}}"#
+ ));
+ assert!(
+ authorize(Some(REGISTERED), padded, &p(APP_BACKEND), "ping").await.is_ok(),
+ "a padded declaration still names its canister"
+ );
+ // An entry that is not a principal at all authorizes nothing, even
+ // though its text is a prefix of a real id.
+ let junk = served(r#"{"version":"1.0.0","canisters":[{"id":"dmp3l-2yaaa"}]}"#);
+ assert!(
+ authorize(Some(REGISTERED), junk, &p(APP_BACKEND), "ping").await.is_err(),
+ "a non-principal entry must not authorize a lookalike"
+ );
+ }
+
+ // (b) A canister that IS declared still gets no write access when the
+ // application's developer has no current Terms acceptance — and the registry
+ // is consulted BEFORE any fetch, which is what keeps the set of origins this
+ // server will fetch from equal to the curated registry.
+ #[tokio::test]
+ async fn declared_but_unaccepted_terms_does_not_authorize() {
+ let (result, fetches) = gate(
+ TEST_REGISTRY,
+ Some("https://unregistered.test"),
+ manifest(&[APP_BACKEND]),
+ &p(APP_BACKEND),
+ "place_order",
+ )
+ .await;
+ let msg = result.expect_err("an unregistered origin must be refused");
+ assert!(msg.contains("Developer Terms"), "{msg}");
+ assert!(msg.contains(DEVELOPER_TERMS_VERSION), "{msg}");
+ assert!(msg.contains("canister_query"), "reads stay available: {msg}");
+ assert_eq!(
+ fetches, 0,
+ "an unregistered origin must never be fetched — that is what keeps the fetch \
+ target curated rather than caller-chosen"
+ );
+ }
+
+ // (e) A Terms bump closes the gate for a stale acceptance: the check is
+ // equality against the current revision, not "has ever accepted".
+ #[tokio::test]
+ async fn a_stale_terms_acceptance_does_not_authorize() {
+ assert_ne!(
+ registration_in(TEST_REGISTRY, "https://stale-app.test")
+ .expect("the row exists")
+ .accepted_terms_version,
+ DEVELOPER_TERMS_VERSION,
+ "fixture must carry an old revision"
+ );
+ let (result, fetches) = gate(
+ TEST_REGISTRY,
+ Some("https://stale-app.test"),
+ manifest(&[APP_BACKEND]),
+ &p(APP_BACKEND),
+ "place_order",
+ )
+ .await;
+ let msg = result.expect_err("a stale acceptance must be refused");
+ assert!(msg.contains(DEVELOPER_TERMS_VERSION), "{msg}");
+ assert_eq!(fetches, 0, "a stale row is not a fetch target either");
+ }
+
+ // (e) Revocation is removal, and it takes effect on the next call: the same
+ // origin and canister, authorized against a registry that still holds the
+ // row, refused against one that no longer does.
+ #[tokio::test]
+ async fn revocation_closes_the_gate_on_the_next_call() {
+ let call = |registry: &'static [RegisteredApplication]| async move {
+ gate(registry, Some(REGISTERED), manifest(&[APP_BACKEND]), &p(APP_BACKEND), "ping")
+ .await
+ .0
+ };
+ assert!(call(TEST_REGISTRY).await.is_ok(), "registered while the row is present");
+ let msg = call(&[]).await.expect_err("removing the row refuses the very next call");
+ assert!(msg.contains("Developer Terms"), "{msg}");
+ }
+
+ // (e) A manifest change takes effect on the next call: the decision is a
+ // function of the manifest read during THAT call, with nothing memoized
+ // between calls. If a cache is ever added, this test becomes its TTL
+ // contract and must advance a clock.
+ #[tokio::test]
+ async fn a_manifest_that_drops_the_canister_stops_authorizing() {
+ assert!(
+ authorize(Some(REGISTERED), manifest(&[APP_BACKEND]), &p(APP_BACKEND), "place_order")
+ .await
+ .is_ok(),
+ "declared: authorized"
+ );
+ let msg =
+ authorize(Some(REGISTERED), manifest(&[APP_FRONTEND]), &p(APP_BACKEND), "place_order")
+ .await
+ .expect_err("dropped from the manifest: refused");
+ assert!(msg.contains("is not declared by"), "{msg}");
+ // And back again, so the second result is the new manifest talking
+ // rather than a one-way latch.
+ assert!(
+ authorize(Some(REGISTERED), manifest(&[APP_BACKEND]), &p(APP_BACKEND), "place_order")
+ .await
+ .is_ok(),
+ "re-declared: authorized again"
+ );
+ }
+
+ // (e) Fail closed when the manifest cannot be read at all — and say so
+ // distinguishably, since a fetch failure is worth retrying while a denial
+ // is not.
+ #[tokio::test]
+ async fn an_unreadable_manifest_fails_closed_and_says_it_is_retryable() {
+ let msg = authorize(
+ Some(REGISTERED),
+ ArchitectureFetch::Unreachable("dns failure".into()),
+ &p(APP_BACKEND),
+ "place_order",
+ )
+ .await
+ .expect_err("an unreachable manifest must refuse");
+ assert!(msg.contains("could not be read"), "{msg}");
+ assert!(msg.contains("worth retrying"), "{msg}");
+
+ let msg = authorize(
+ Some(REGISTERED),
+ ArchitectureFetch::NotDeclared("answered 404".into()),
+ &p(APP_BACKEND),
+ "place_order",
+ )
+ .await
+ .expect_err("a missing manifest must refuse");
+ assert!(msg.contains("answered 404"), "{msg}");
+ assert!(!msg.contains("worth retrying"), "a denial is not a retry: {msg}");
+ }
+
+ // (d) Layer 2 still refuses inside the authorized surface. Registration buys
+ // an application access to its OWN declared canisters; it does not make a
+ // value-moving call acceptable — even when the application declares the
+ // ledger in its own manifest. Layer 2 is also evaluated FIRST, so the caller
+ // gets the do-it-yourself redirect rather than a registration message, and
+ // the call costs no fetch.
+ #[tokio::test]
+ async fn the_financial_guard_still_refuses_inside_an_authorized_surface() {
+ // A standardized transfer is refused on any canister…
+ let (result, fetches) = gate(
+ TEST_REGISTRY,
+ Some(REGISTERED),
+ manifest(&[APP_BACKEND, ICP_LEDGER]),
+ &p(APP_BACKEND),
+ "icrc1_transfer",
+ )
+ .await;
+ let msg = result.expect_err("a standardized transfer must stay refused");
+ assert!(msg.contains("icrc1_transfer"), "{msg}");
+ assert!(
+ msg.contains("outside this connector, in a trusted interface they control"),
+ "the redirect sends the user outside this connector: {msg}"
+ );
+ assert_eq!(fetches, 0, "a value-moving call is refused without reaching the network");
+ // …and every update on a known finance canister is refused, whatever the
+ // application says about it.
+ let msg = authorize(
+ Some(REGISTERED),
+ manifest(&[APP_BACKEND, ICP_LEDGER]),
+ &p(ICP_LEDGER),
+ "transfer",
+ )
+ .await
+ .expect_err("the ledger must stay refused");
+ assert!(msg.contains("the ICP ledger"), "{msg}");
+ // The same non-financial method on the app's own canister is fine, so
+ // the refusals above are Layer 2 talking, not Layer 1.
+ assert!(authorize(
+ Some(REGISTERED),
+ manifest(&[APP_BACKEND, ICP_LEDGER]),
+ &p(APP_BACKEND),
+ "place_order"
+ )
+ .await
+ .is_ok());
+ }
+
+ // Layer 2 runs before the registration checks, so a value-moving request is
+ // answered the same way whether or not the named application is registered:
+ // the caller learns to use their own wallet, and learns nothing about the
+ // registry by probing with one.
+ #[tokio::test]
+ async fn the_financial_refusal_does_not_depend_on_registration() {
+ for origin in [Some(REGISTERED), Some("https://unregistered.test"), None] {
+ let msg = authorize(origin, manifest(&[ICP_LEDGER]), &p(ICP_LEDGER), "icrc1_transfer")
+ .await
+ .expect_err("a transfer must be refused whatever the origin");
+ assert!(
+ msg.contains("icrc1_transfer") && !msg.contains("Developer Terms"),
+ "{origin:?} must get the financial refusal, not a registration one: {msg}"
+ );
+ }
+ }
+
+ // (1) The argument is mandatory, and the refusal teaches the recovery —
+ // including that the derivation origin is a different value, which is the
+ // mistake an agent holding one will otherwise make.
+ #[tokio::test]
+ async fn a_missing_application_origin_is_refused_with_the_recovery() {
+ for missing in [None, Some(""), Some(" ")] {
+ let msg = authorize(missing, manifest(&[APP_BACKEND]), &p(APP_BACKEND), "ping")
+ .await
+ .expect_err("an absent application_origin must be refused");
+ assert!(msg.contains("`application_origin` is required"), "{msg}");
+ assert!(msg.contains("derivation_origin"), "names the confusable value: {msg}");
+ assert!(msg.contains("open_app"), "says where to get it: {msg}");
+ }
+ }
+
+ // The shipped registry is well-formed: canonical origins, no duplicates, no
+ // blank fields, and no row carrying a revision other than the current one (a
+ // stale row is dead weight that reads as authorization). Vacuously true while
+ // the table is empty — which is the shipped default, so this test is the
+ // guard for the day rows are added.
+ #[test]
+ fn the_shipped_registry_is_well_formed() {
+ let mut seen: Vec<&str> = Vec::new();
+ for app in REGISTERED_APPLICATIONS {
+ assert_eq!(
+ discover::normalize_origin(app.origin).as_deref(),
+ Some(app.origin),
+ "{}: origins must be stored in canonical form",
+ app.origin
+ );
+ assert!(!seen.contains(&app.origin), "{}: duplicate row", app.origin);
+ seen.push(app.origin);
+ assert!(!app.publisher.trim().is_empty(), "{}: publisher required", app.origin);
+ assert!(!app.accepted_on.trim().is_empty(), "{}: acceptance date required", app.origin);
+ // A row with no pinned canisters authorizes nothing, so it is dead
+ // weight that reads like a registration; an empty derivation-origin
+ // list means the app can only ever be called anonymously, which is
+ // almost certainly an oversight rather than an intent.
+ assert!(
+ !app.canisters.is_empty(),
+ "{}: pin the canisters reviewed at registration, or remove the row",
+ app.origin
+ );
+ assert!(
+ !app.derivation_origins.is_empty(),
+ "{}: record the derivation origin(s) this application acts as",
+ app.origin
+ );
+ for id in app.canisters {
+ let parsed = Principal::from_text(id)
+ .unwrap_or_else(|e| panic!("{}: pinned id {id:?}: {e}", app.origin));
+ assert_eq!(
+ &parsed.to_text(),
+ id,
+ "{}: pinned ids must be in canonical text form",
+ app.origin
+ );
+ }
+ for d in app.derivation_origins {
+ // Stored in the canonical EFFECTIVE form, so the comparison in
+ // the gate — which receives an already-remapped origin — matches.
+ assert_eq!(
+ &crate::identities::target_origin(d),
+ d,
+ "{}: derivation origins must be stored in canonical effective form",
+ app.origin
+ );
+ }
+ assert_eq!(
+ app.accepted_terms_version, DEVELOPER_TERMS_VERSION,
+ "{}: a row that does not carry the current Terms revision authorizes nothing — \
+ re-stamp it after the publisher accepts, or remove it",
+ app.origin
+ );
+ }
+ }
+
+ // An EMPTY shipped registry authorizes nothing at all — the state this
+ // server ships in. Pinned so "the gate fails closed for everyone until a
+ // publisher is added" is a tested property rather than a claim in a doc
+ // comment, and so a future default-allow path cannot creep in unnoticed.
+ // The refusal also SAYS the registry is empty, so an agent stops instead of
+ // looping over other origins and canister ids to reach the same answer.
+ #[tokio::test]
+ async fn an_empty_registry_authorizes_nothing_and_says_so() {
+ for origin in [Some(REGISTERED), Some("https://anything.test")] {
+ let (result, fetches) =
+ gate(&[], origin, manifest(&[APP_BACKEND]), &p(APP_BACKEND), "place_order").await;
+ let msg = result.expect_err("must be refused against an empty registry");
+ assert!(
+ msg.contains("No applications are registered"),
+ "{origin:?}: the refusal must say the registry is empty: {msg}"
+ );
+ assert!(msg.contains("do not retry"), "{origin:?}: and say not to loop: {msg}");
+ assert_eq!(fetches, 0, "{origin:?} must not be fetched");
+ }
+ // A missing argument still gets the argument's own refusal, not the
+ // empty-registry one — the caller's first problem is the one to fix.
+ let msg = gate(&[], None, manifest(&[APP_BACKEND]), &p(APP_BACKEND), "place_order")
+ .await
+ .0
+ .expect_err("no origin at all must be refused");
+ assert!(msg.contains("`application_origin` is required"), "{msg}");
+ // …and a non-empty registry does not carry the empty-registry wording.
+ let msg = authorize(
+ Some("https://unregistered.test"),
+ manifest(&[APP_BACKEND]),
+ &p(APP_BACKEND),
+ "place_order",
+ )
+ .await
+ .expect_err("refused");
+ assert!(!msg.contains("No applications are registered"), "{msg}");
+ }
+
+ // The registration PIN bounds the live manifest: a canister the manifest
+ // declares but the registration never recorded is refused. Without this, a
+ // publisher (or whoever compromised it) could widen its own write scope by
+ // editing a document only it controls, after the review that admitted it.
+ #[tokio::test]
+ async fn the_manifest_cannot_widen_the_reviewed_surface() {
+ // An id nobody reviewed — the registration lists APP_BACKEND,
+ // APP_FRONTEND and ICP_LEDGER, not this.
+ let unreviewed = p("be2us-64aaa-aaaaa-qaabq-cai");
+ let msg = authorize(
+ Some(REGISTERED),
+ manifest(&[APP_BACKEND, "be2us-64aaa-aaaaa-qaabq-cai"]),
+ &unreviewed,
+ "set_name",
+ )
+ .await
+ .expect_err("a canister the manifest added after review must be refused");
+ assert!(msg.contains("not among the canisters reviewed"), "{msg}");
+ assert!(msg.contains("cannot widen"), "and say why: {msg}");
+ // The manifest may still NARROW: a pinned canister the app has dropped
+ // from its manifest stops working immediately.
+ let msg = authorize(Some(REGISTERED), manifest(&[APP_FRONTEND]), &p(APP_BACKEND), "ping")
+ .await
+ .expect_err("dropped from the live manifest: refused");
+ assert!(msg.contains("is not declared by"), "{msg}");
+ // Pinned AND declared: authorized.
+ assert!(authorize(Some(REGISTERED), manifest(&[APP_BACKEND]), &p(APP_BACKEND), "ping")
+ .await
+ .is_ok());
+ }
+
+ // The identity a call is signed as must be one the named application acts
+ // as. `application_origin` and `derivation_origin` are separate arguments
+ // resolved independently, so without this check a registered application
+ // could have the server sign a call to a canister it lists as the user's
+ // principal AT AN UNRELATED APP — an identity that canister may trust.
+ #[tokio::test]
+ async fn an_application_cannot_borrow_another_apps_identity() {
+ // The registered application acting as its own identity: fine.
+ let (result, fetches) = gate_as(
+ TEST_REGISTRY,
+ Some(REGISTERED),
+ Some("https://example-app.test"),
+ manifest(&[APP_BACKEND]),
+ &p(APP_BACKEND),
+ "place_order",
+ )
+ .await;
+ assert!(result.is_ok(), "its own identity must be allowed: {result:?}");
+ assert_eq!(fetches, 1);
+
+ // The same application asking to sign as a DIFFERENT app's identity:
+ // refused, offline, before the manifest is even read.
+ for victim in ["https://victim.test", "https://nns.ic0.app", "https://oisy.com"] {
+ let (result, fetches) = gate_as(
+ TEST_REGISTRY,
+ Some(REGISTERED),
+ Some(victim),
+ manifest(&[APP_BACKEND]),
+ &p(APP_BACKEND),
+ "place_order",
+ )
+ .await;
+ let msg = result.expect_err("borrowing another app's identity must be refused");
+ assert!(msg.contains("does not act as the identity"), "{msg}");
+ assert!(msg.contains(victim), "the refusal names the identity asked for: {msg}");
+ assert_eq!(fetches, 0, "and it is refused before any fetch");
+ }
+
+ // An anonymous call (no identity at all) is unaffected by this check.
+ assert!(gate_as(
+ TEST_REGISTRY,
+ Some(REGISTERED),
+ None,
+ manifest(&[APP_BACKEND]),
+ &p(APP_BACKEND),
+ "place_order"
+ )
+ .await
+ .0
+ .is_ok());
+ }
+
+ // Every refusal this module produces is prose a user reads, so none of them
+ // may carry a run of whitespace. Pinned because the bug is invisible in
+ // source: a `\`-continued literal that rustfmt later joins onto one line
+ // keeps the continuation's indentation as literal spaces, and the assertions
+ // above all match single-line substrings that straddle no continuation.
+ #[tokio::test]
+ async fn no_refusal_carries_stray_whitespace() {
+ let mut refusals = vec![
+ // Step 2: the argument is missing.
+ gate(TEST_REGISTRY, None, manifest(&[APP_BACKEND]), &p(APP_BACKEND), "ping").await.0,
+ // Step 2: the argument is not an origin.
+ gate(TEST_REGISTRY, Some("not a url"), manifest(&[APP_BACKEND]), &p(APP_BACKEND), "ping")
+ .await
+ .0,
+ // Step 3: not registered, against a NON-empty registry…
+ gate(
+ TEST_REGISTRY,
+ Some("https://unregistered.test"),
+ manifest(&[APP_BACKEND]),
+ &p(APP_BACKEND),
+ "ping",
+ )
+ .await
+ .0,
+ // …and against an empty one, which appends the extra sentence.
+ gate(&[], Some(REGISTERED), manifest(&[APP_BACKEND]), &p(APP_BACKEND), "ping").await.0,
+ // Step 4: unreachable, and not served.
+ gate(
+ TEST_REGISTRY,
+ Some(REGISTERED),
+ ArchitectureFetch::Unreachable("dns failure".into()),
+ &p(APP_BACKEND),
+ "ping",
+ )
+ .await
+ .0,
+ gate(
+ TEST_REGISTRY,
+ Some(REGISTERED),
+ ArchitectureFetch::NotDeclared("answered 404".into()),
+ &p(APP_BACKEND),
+ "ping",
+ )
+ .await
+ .0,
+ // Step 5: declared by nobody, and a long list that hits the cap.
+ gate(TEST_REGISTRY, Some(REGISTERED), manifest(&[]), &p(APP_BACKEND), "ping").await.0,
+ gate(
+ TEST_REGISTRY,
+ Some(REGISTERED),
+ manifest(&[APP_FRONTEND]),
+ &p(APP_BACKEND),
+ "ping",
+ )
+ .await
+ .0,
+ ];
+ // Layer 2's refusals travel the same path, so hold them to it too.
+ refusals.push(
+ gate(
+ TEST_REGISTRY,
+ Some(REGISTERED),
+ manifest(&[APP_BACKEND]),
+ &p(APP_BACKEND),
+ "icrc1_transfer",
+ )
+ .await
+ .0,
+ );
+ for r in refusals {
+ let msg = r.expect_err("every case above must refuse");
+ assert!(!msg.contains(" "), "a refusal carries a run of spaces: {msg:?}");
+ assert!(!msg.contains('\n'), "a refusal carries a newline: {msg:?}");
+ assert!(!msg.contains('\t'), "a refusal carries a tab: {msg:?}");
+ }
+ }
+
+ // The fixture registry is held to the same shape as the shipped one, so it
+ // can't drift into testing something the real table could never be. (The
+ // deliberately-stale row is exempt from the revision rule — it exists to
+ // prove the revision rule.)
+ #[test]
+ fn the_test_registry_mirrors_the_shipped_shape() {
+ for app in TEST_REGISTRY {
+ assert_eq!(
+ discover::normalize_origin(app.origin).as_deref(),
+ Some(app.origin),
+ "{}: fixture origins must be canonical too",
+ app.origin
+ );
+ assert!(!app.canisters.is_empty(), "{}: fixture rows are pinned too", app.origin);
+ for d in app.derivation_origins {
+ assert_eq!(
+ &crate::identities::target_origin(d),
+ d,
+ "{}: fixture derivation origins must be canonical effective form too",
+ app.origin
+ );
+ }
+ }
+ }
+}
diff --git a/crates/imcp2-core/src/calls.rs b/crates/imcp2-core/src/calls.rs
index 353c846..40be5aa 100644
--- a/crates/imcp2-core/src/calls.rs
+++ b/crates/imcp2-core/src/calls.rs
@@ -187,6 +187,33 @@ pub struct CanisterUpdateCallArgs {
pub canister_id: String,
/// Update method name to invoke.
pub method: String,
+ /// REQUIRED. The https origin of the application this call belongs to —
+ /// scheme and host only, e.g. `https://example.com` (no path). This is
+ /// what AUTHORIZES the call: the origin must be a registered application
+ /// whose developer accepted the ICP MCP Developer Terms, its
+ /// `/.well-known/ic-architecture` manifest is read fresh on every call,
+ /// and the target canister must be one the manifest declares. A canister
+ /// id found any other way (a response header, an `/env.json`, a JS
+ /// bundle) cannot be written to. Get the value from open_app / resolve_app
+ /// (`application_origin`). NOT interchangeable with `derivation_origin`:
+ /// several frontends can share one derivation origin, and the manifest
+ /// lives at the application origin. Reads (canister_query) need no
+ /// application origin.
+ ///
+ /// `Option` + `schemars(required)` deliberately: the SCHEMA marks it
+ /// required, so a client sends it rather than discovering the requirement
+ /// from an error, while the type still lets a client that omits it anyway
+ /// reach the gate's own refusal — which names the argument, says where to
+ /// get it, and distinguishes it from `derivation_origin` — instead of an
+ /// opaque invalid-params protocol error rmcp would raise for a missing
+ /// required `String`.
+ ///
+ /// No `#[serde(default)]`: schemars treats a defaulted field as optional
+ /// regardless of `required` (schemars_derive `schema_exprs.rs`), and serde
+ /// already deserializes a missing `Option` field to `None` without it — so
+ /// the pair only works this way round. Pinned by a test on both halves.
+ #[schemars(required)]
+ pub application_origin: Option,
/// Arguments in textual Candid syntax, e.g. `()` or `(record { owner = principal "..." })`.
#[serde(default = "default_args")]
pub args: String,
@@ -219,6 +246,16 @@ pub struct CanisterUpdateCallOutput {
pub canister_id: String,
/// The method that was invoked.
pub method: String,
+ /// The registered application origin the call was authorized against, in
+ /// canonical form. Compare it with what you passed to catch an origin that
+ /// canonicalized to a different application than you meant.
+ pub application_origin: String,
+ /// How that application's own `/.well-known/ic-architecture` manifest
+ /// describes the canister that was called (its name/role) — null when the
+ /// manifest declares the id with no labels. Read it as confirmation that
+ /// the canister you called is the one the application says it is.
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub declared_as: Option,
/// The decoded reply in textual Candid.
pub reply: String,
/// The principal the call was signed as — null for an anonymous call.
diff --git a/crates/imcp2-core/src/compliance.rs b/crates/imcp2-core/src/compliance.rs
index b735600..e5a4931 100644
--- a/crates/imcp2-core/src/compliance.rs
+++ b/crates/imcp2-core/src/compliance.rs
@@ -1,4 +1,20 @@
-//! The financial-transactions guard for the generic update-call tool.
+//! **Layer 2 of the write gate: the financial-transactions guard.**
+//!
+//! `canister_update_call` is authorized in two layers, and this is the inner
+//! one. [`crate::authorization`] decides *whether the application may be
+//! written to at all* — it must be registered under the ICP
+//! service-discoverability protocol, with its developer's acceptance of the
+//! ICP MCP Developer Terms on file, and must declare the target canister in
+//! its own `/.well-known/ic-architecture` manifest. This module then refuses
+//! value-moving calls **inside** that authorized surface.
+//!
+//! The two layers answer different questions and neither substitutes for the
+//! other. Layer 1 is what keeps arbitrary canisters — a ledger a user names by
+//! hand, a canister mined out of a frontend bundle — out of reach entirely, so
+//! this list is not the thing standing between an agent and the ICP ledger.
+//! Layer 2 is what keeps a *registered* application from moving value through
+//! its own authorized surface, which registration must never buy: it is
+//! deliberately origin-blind, so no registration state can reach it.
//!
//! This server is not a financial tool: its purpose is reading, building, and
//! operating canisters, and the marketplace directories it is listed in
@@ -81,7 +97,11 @@
//! launch: entries cover each service's central canisters (verified
//! against the IC dashboard's registry and the services' own published
//! sources), and the standardized-methods group plus the stated policy
-//! cover the rest.
+//! cover the rest. What bounds the un-enumerable remainder is Layer 1, not
+//! this list: an update call can only reach a canister a registered
+//! application declares as its own, so a bespoke value-moving method is
+//! reachable only inside an application whose developer accepted Terms
+//! that forbid exposing one.
//! * Legacy pre-ICRC token standards (DIP20/EXT `transfer`/`transferFrom`/
//! `approve` on arbitrary canisters) are deliberately NOT matched: the
//! names are too abstract to block everywhere without breaking
diff --git a/crates/imcp2-core/src/discover.rs b/crates/imcp2-core/src/discover.rs
index b225342..68c076b 100644
--- a/crates/imcp2-core/src/discover.rs
+++ b/crates/imcp2-core/src/discover.rs
@@ -1,24 +1,38 @@
-//! Best-effort discovery of the canisters behind a web domain served from the
-//! Internet Computer, folding together the patterns we've seen across apps:
+//! Discovery of the canisters behind a web domain served from the Internet
+//! Computer: one **declaration** and three **heuristics**, in that order of
+//! authority.
//!
-//! 1. **App-declared metadata** (most authoritative — the app says so):
-//! the `ic:canister-id` ` ` on `/ai-connect.html` (the App Connect
-//! bridge page, spec §4.7/§6.1 — the app's MAIN backend), and the
-//! `/.well-known/ic-app.json` manifest enumerating ALL the app's
-//! canisters with roles (our proposed convention for the spec's deferred
-//! §6.3 "multi-canister applications" — see README).
-//! 2. `x-ic-canister-id` response header — the frontend/asset canister. This
-//! is the one universal signal (the HTTP gateway sets it).
-//! 3. a runtime config asset (`/env.json`) carrying `*canister_id*` keys —
+//! 1. **The app's own architecture manifest** —
+//! `/.well-known/ic-architecture`, the composition layer of the [ICP
+//! service-discoverability protocol] ([`crate::architecture`]): the
+//! application enumerates the canisters it comprises, with names and
+//! roles. This is the app *declaring* its composition at its own origin,
+//! which is why it is also the only source that can authorize a
+//! state-changing call ([`crate::authorization`]).
+//! 2. `/.well-known/ic-app.json` — the earlier, DFINITY-proposed manifest
+//! the protocol's layer 1 supersedes. Still read, so apps that shipped it
+//! keep being discovered while they migrate, but it is a **read-only
+//! fallback**: it authorizes nothing.
+//! 3. `x-ic-canister-id` response header — the frontend/asset canister. The
+//! one universal signal (the HTTP gateway sets it).
+//! 4. a runtime config asset (`/env.json`) carrying `*canister_id*` keys —
//! e.g. Caffeine apps expose `backend_canister_id` here.
-//! 4. canister-id literals in the JS bundle, preferring labelled
+//! 5. canister-id literals in the JS bundle, preferring labelled
//! `*_CANISTER_ID` constants — e.g. dfx/Vite apps like OISY bake
//! `IC_BACKEND_CANISTER_ID`, `IC_SIGNER_CANISTER_ID`, etc.
//!
-//! There is NO authoritative reverse lookup for "this site's backend" — (1)
-//! is declared by the app itself and (2) is certain for the frontend; (3) and
-//! (4) are mined from client code, so each result carries its provenance and
-//! the caller decides (and should confirm with `get_canister_candid`).
+//! There is NO authoritative reverse lookup for "this site's backend". (1) and
+//! (2) are declared by the app itself and (3) is certain for the frontend; (4)
+//! and (5) are mined from client code, so each result carries its provenance
+//! and the caller decides (and should confirm with `get_canister_candid`).
+//!
+//! **Provenance is not permission.** Only (1) can authorize a state-changing
+//! call; (2)–(5) are read-only hints. (3)–(5) say a canister id appeared in
+//! bytes served behind a domain, not that the application claims it, and even
+//! (2) — a real app declaration — is not the protocol's manifest, so it is not
+//! what a write is checked against. See [`crate::authorization`].
+//!
+//! [ICP service-discoverability protocol]: https://docs.internetcomputer.org/guides/frontends/service-discoverability/
use std::{
collections::BTreeMap,
@@ -33,13 +47,16 @@ use rmcp::schemars;
use serde::{Deserialize, Serialize};
use tokio::task::JoinSet;
+use crate::architecture;
+
#[derive(Serialize, Clone, Debug)]
pub struct Found {
pub canister_id: String,
- /// A human label if one was attached (App Connect role, env.json key,
- /// bundle constant name, or "frontend"); None for a bare bundle literal.
+ /// A human label if one was attached (the manifest's name/role, an
+ /// env.json key, a bundle constant name, or "frontend"); None for a bare
+ /// bundle literal.
pub label: Option,
- /// Where it was found: "ai-connect.html", "ic-app.json", "header",
+ /// Where it was found: "ic-architecture", "ic-app.json", "header",
/// "env.json", "bundle:", "bundle".
pub sources: Vec,
/// IC dashboard label (e.g. "ICP Ledger"), filled in when the id is a known
@@ -57,17 +74,22 @@ pub struct Found {
pub struct DiscoveredCanister {
/// The canister's principal id.
pub canister_id: String,
- /// A human label if one was attached (App Connect role, env.json key,
- /// bundle constant, or "frontend"); null for a bare bundle literal.
+ /// A human label if one was attached (the manifest's name/role, an
+ /// env.json key, a bundle constant, or "frontend"); null for a bare
+ /// bundle literal.
pub label: Option,
/// IC dashboard label (e.g. "ICP Ledger"), when the id is a known canister.
pub name: Option,
/// IC dashboard classification (e.g. "ledger"), when known.
pub kind: Option,
- /// Where it was found: "ai-connect.html" (the App Connect page's declared
- /// main canister), "ic-app.json" (the app's own canister manifest),
- /// "header", "env.json", "bundle:", or "bundle". The first two are
- /// declared by the app itself and are the most authoritative.
+ /// Where it was found: "ic-architecture" (the app's own
+ /// `/.well-known/ic-architecture` manifest — the app DECLARING which
+ /// canisters it comprises, and the only source a state-changing call can
+ /// be authorized against), "ic-app.json" (the superseded manifest, read as
+ /// a fallback while apps migrate), "header" (the IC gateway's
+ /// x-ic-canister-id — the frontend), "env.json", "bundle:", or
+ /// "bundle". Only "ic-architecture" can authorize an update call; every
+ /// other provenance is a read-only hint.
pub sources: Vec,
/// Whether this canister exposes the OQL query surface — filled in for the
/// app's OWN data canisters by a single Candid fetch during open_app /
@@ -113,15 +135,25 @@ impl From<&Found> for DiscoveredCanister {
/// canister or a shared system canister (a ledger, II, NNS…). Scopes the
/// per-canister capability probe and the caller-gated data-access handle (#3) so
/// they never attach to II/NNS/ledger/frontend, per the security guardrail.
+///
+/// This is a READ-capability hint (which canisters are worth probing for OQL /
+/// an API doc), not an authorization: the mined sources stay in the set for
+/// that purpose, while [`crate::authorization`] admits only manifest-declared
+/// canisters to a state-changing call.
pub fn is_app_data_candidate(c: &DiscoveredCanister) -> bool {
// Declared or mined as the app's own backend (not merely the gateway header).
let app_owned = c.sources.iter().any(|s| {
- s == "ai-connect.html" || s == "ic-app.json" || s == "env.json" || s.starts_with("bundle")
+ s == "ic-architecture" || s == "ic-app.json" || s == "env.json" || s.starts_with("bundle")
});
- // The frontend / asset canister: an explicit "frontend" label, or found ONLY
- // via the gateway `x-ic-canister-id` header.
- let is_frontend =
- c.label.as_deref() == Some("frontend") || c.sources == ["header"];
+ // The frontend / asset canister: a label that SAYS frontend, or found ONLY
+ // via the gateway `x-ic-canister-id` header. Matched as a word rather than
+ // by equality, because a declared label is prose the app wrote: the
+ // protocol's own example manifest labels its frontend `"the frontend
+ // (frontend)"` once name and role are folded, and an equality test against
+ // "frontend" would miss it and hand the asset canister out as a data
+ // backend.
+ let is_frontend = c.label.as_deref().is_some_and(label_says_frontend)
+ || c.sources == ["header"];
// A dashboard-classified shared system canister (ledger, governance, …).
let is_system = c.kind.as_deref().is_some_and(|k| {
let k = k.to_ascii_lowercase();
@@ -132,6 +164,22 @@ pub fn is_app_data_candidate(c: &DiscoveredCanister) -> bool {
app_owned && !is_frontend && !is_system
}
+/// Whether a discovered canister's label identifies it as the app's frontend /
+/// asset canister. A whole-WORD match on the app-supplied prose, so "frontend",
+/// "the frontend (frontend)", "Frontend assets", and "asset canister" all count.
+///
+/// Words are split on whitespace only, with surrounding punctuation trimmed —
+/// deliberately NOT on `-`/`_`, which are identifier separators rather than
+/// prose: a backend an app names `frontend-orders-api` or `frontend_api` is the
+/// API *for* the frontend, not the frontend, and misreading it as one would
+/// quietly drop it from the OQL/api-doc capability probe.
+fn label_says_frontend(label: &str) -> bool {
+ label.split_whitespace().any(|w| {
+ let w = w.trim_matches(|c: char| !c.is_alphanumeric());
+ ["frontend", "asset", "assets"].iter().any(|k| w.eq_ignore_ascii_case(k))
+ })
+}
+
/// Arguments for `discover_app_canisters`.
#[derive(Debug, Deserialize, schemars::JsonSchema)]
pub struct DiscoverCanistersArgs {
@@ -197,119 +245,27 @@ fn canisters_from_env_json(text: &str) -> Vec<(String, String)> {
out
}
-/// Pull `content` out of the first ` ` tag with the given name,
-/// reading the RAW served markup — like an App Connect connector, we fetch the
-/// page and parse it, never executing its JavaScript (spec §6.1). Tolerates
-/// attribute order and single or double quotes.
-fn parse_meta(html: &str, name: &str) -> Option {
- let bytes = html.as_bytes();
- let mut i = 0;
- while i + 5 <= bytes.len() {
- // Find the next ` .
- if !matches!(bytes.get(after), Some(b' ' | b'\t' | b'\n' | b'\r' | b'/' | b'>')) {
- i = after;
- continue;
- }
- let rest = &html[after..];
- let Some(end) = rest.find('>') else {
- // No '>' anywhere in the remainder — no complete tag can follow.
- break;
- };
- let tag = &rest[..end];
- if attr(tag, "name").as_deref() == Some(name) {
- if let Some(content) = attr(tag, "content") {
- return Some(content);
- }
- }
- i = after + end;
- }
- None
-}
-
-/// A `key="value"` (or `key='value'`) attribute inside a tag body. Scans the
-/// tag left-to-right as a sequence of attributes, consuming each quoted value
-/// whole — so a key can never be matched inside another attribute's VALUE
-/// (e.g. `data="… name='x' …"`), `data-name` can never match `name` (names
-/// compare whole, ASCII-case-insensitively per HTML), and whitespace is
-/// tolerated around the `=`. Only quoted values are returned.
-fn attr(tag: &str, key: &str) -> Option {
- let bytes = tag.as_bytes();
- let mut i = 0;
- while i < bytes.len() {
- // Skip whitespace between attributes.
- while i < bytes.len() && bytes[i].is_ascii_whitespace() {
- i += 1;
- }
- if i >= bytes.len() {
- break;
- }
- // Read one attribute name (stop at whitespace, '=', or a quote).
- let name_start = i;
- while i < bytes.len()
- && !bytes[i].is_ascii_whitespace()
- && bytes[i] != b'='
- && bytes[i] != b'"'
- && bytes[i] != b'\''
- {
- i += 1;
- }
- let name = &tag[name_start..i];
- // Optional `= value`, with whitespace tolerated around the '='.
- while i < bytes.len() && bytes[i].is_ascii_whitespace() {
- i += 1;
- }
- if i < bytes.len() && bytes[i] == b'=' {
- i += 1;
- while i < bytes.len() && bytes[i].is_ascii_whitespace() {
- i += 1;
- }
- if i < bytes.len() && (bytes[i] == b'"' || bytes[i] == b'\'') {
- // Quoted value: consume it whole (to the matching quote).
- let quote = bytes[i];
- let vstart = i + 1;
- let mut j = vstart;
- while j < bytes.len() && bytes[j] != quote {
- j += 1;
- }
- if j >= bytes.len() {
- return None; // unterminated quote — malformed tag, bail
- }
- if name.eq_ignore_ascii_case(key) {
- return Some(tag[vstart..j].to_string());
- }
- i = j + 1;
- } else {
- // Unquoted value: consume the token; never returned.
- while i < bytes.len() && !bytes[i].is_ascii_whitespace() {
- i += 1;
- }
- }
- }
- // Guarantee progress on stray bytes (e.g. a bare quote at name position).
- if i == name_start {
- i += 1;
- }
- }
- None
-}
-
-/// Cap on how many manifest entries we honour — an app-declared list is small;
-/// this just bounds a hostile manifest.
+/// Cap on how many entries we honour in a list an app serves us — an
+/// app-declared list is small, so this only bounds a hostile document. Applies
+/// to the superseded `ic-app.json` manifest and to `ii-alternative-origins`.
+///
+/// The ARCHITECTURE manifest deliberately does not share this cap: it has its
+/// own, far larger one in [`crate::architecture`], because there a truncated
+/// list would silently deny a canister the app really declared — a wrong
+/// answer, where here it is merely a shorter list of read hints.
const MAX_MANIFEST_CANISTERS: usize = 100;
-/// The `/.well-known/ic-app.json` manifest — our proposed convention for App
-/// Connect's deferred §6.3 (multi-canister applications): the app itself
-/// enumerates ALL its canisters and their roles, so an agent doesn't have to
-/// mine them out of the frontend bundle. Unknown fields are ignored
-/// (forward-compatible); entries whose `id` isn't a valid principal are
-/// dropped downstream by `add`.
+/// The superseded `/.well-known/ic-app.json` manifest: DFINITY's own earlier
+/// proposal for app-declared composition, which the ICP
+/// service-discoverability protocol's `/.well-known/ic-architecture` replaces
+/// (see [`crate::architecture`]).
+///
+/// Still parsed, for exactly one reason: apps that shipped it should keep being
+/// DISCOVERABLE while they migrate. It is a read-only fallback — findings from
+/// it are stamped `ic-app.json`, rank below the architecture manifest, and
+/// authorize nothing ([`crate::authorization`] admits only ids the architecture
+/// manifest declares). Unknown fields are ignored; entries whose `id` isn't a
+/// valid principal are dropped downstream by `add`.
///
/// ```json
/// { "derivation_origin": "https://.icp0.io",
@@ -317,15 +273,6 @@ const MAX_MANIFEST_CANISTERS: usize = 100;
/// { "id": "aaaaa-…-cai", "role": "backend", "description": "orders API" },
/// { "id": "bbbbb-…-cai", "role": "ledger" } ] }
/// ```
-///
-/// The optional top-level `derivation_origin` is the app's own declaration of
-/// the Internet Identity derivation origin its frontends pin (via
-/// `derivationOrigin` + `/.well-known/ii-alternative-origins`). It is the ONLY
-/// authoritative way to learn a custom derivation origin: there is no reverse
-/// lookup from an app URL to it (the app's own alternative-origins file lists
-/// the inverse relation, and the frontend's `derivationOrigin` config is
-/// typically minified out of reach). When absent, a consumer must fall back to
-/// the application origin and say so.
#[derive(Deserialize)]
struct AppManifest {
#[serde(default)]
@@ -346,6 +293,8 @@ struct AppManifestEntry {
/// Extract `(canister_id, label)` pairs from an `/.well-known/ic-app.json`
/// body; the label is "role — description", whichever parts are present.
+/// Fail-SOFT (an empty list) on any malformed body: this is a discovery hint,
+/// so an unreadable one costs a finding, never a refusal.
fn canisters_from_app_manifest(text: &str) -> Vec<(String, Option)> {
let Ok(m) = serde_json::from_str::(text) else {
return Vec::new();
@@ -368,6 +317,17 @@ fn canisters_from_app_manifest(text: &str) -> Vec<(String, Option)> {
.collect()
}
+/// The derivation origin the superseded manifest declares in its optional
+/// top-level `derivation_origin`, reduced to a bare `https://host[:port]`
+/// origin. Consulted only when the protocol's own
+/// `/.well-known/ii-derivation-origin` names none, so an app that has migrated
+/// is never second-guessed by its old file. `None` if absent, blank, an
+/// explicit non-https scheme, user-info, or not a parseable URL.
+fn declared_derivation_origin(manifest_text: &str) -> Option {
+ let m = serde_json::from_str::(manifest_text).ok()?;
+ normalize_origin(m.derivation_origin?.as_str())
+}
+
/// Reduce a raw origin string to a canonical bare `https://host[:port]` origin,
/// accepting https with a real (tuple) host and no user-info. A scheme-less value
/// is treated as a bare host and gets `https://` prepended (so a good-faith
@@ -377,7 +337,7 @@ fn canisters_from_app_manifest(text: &str) -> Vec<(String, Option)> {
/// (incl. `http://`, which `target_origin` would silently upgrade downstream,
/// masking a wrong origin), user-info, host-less, or unparseable — so callers fail
/// closed with no hidden scheme rewrite.
-fn normalize_origin(raw: &str) -> Option {
+pub(crate) fn normalize_origin(raw: &str) -> Option {
let raw = raw.trim();
if raw.is_empty() {
return None;
@@ -407,15 +367,6 @@ fn normalize_origin(raw: &str) -> Option {
Some(origin.ascii_serialization())
}
-/// The app's declared Internet Identity derivation origin, from the manifest's
-/// optional top-level `derivation_origin`, reduced to a bare `https://host[:port]`
-/// origin (a scheme-less bare host is accepted and gets `https://`). `None` if
-/// absent, blank, an explicit non-https scheme, user-info, or not a parseable URL.
-fn declared_derivation_origin(manifest_text: &str) -> Option {
- let m = serde_json::from_str::(manifest_text).ok()?;
- normalize_origin(m.derivation_origin?.as_str())
-}
-
/// Which origins Internet Identity permits to derive from this origin, from its
/// `/.well-known/ii-alternative-origins` (`{ "alternativeOrigins": [...] }`).
/// Purely informational — this is the INVERSE of "what derivation origin does
@@ -444,7 +395,7 @@ fn parse_alternative_origins(text: &str) -> Vec {
/// Where a resolved derivation origin came from.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum DerivationSource {
- /// The app declared it in `/.well-known/ic-app.json` (`derivation_origin`).
+ /// The app declared it in `/.well-known/ii-derivation-origin`.
Declared,
/// Not declared by the app, but the app is in the built-in registry of
/// well-known custom-derivation-origin apps ([`KNOWN_DERIVATION_ORIGINS`]).
@@ -466,7 +417,7 @@ impl DerivationSource {
/// Built-in derivation origins for well-known apps that pin a CUSTOM Internet
/// Identity derivation origin (so the visible URL is NOT what II derives against)
-/// but don't yet declare it in `/.well-known/ic-app.json`. Verified from each app's
+/// but don't yet declare it in `/.well-known/ii-derivation-origin`. Verified from each app's
/// frontend `derivationOrigin` and the derivation origin's own
/// `/.well-known/ii-alternative-origins`. This is a stopgap so an agent gets the
/// right principal from the app URL alone; an app's own manifest declaration ALWAYS
@@ -488,7 +439,12 @@ impl DerivationSource {
const KNOWN_DERIVATION_ORIGINS: &[(&str, &str)] = &[
// NNS dapp: served at nns.internetcomputer.org (canister mc7vh-…) but pins the
// classic https://nns.ic0.app (canister qoctq-…) as its derivation origin. The
- // rest are nns.ic0.app's ii-alternative-origins.
+ // rest are nns.ic0.app's ii-alternative-origins. Deliberately NOT in
+ // [`KNOWN_APPS`]: the connector offers no name resolution or repair guidance
+ // toward a staking/funds frontend (and update calls to its canisters are
+ // refused, see `compliance`). The mappings stay so a user who brings an NNS
+ // URL themselves still derives the CORRECT per-app identity for reads,
+ // rather than a silently wrong (empty-looking) one.
("nns.ic0.app", "https://nns.ic0.app"),
("nns.internetcomputer.org", "https://nns.ic0.app"),
("beta.nns.internetcomputer.org", "https://nns.ic0.app"),
@@ -558,7 +514,6 @@ struct KnownApp {
// to its canisters is refused by that guard regardless of how the URL was
// reached.
const KNOWN_APPS: &[KnownApp] = &[
- KnownApp { name: "NNS", aliases: &["nns", "nnsdapp"], app_url: "https://nns.internetcomputer.org" },
KnownApp { name: "Oisy", aliases: &["oisy", "oisywallet"], app_url: "https://oisy.com" },
KnownApp { name: "MULTI/DEX", aliases: &["multidex"], app_url: "https://multidex.ai" },
KnownApp { name: "ICPSwap", aliases: &["icpswap"], app_url: "https://app.icpswap.com" },
@@ -717,7 +672,7 @@ fn ic_evidence_from(resp: &reqwest::Response, expected_origin: &str) -> bool {
/// Resolve an app URL to its Internet Identity derivation context, WITHOUT
/// guessing: the derivation origin is the app's declared one
-/// (`/.well-known/ic-app.json` → `derivation_origin`) if present, else a built-in
+/// (`/.well-known/ii-derivation-origin`) if present, else a built-in
/// known-app registry entry ([`KNOWN_DERIVATION_ORIGINS`]) if the app is one, else
/// the application origin (a clearly-flagged default). Uses the same SSRF-pinned
/// client and capped reads as `discover`; the app URL is user-controlled.
@@ -773,10 +728,10 @@ fn decide_declared_origin(
))
}
-/// What the app's `/.well-known/ic-app.json` resolved to: its declared (and
-/// authorized) derivation origin or the application-origin default, whether the
-/// manifest response carried IC-hosting evidence (`x-ic-canister-id`), and — when
-/// an accepted CROSS-origin declaration fetched them — that origin's alt-origins
+/// What the app's `/.well-known/ii-derivation-origin` resolved to: its declared
+/// (and authorized) derivation origin or the application-origin default, whether
+/// the response carried IC-hosting evidence (`x-ic-canister-id`), and — when an
+/// accepted CROSS-origin declaration fetched them — that origin's alt-origins
/// (reused for the display list so it isn't fetched twice).
struct DeclaredResolution {
derivation_origin: String,
@@ -785,50 +740,87 @@ struct DeclaredResolution {
alt_origins: Option>,
}
-impl DeclaredResolution {
- /// The application-origin default (no usable declaration), carrying whatever
- /// IC evidence the manifest response showed.
- fn app_default(application_origin: &str, ic_evidence: bool) -> Self {
- Self {
- derivation_origin: application_origin.to_string(),
- source: DerivationSource::AppUrlDefault,
- ic_evidence,
- alt_origins: None,
- }
+/// Read one well-known document from the application origin and extract a
+/// declared derivation origin from it with `parse`. Returns `(ic_evidence,
+/// declared)`; an unreachable origin, a non-success status, and a response that
+/// came from somewhere else all yield `(evidence-so-far, None)` — a declaration
+/// this server can't attribute to this exact origin is simply no declaration,
+/// since the spec has an app that derives against its own origin serve no file
+/// at all.
+///
+/// The body is parsed ONLY when the response came from `application_origin`
+/// itself. The shared redirect policy permits a same-host different-PORT hop and
+/// hops to global IP literals, so without that check a neighbouring origin's
+/// file could be read as this application's declaration — the same attribution
+/// rule [`crate::architecture::fetch_architecture`] applies to the manifest, and
+/// the one `ic_evidence_from` already applies to the evidence.
+async fn read_declared_origin_file(
+ client: &reqwest::Client,
+ application_origin: &str,
+ path: &str,
+ parse: fn(&str) -> Option,
+) -> (bool, Option) {
+ let Ok(resp) = client.get(format!("{application_origin}{path}")).send().await else {
+ return (false, None);
+ };
+ let ic_evidence = ic_evidence_from(&resp, application_origin);
+ if !resp.status().is_success() {
+ return (ic_evidence, None);
+ }
+ if resp.url().origin().ascii_serialization() != application_origin {
+ tracing::debug!(
+ application_origin = %application_origin,
+ served_by = %resp.url().origin().ascii_serialization(),
+ path = %path,
+ "ignoring a derivation-origin declaration served by a different origin"
+ );
+ return (ic_evidence, None);
}
+ let text = read_capped(resp, MAX_META_BYTES).await;
+ (ic_evidence, parse(&text))
}
-/// Resolve the app's declared derivation origin from `/.well-known/ic-app.json`,
-/// authorizing a cross-origin claim against the declared origin's own
-/// `ii-alternative-origins` (the browser/II rule; the decision is
-/// [`decide_declared_origin`]). Flat, with early guards. A missing/unsuccessful/
-/// undeclared manifest legitimately yields the application-origin default (the app
-/// derives against its own origin).
+/// Resolve the app's declared derivation origin, then authorize a cross-origin
+/// claim against the declared origin's own `ii-alternative-origins` (the
+/// browser/II rule; the decision is [`decide_declared_origin`]).
+///
+/// Two sources, in precedence order: the protocol's identity layer
+/// `/.well-known/ii-derivation-origin` (one line naming the origin Internet
+/// Identity derives against — see [`crate::architecture`]), then, only if that
+/// names none, the superseded `ic-app.json` manifest's `derivation_origin` key,
+/// so an app that hasn't migrated still resolves. An app that HAS migrated is
+/// never second-guessed by its old file. Nothing declared at all legitimately
+/// yields the application-origin default.
///
/// A cross-origin claim that CANNOT be authorized is an `Err`, not a silent
/// fall-back: falling back to the application origin there would derive the WRONG
/// principal for an app that deliberately pins a custom derivation origin (and
/// would mask a spoof, a misconfiguration, or an unreachable `ii-alternative-origins`).
/// Surfacing it lets the caller refuse rather than act as an unintended identity
-/// (ICPBB-430). The manifest response doubles as IC-hosting evidence, captured for
-/// the caller's later gate.
+/// (ICPBB-430). The responses double as IC-hosting evidence, captured for the
+/// caller's later gate.
async fn resolve_declared_origin(
client: &reqwest::Client,
application_origin: &str,
) -> Result {
- let Ok(resp) = client
- .get(format!("{application_origin}/.well-known/ic-app.json"))
- .send()
- .await
- else {
- return Ok(DeclaredResolution::app_default(application_origin, false));
- };
- let ic_evidence = ic_evidence_from(&resp, application_origin);
- if !resp.status().is_success() {
- return Ok(DeclaredResolution::app_default(application_origin, ic_evidence));
+ let (mut ic_evidence, mut declared) = read_declared_origin_file(
+ client,
+ application_origin,
+ architecture::II_DERIVATION_ORIGIN_WELL_KNOWN,
+ architecture::parse_derivation_origin,
+ )
+ .await;
+ if declared.is_none() {
+ let (evidence, fallback) = read_declared_origin_file(
+ client,
+ application_origin,
+ "/.well-known/ic-app.json",
+ declared_derivation_origin,
+ )
+ .await;
+ ic_evidence |= evidence;
+ declared = fallback;
}
- let text = read_capped(resp, MAX_META_BYTES).await;
- let declared = declared_derivation_origin(&text);
// The declared origin's ii-alternative-origins is the authorization list, and
// only a CROSS-origin claim needs it — no declaration and a self-declaration
@@ -1039,7 +1031,7 @@ pub fn classify_app_query(query: &str) -> AppQuery {
/// Tidy an app-supplied label for display: control characters (ANSI escapes,
/// CR/LF tricks) become spaces, then trim and cap — manifest roles and
/// descriptions are untrusted server text (CWE-150).
-fn clean_label(s: &str) -> String {
+pub(crate) fn clean_label(s: &str) -> String {
const MAX_LABEL_CHARS: usize = 120;
s.chars()
.map(|c| if c.is_control() { ' ' } else { c })
@@ -1128,7 +1120,7 @@ fn ipv6_is_global(ip: &Ipv6Addr) -> bool {
/// Validate a user-supplied discovery URL against SSRF and return the parsed URL
/// plus the socket addresses to PIN the client to. https only; every resolved
/// address must be global. Async DNS (no blocking of the executor).
-async fn resolve_public_url(raw: &str) -> Result<(url::Url, Vec), String> {
+pub(crate) async fn resolve_public_url(raw: &str) -> Result<(url::Url, Vec), String> {
let url = url::Url::parse(raw).map_err(|e| format!("invalid discovery URL {raw}: {e}"))?;
if url.scheme() != "https" {
return Err(format!(
@@ -1204,7 +1196,7 @@ fn ssrf_redirect_policy() -> reqwest::redirect::Policy {
/// rebind the connection to an internal address between validation and connect.
/// (Pinning only overrides this host; requests to other hosts — e.g. the
/// dashboard — resolve normally, still under the redirect guard.)
-fn site_client(host: &str, addrs: &[SocketAddr]) -> Result {
+pub(crate) fn site_client(host: &str, addrs: &[SocketAddr]) -> Result {
reqwest::Client::builder()
.user_agent("ic-mcp-discover/0.1")
.timeout(std::time::Duration::from_secs(15))
@@ -1226,13 +1218,13 @@ fn site_client(host: &str, addrs: &[SocketAddr]) -> Result String {
+pub(crate) async fn read_capped(mut resp: reqwest::Response, max: usize) -> String {
let mut buf: Vec = Vec::new();
loop {
if buf.len() >= max {
@@ -1253,6 +1245,31 @@ async fn read_capped(mut resp: reqwest::Response, max: usize) -> String {
String::from_utf8_lossy(&buf).into_owned()
}
+/// Read a response body of at most `max` bytes, STRICTLY: a mid-stream error is
+/// an error, and a body that exceeds the cap is an error rather than a silent
+/// prefix. The counterpart to [`read_capped`], for callers that must fail closed
+/// on an incomplete read instead of treating what arrived as the whole document
+/// (see [`crate::architecture::fetch_architecture`]). Reading a truncated
+/// manifest could only ever DENY a canister — a prefix cannot add an entry — but
+/// a gate should not decide on a document it did not fully receive, and "the body
+/// is capped" should mean refused, not quietly shortened.
+pub(crate) async fn read_strict(mut resp: reqwest::Response, max: usize) -> Result {
+ let mut buf: Vec = Vec::new();
+ loop {
+ match resp.chunk().await {
+ Ok(Some(chunk)) => {
+ if buf.len() + chunk.len() > max {
+ return Err(format!("the body is larger than the {max}-byte limit"));
+ }
+ buf.extend_from_slice(&chunk);
+ }
+ Ok(None) => break,
+ Err(e) => return Err(format!("the body could not be read in full: {e}")),
+ }
+ }
+ String::from_utf8(buf).map_err(|e| format!("the body is not valid UTF-8: {e}"))
+}
+
/// Accumulator for discovered canister ids, with a hard ceiling on the number of
/// DISTINCT entries retained. A hostile discovery target can pack the permitted
/// 8 MiB scan buffer with hundreds of thousands of unique, CRC-valid principals;
@@ -1332,36 +1349,42 @@ pub async fn discover(domain: &str) -> Result {
let client = site_client(&host, &pinned)?;
// Well-known paths and root-relative script paths live at the ORIGIN, not
// under whatever path the caller's URL carried (e.g. https://x.com/app must
- // probe https://x.com/ai-connect.html) — only the initial page fetch below
- // uses the URL as given.
+ // probe https://x.com/.well-known/ic-architecture) — only the initial page
+ // fetch below uses the URL as given.
let origin = base_url.origin().ascii_serialization();
let mut found = Findings::default();
- // 1. App-declared metadata — the app saying, in bytes it serves itself,
- // which canisters it comprises. Most authoritative of all sources, and
+ // 1. The app's own architecture manifest: /.well-known/ic-architecture, the
+ // composition layer of the ICP service-discoverability protocol — the app
+ // DECLARING, in bytes it serves itself, which canisters it comprises. The
+ // only source that is a declaration rather than a hint (and so the only one
+ // an update call can be authorized against, see `crate::authorization`);
// probed FIRST so its labels win `add`'s first-label-wins rule (a
// single-canister app's id would otherwise keep the header's generic
- // "frontend" label instead of the declared one).
- // a. The App Connect bridge page's ic:canister-id meta: the app's MAIN
- // backend (spec §4.7/§6.1). Read from raw markup, no JS execution.
- // An SPA catch-all serving index.html here fails closed: no such
- // meta, no finding.
- // b. /.well-known/ic-app.json: the app's own canister manifest with
- // roles (proposed convention for the spec's deferred §6.3). A
- // catch-all HTML response fails JSON parsing → no findings.
- if let Ok(resp) = client.get(format!("{origin}/ai-connect.html")).send().await {
+ // "frontend" label instead of the declared one). A catch-all HTML response
+ // fails JSON parsing → no findings, i.e. it fails closed.
+ //
+ // Discovery reads it best-effort: an unreachable or malformed manifest just
+ // yields no findings here, while the authorization path treats the very
+ // same outcome as a refusal. Same document, two postures — reads stay
+ // opportunistic, writes fail closed.
+ if let Ok(resp) =
+ client.get(format!("{origin}{}", architecture::ARCHITECTURE_WELL_KNOWN)).send().await
+ {
if resp.status().is_success() {
- let page = read_capped(resp, MAX_META_BYTES).await;
- if let Some(id) = parse_meta(&page, "ic:canister-id") {
- found.add(
- id.trim(),
- Some("main backend (App Connect)".into()),
- "ai-connect.html".into(),
- );
+ let text = read_capped(resp, MAX_META_BYTES).await;
+ if let Ok(arch) = architecture::parse_architecture(&text) {
+ for (id, label) in arch.findings() {
+ found.add(&id, label, "ic-architecture".into());
+ }
}
}
}
+ // 2. The superseded ic-app.json manifest, as a read-only fallback so apps
+ // that shipped it stay discoverable while they migrate. Ranked and probed
+ // after the architecture manifest, so a migrated app's own labels win
+ // first-label-wins; authorizes nothing either way.
if let Ok(resp) = client.get(format!("{origin}/.well-known/ic-app.json")).send().await {
if resp.status().is_success() {
let text = read_capped(resp, MAX_META_BYTES).await;
@@ -1371,8 +1394,8 @@ pub async fn discover(domain: &str) -> Result {
}
}
- // 2. Frontend via the gateway header (and keep the HTML for bundle mining).
- // This is also the reachability gate: the two probes above are best-effort,
+ // 3. Frontend via the gateway header (and keep the HTML for bundle mining).
+ // This is also the reachability gate: the probes above are best-effort,
// but an unreachable base is a hard error.
let resp = client
.get(&base)
@@ -1388,7 +1411,7 @@ pub async fn discover(domain: &str) -> Result {
}
let html = read_capped(resp, MAX_BODY_BYTES).await;
- // 3. Runtime config: /env.json with *canister_id* keys (e.g. Caffeine apps).
+ // 4. Runtime config: /env.json with *canister_id* keys (e.g. Caffeine apps).
if let Ok(resp) = client.get(format!("{origin}/env.json")).send().await {
if resp.status().is_success() {
let text = read_capped(resp, MAX_ENV_JSON_BYTES).await;
@@ -1398,7 +1421,7 @@ pub async fn discover(domain: &str) -> Result {
}
}
- // 4. JS bundle: labelled constants first, then any bare canister literals.
+ // 5. JS bundle: labelled constants first, then any bare canister literals.
let mut blob = html.clone();
let script_re = Regex::new(r#"["'](/[^"'<> ]+?\.js)["']"#).unwrap();
// Only the first 20 (sorted) paths are fetched below, and no real page has
@@ -1449,12 +1472,15 @@ pub async fn discover(domain: &str) -> Result {
found.add(m.as_str(), None, "bundle".into());
}
- // Order: app-declared metadata first (App Connect main, then the manifest
- // siblings), then header (frontend), env.json, labelled bundle, bare.
- // Authority tier of a finding (lower = more authoritative). Kept as a helper
- // so the sort can compare it without cloning `canister_id` into a key.
+ // Order: the protocol's own declaration first (the architecture manifest),
+ // then the superseded manifest, then the mined hints — header (frontend),
+ // env.json, labelled bundle, bare. Authority tier of a finding (lower = more
+ // authoritative). Kept as a helper so the sort can compare it without
+ // cloning `canister_id` into a key. Note this is DISPLAY/cap authority, not
+ // permission: only tier 0 can authorize a write, and no tier below it can,
+ // however high it sorts.
let rank = |f: &Found| {
- if f.sources.iter().any(|s| s == "ai-connect.html") {
+ if f.sources.iter().any(|s| s == "ic-architecture") {
0
} else if f.sources.iter().any(|s| s == "ic-app.json") {
1
@@ -2166,7 +2192,7 @@ mod tests {
// Authority-ordered, as discover() produces: labelled tiers first, then
// a long tail of bare bundle literals.
let mut found = vec![
- mk(0, Some("main backend (App Connect)"), "ai-connect.html"),
+ mk(0, Some("the backend"), "ic-architecture"),
mk(1, Some("frontend"), "header"),
mk(2, Some("IC_BACKEND_CANISTER_ID"), "bundle:IC_BACKEND_CANISTER_ID"),
];
@@ -2182,7 +2208,9 @@ mod tests {
assert_eq!(d.canisters[22].canister_id, "id-022");
// The global cap backstops labelled tiers too, and still reports.
- let many_labelled: Vec = (0..60).map(|i| mk(i, Some("x"), "ic-app.json")).collect();
+ let many_labelled: Vec = (0..60)
+ .map(|i| mk(i, Some("x"), "ic-architecture"))
+ .collect();
let d = bound_findings(many_labelled, 0);
assert_eq!(d.canisters.len(), 50);
assert_eq!(d.omitted, 10);
@@ -2387,154 +2415,68 @@ mod tests {
assert_eq!(got[0].1, "backend_canister_id");
}
- // App Connect discovery metadata (spec §4.7/§6.1): the ic:canister-id meta
- // is read from the RAW markup, tolerating attribute order and quote style;
- // an SPA catch-all page without the meta yields nothing.
+ // The app's own declared label must win over the header's generic
+ // "frontend" for the SAME canister (single-canister apps): `add` keeps the
+ // FIRST label, so discover() probes the architecture manifest before the
+ // header. This pins the first-label-wins semantics that ordering relies on
+ // — and, with the manifest now the FIRST probe, that it is the manifest's
+ // label a reader sees.
#[test]
- fn parse_meta_reads_app_connect_canister_id() {
- // The shipped ai-connect.html shape (name first, double quotes).
- let page = r#"
-
- Connect "#;
- assert_eq!(
- parse_meta(page, "ic:canister-id").as_deref(),
- Some("dmp3l-2yaaa-aaaae-aamva-cai")
- );
- // Attribute order flipped + single quotes.
- let flipped = r#" "#;
- assert_eq!(parse_meta(flipped, "ic:canister-id").as_deref(), Some("aaaaa-aa"));
- // Other metas don't match; absent meta yields None.
- let other = r#" "#;
- assert_eq!(parse_meta(other, "ic:canister-id"), None);
- assert_eq!(parse_meta("no metas", "ic:canister-id"), None);
- // The right meta is found among several.
- let multi = format!("{other}\n \n{flipped}");
- assert_eq!(parse_meta(&multi, "ic:canister-id").as_deref(), Some("aaaaa-aa"));
- assert_eq!(parse_meta(&multi, "ic:network").as_deref(), Some("ic"));
- // Attribute boundaries (per review): `data-name` must NOT match `name`,
- // and whitespace around `=` is legal HTML that must still parse.
- let trap = r#" "#;
- assert_eq!(parse_meta(trap, "ic:canister-id"), None, "data-name must not match name");
- let spaced = r#" "#;
- assert_eq!(parse_meta(spaced, "ic:canister-id").as_deref(), Some("aaaaa-aa"));
- // Both shapes on one tag: the boundary-checked real `name` wins.
- let both = r#" "#;
- assert_eq!(parse_meta(both, "ic:canister-id").as_deref(), Some("aaaaa-aa"));
- // An unquoted value is not accepted (we only read the quoted shape).
- assert_eq!(attr("name=bare content=\"x\"", "name"), None);
- // Tag-name boundary (per review): `` is not a tag,
- // and must not shadow a real meta that follows it.
- let metadata = r#""#;
- assert_eq!(parse_meta(metadata, "ic:canister-id"), None, " must not match");
- let after = format!("{metadata}\n ");
- assert_eq!(parse_meta(&after, "ic:canister-id").as_deref(), Some("aaaaa-aa"));
- // A malformed, never-closed ` ' remains, so no complete tag can follow anyway).
- assert_eq!(parse_meta(" "#;
- assert_eq!(parse_meta(embedded, "ic:canister-id"), None, "key inside a value must not match");
- assert_eq!(attr(r#"data="x name='inner' y" name="real""#, "name").as_deref(), Some("real"));
- // HTML tag and attribute names are ASCII-case-insensitive (per review):
- // parses; a mixed-case still doesn't.
- let upper = r#" "#;
- assert_eq!(parse_meta(upper, "ic:canister-id").as_deref(), Some("aaaaa-aa"));
- let mixed_decoy = r#""#;
- assert_eq!(parse_meta(mixed_decoy, "ic:canister-id"), None, " must not match");
- }
-
- // App-declared labels must win over the header's generic "frontend" for the
- // SAME canister (single-canister apps): `add` keeps the FIRST label, so
- // discover() probes the declared metadata before the header. This pins the
- // first-label-wins semantics that ordering relies on.
- #[test]
- fn add_keeps_first_label_so_declared_probes_run_first() {
+ fn add_keeps_first_label_so_the_manifest_probe_runs_first() {
let mut found = Findings::default();
let id = "dmp3l-2yaaa-aaaae-aamva-cai";
- found.add(id, Some("main backend (App Connect)".into()), "ai-connect.html".into());
+ found.add(id, Some("the backend".into()), "ic-architecture".into());
found.add(id, Some("frontend".into()), "header".into());
let f = &found.map[id];
- assert_eq!(f.label.as_deref(), Some("main backend (App Connect)"));
- assert_eq!(f.sources, vec!["ai-connect.html", "header"], "both provenances kept");
+ assert_eq!(f.label.as_deref(), Some("the backend"));
+ assert_eq!(f.sources, vec!["ic-architecture", "header"], "both provenances kept");
}
- // The proposed /.well-known/ic-app.json manifest: entries yield (id, label)
- // pairs with "role — description" labels; unknown fields are ignored;
- // hostile shapes (non-JSON / HTML catch-all / huge lists / control chars in
- // labels) fail closed or are bounded.
+ // The architecture manifest as a DISCOVERY source: entries yield (id, label)
+ // pairs folding name/role/description, unknown fields are ignored, and
+ // non-principal / blank ids drop out. (The manifest's parse-level
+ // fail-closed behaviour, version handling, and entry cap are pinned in
+ // `crate::architecture`; this test covers what discovery does with it.)
#[test]
- fn app_manifest_parses_and_fails_closed() {
+ fn architecture_manifest_yields_labelled_findings() {
let manifest = r#"{
- "name": "Example DEX",
+ "version": "1.0.0",
"canisters": [
- {"id": "dmp3l-2yaaa-aaaae-aamva-cai", "role": "backend", "description": "orders API"},
+ {"id": "dmp3l-2yaaa-aaaae-aamva-cai", "name": "backend", "role": "the backend",
+ "description": "orders API"},
{"id": "ryjl3-tyaaa-aaaaa-aaaba-cai", "role": "ledger"},
{"id": "qoctq-giaaa-aaaaa-aaaea-cai", "description": "governance"},
{"id": "aaaaa-aa", "future_field": {"nested": true}},
{"id": " "},
- {"role": "orphan-no-id"}
+ {"id": "not-a-principal", "role": "orphan"}
]
}"#;
- let got = canisters_from_app_manifest(manifest);
- assert_eq!(got.len(), 4, "blank/missing ids are skipped: {got:?}");
- assert_eq!(got[0], ("dmp3l-2yaaa-aaaae-aamva-cai".into(), Some("backend — orders API".into())));
+ let arch = architecture::parse_architecture(manifest).expect("parses");
+ let got = arch.findings();
+ assert_eq!(got.len(), 4, "blank and non-principal ids are skipped: {got:?}");
+ assert_eq!(
+ got[0],
+ (
+ "dmp3l-2yaaa-aaaae-aamva-cai".into(),
+ Some("the backend (backend) — orders API".into())
+ )
+ );
assert_eq!(got[1], ("ryjl3-tyaaa-aaaaa-aaaba-cai".into(), Some("ledger".into())));
assert_eq!(got[2], ("qoctq-giaaa-aaaaa-aaaea-cai".into(), Some("governance".into())));
assert_eq!(got[3], ("aaaaa-aa".into(), None), "unknown fields are ignored");
- // Fail closed: not JSON (an SPA catch-all serving HTML) or wrong shape.
- assert!(canisters_from_app_manifest("app").is_empty());
- assert!(canisters_from_app_manifest("[1,2,3]").is_empty());
- assert!(canisters_from_app_manifest("").is_empty());
-
- // Bounded: a hostile manifest can't produce unbounded findings.
- let huge = format!(
- r#"{{"canisters":[{}]}}"#,
- std::iter::repeat(r#"{"id":"aaaaa-aa"}"#)
- .take(500)
- .collect::>()
- .join(",")
- );
- assert_eq!(canisters_from_app_manifest(&huge).len(), MAX_MANIFEST_CANISTERS);
-
// Labels are sanitized: control chars (ANSI/CR) become spaces, length capped.
- let sneaky = r#"{"canisters":[{"id":"aaaaa-aa","role":"ok\u001b[31mEVIL\r\nline"}]}"#;
- let got = canisters_from_app_manifest(sneaky);
- let label = got[0].1.as_deref().unwrap();
+ let sneaky = r#"{"version":"1.0.0","canisters":[
+ {"id":"aaaaa-aa","role":"ok\u001b[31mEVIL\r\nline"}]}"#;
+ let arch = architecture::parse_architecture(sneaky).expect("parses");
+ let label = arch.findings()[0].1.clone().expect("a label");
assert!(!label.chars().any(char::is_control), "control chars must be gone: {label:?}");
- let long = format!(r#"{{"canisters":[{{"id":"aaaaa-aa","role":"{}"}}]}}"#, "x".repeat(1000));
- assert!(canisters_from_app_manifest(&long)[0].1.as_deref().unwrap().len() <= 120);
- }
-
- // The manifest's optional declared derivation origin is read and reduced to a
- // bare origin; absent / blank / non-http values yield None (fail-closed).
- #[test]
- fn declared_derivation_origin_parses_and_fails_closed() {
- let declared = r#"{"derivation_origin":"https://hcv4s-uaaaa-aaabq-qaaba-cai.icp0.io","canisters":[]}"#;
- assert_eq!(
- declared_derivation_origin(declared).as_deref(),
- Some("https://hcv4s-uaaaa-aaabq-qaaba-cai.icp0.io")
+ let long = format!(
+ r#"{{"version":"1.0.0","canisters":[{{"id":"aaaaa-aa","role":"{}"}}]}}"#,
+ "x".repeat(1000)
);
- // Reduced to a bare origin (path/query/trailing slash dropped).
- let with_path = r#"{"derivation_origin":"https://app.example.com/x?y=1"}"#;
- assert_eq!(declared_derivation_origin(with_path).as_deref(), Some("https://app.example.com"));
- // A scheme-less bare host is accepted (https assumed), matching the
- // interactive `derivation_origin` param, so a good-faith bare-host
- // declaration resolves instead of being silently dropped.
- let bare = r#"{"derivation_origin":"app.example.com"}"#;
- assert_eq!(declared_derivation_origin(bare).as_deref(), Some("https://app.example.com"));
- // Absent, blank, non-http, or non-JSON → None.
- assert_eq!(declared_derivation_origin(r#"{"canisters":[]}"#), None);
- assert_eq!(declared_derivation_origin(r#"{"derivation_origin":" "}"#), None);
- assert_eq!(declared_derivation_origin(r#"{"derivation_origin":"ftp://x/"}"#), None);
- // Non-https is rejected (https-only; else target_origin would silently
- // upgrade an http:// declaration while still reporting source=declared).
- assert_eq!(declared_derivation_origin(r#"{"derivation_origin":"http://example.com"}"#), None);
- // User-info is rejected (url.origin() would silently drop it).
- assert_eq!(declared_derivation_origin(r#"{"derivation_origin":"https://u:p@example.com"}"#), None);
- assert_eq!(declared_derivation_origin(""), None);
+ let arch = architecture::parse_architecture(&long).expect("parses");
+ assert!(arch.findings()[0].1.as_deref().unwrap().len() <= 120);
}
// ii-alternative-origins parsing is tolerant and bounded, and sanitizes
@@ -2696,8 +2638,10 @@ mod tests {
);
}
assert_eq!(find_app_by_name("Oisy").matches[0].app_url, "https://oisy.com");
- assert_eq!(find_app_by_name("nns").matches[0].app_url, "https://nns.internetcomputer.org");
assert_eq!(find_app_by_name("ICPSwap").matches[0].app_url, "https://app.icpswap.com");
+ // Deliberately NOT a known app (a staking/funds frontend): no name
+ // resolution — the query falls through to the unknown-app guidance.
+ assert!(find_app_by_name("nns").matches.is_empty());
// The derivation origin is DERIVED from KNOWN_DERIVATION_ORIGINS (single
// source of truth), so every app_url host must be a registry key — otherwise
@@ -2771,9 +2715,11 @@ mod tests {
assert_eq!(similar_known_app("icpswap.com").map(|m| m.app_url).as_deref(), Some("https://app.icpswap.com"));
assert_eq!(similar_known_app("oisy.org").map(|m| m.name).as_deref(), Some("Oisy"));
// A REAL known-app host is not a lookalike — nothing to repair.
- for real in ["multidex.ai", "https://oisy.com", "app.icpswap.com", "nns.internetcomputer.org"] {
+ for real in ["multidex.ai", "https://oisy.com", "app.icpswap.com"] {
assert!(similar_known_app(real).is_none(), "{real} is a real host, no suggestion");
}
+ // Deliberately no repair guidance toward the NNS (not a known app).
+ assert!(similar_known_app("nns.internetcomputer.org").is_none());
// Token boundaries hold (no substring false positives), and unrelated or
// unparseable inputs yield nothing.
for other in ["noisy.com", "multidexchange.org", "example.com", ""] {
@@ -2798,7 +2744,8 @@ mod tests {
known("multi dex", "https://multidex.ai");
known("multidex.com", "https://multidex.ai"); // wrong-TLD guess, no scheme
known("Oisy", "https://oisy.com");
- known("nns", "https://nns.internetcomputer.org");
+ // Deliberately not a known app: a bare "nns" is an unknown NAME.
+ assert!(matches!(classify_app_query("nns"), UnknownName));
// An explicit scheme is honoured as a URL (NOT registry-rewritten) — so a
// deliberately-typed guess reaches the IC-evidence gate on that origin.
match classify_app_query("https://multidex.com") {
@@ -2891,16 +2838,42 @@ mod tests {
api_doc_available: None,
};
// App-declared / app-mined backends → candidates.
- assert!(is_app_data_candidate(&dc(None, &["ai-connect.html"], None)));
+ assert!(is_app_data_candidate(&dc(Some("the backend"), &["ic-architecture"], None)));
assert!(is_app_data_candidate(&dc(Some("backend"), &["ic-app.json"], None)));
assert!(is_app_data_candidate(&dc(Some("backend_canister_id"), &["env.json"], None)));
assert!(is_app_data_candidate(&dc(Some("BACKEND"), &["bundle:BACKEND"], None)));
- // The frontend / asset canister → NOT a candidate.
- assert!(!is_app_data_candidate(&dc(Some("frontend"), &["ic-app.json"], None)));
+ // A retired provenance qualifies a canister for nothing: this server no
+ // longer produces the string, so a finding carrying it is not ours.
+ assert!(!is_app_data_candidate(&dc(None, &["ai-connect.html"], None)));
+ // The frontend / asset canister → NOT a candidate. The label is prose
+ // the APP wrote, so the match is on words, not equality: the protocol's
+ // own example manifest labels its frontend "the frontend (frontend)"
+ // once name and role are folded, and an equality test against
+ // "frontend" would hand the asset canister out as a data backend.
+ for label in ["frontend", "the frontend (frontend)", "Frontend assets", "asset canister"] {
+ assert!(
+ !is_app_data_candidate(&dc(Some(label), &["ic-architecture"], None)),
+ "{label:?} is the frontend, not a data backend"
+ );
+ }
+ // …but an identifier that merely STARTS with the word is a backend: an
+ // app naming one `frontend-orders-api` means the API for the frontend,
+ // and reading it as the frontend would drop it from the capability probe.
+ for label in ["frontend-orders-api", "frontend_api", "assets_index"] {
+ assert!(
+ is_app_data_candidate(&dc(Some(label), &["ic-architecture"], None)),
+ "{label:?} is a backend, not the frontend"
+ );
+ }
assert!(!is_app_data_candidate(&dc(None, &["header"], None)));
// A shared system canister (dashboard-classified) → NOT a candidate, even
// if it slipped in via a bundle literal.
assert!(!is_app_data_candidate(&dc(None, &["bundle"], Some("ledger"))));
- assert!(!is_app_data_candidate(&dc(None, &["ic-app.json"], Some("governance"))));
+ assert!(!is_app_data_candidate(&dc(None, &["ic-architecture"], Some("governance"))));
+
+ // Being an app-data candidate is a READ hint and nothing more: it never
+ // implies a canister may be written to. `crate::authorization` decides
+ // that from the manifest alone, and this function is not part of that
+ // decision (its input type has no path into the gate).
}
}
diff --git a/crates/imcp2-core/src/identities.rs b/crates/imcp2-core/src/identities.rs
index e05fd5c..595bd97 100644
--- a/crates/imcp2-core/src/identities.rs
+++ b/crates/imcp2-core/src/identities.rs
@@ -466,7 +466,7 @@ pub struct ResolveAppOutput {
/// as `derivation_origin` to the identity tools.
pub derivation_origin: String,
/// How `derivation_origin` was determined: "declared" (the app declared it
- /// in /.well-known/ic-app.json — authoritative), "known" (from the connector's
+ /// in /.well-known/ii-derivation-origin — 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" (assumed to equal the
/// application origin — correct only if the app has no custom derivation
diff --git a/crates/imcp2-core/src/lib.rs b/crates/imcp2-core/src/lib.rs
index 1a74aad..bd995be 100644
--- a/crates/imcp2-core/src/lib.rs
+++ b/crates/imcp2-core/src/lib.rs
@@ -29,17 +29,30 @@
//! here: the binary passes in its own agent, so anonymous canister calls go
//! through the host's boundary-node client and the whole process links a
//! single `ic-agent`.
+//!
+//! **Who may write** is decided in one place for every deployment:
+//! `authorization` gates `canister_update_call` on the application being
+//! registered under the [ICP service-discoverability protocol] with its
+//! developer's acceptance of the ICP MCP Developer Terms on file, and
+//! `compliance` refuses value-moving calls inside that surface. Both are
+//! part of the shared tool implementation, so a binary composing these
+//! components cannot opt out of them.
+//!
+//! [ICP service-discoverability protocol]: https://docs.internetcomputer.org/guides/frontends/service-discoverability/
pub mod identities;
pub mod iiconnect;
pub mod skills;
pub mod tools;
+mod architecture;
+mod authorization;
mod calls;
mod compliance;
mod discover;
mod management;
+pub use authorization::{DEVELOPER_TERMS_URL, DEVELOPER_TERMS_VERSION};
pub use identities::{IiInstance, SessionGauges};
pub use tools::{IcCanisterTools, IcProtocolTools, IcTools, SessionResolver};
/// The IC [`Agent`] type the components are built around, re-exported so
diff --git a/crates/imcp2-core/src/tools.rs b/crates/imcp2-core/src/tools.rs
index 13e5bf3..06e4cf6 100644
--- a/crates/imcp2-core/src/tools.rs
+++ b/crates/imcp2-core/src/tools.rs
@@ -17,7 +17,9 @@ use rmcp::{
schemars, ErrorData as McpError, RoleServer, ServerHandler,
};
-use crate::{calls, compliance, discover, identities, identities::Identities, management, skills};
+use crate::{
+ authorization, calls, discover, identities, identities::Identities, management, skills,
+};
use std::sync::Arc;
/// Cap on the per-canister Candid probes open_app / discover_app_canisters run to
@@ -508,7 +510,7 @@ impl IcCanisterTools {
}
#[tool(
- description = "Make an update call (a state-changing call) on an Internet Computer canister method, with textual Candid in and out. Args are encoded against the method's declared Candid types, so plain literals like 42 coerce correctly without `: type` annotations. Omitting `derivation_origin` calls anonymously and needs no session; passing it calls as the user's account at that app, which requires an authenticated session and uses a short-lived account delegation derived on demand from this connection's standing Internet Identity credential. `derivation_origin` is the app's exact canonical Internet Identity derivation origin — not necessarily its visible URL, and not an alternative-origins entry — which open_app and resolve_app resolve from an app name or URL; this tool takes the origin itself, not a raw website URL. `account` names one of the user's accounts (list_app_accounts returns them); omitted, the app's default account is used. The result echoes `derived_for_origin`, `requested`, and `acted_as_principal`, so an origin mismatch is visible. Read-only calls — Candid query methods and OQL queries — go through canister_query. `candid` supplies the interface as `.did` text when the canister's own metadata can't be read, so args and replies stay typed.",
+ description = "Make an update call (a state-changing call) on an Internet Computer canister method, with textual Candid in and out. `application_origin` is the application the call belongs to, and it is what authorizes the call: that origin is a registered application whose developer has accepted the ICP MCP Developer Terms, its own `/.well-known/ic-architecture` manifest — the ICP service-discoverability protocol's composition layer, re-read on every call — declares the target canister, that canister was recorded when the application registered, and the derivation origin the call is signed with is one recorded for that same application; a call that fails any of these checks is refused. A canister id known from another source — the gateway's `x-ic-canister-id` header, an `/env.json`, a JS bundle — is readable rather than writable. `application_origin` and `derivation_origin` are different values: applications can share a derivation origin, and the manifest is served at the application origin; open_app and resolve_app return each. Args are encoded against the method's declared Candid types, so plain literals like 42 coerce correctly without `: type` annotations. Omitting `derivation_origin` calls anonymously and needs no session; passing it calls as the user's account at that app, which requires an authenticated session and uses a short-lived account delegation derived on demand from this connection's standing Internet Identity credential. `derivation_origin` is the app's exact canonical Internet Identity derivation origin — not necessarily its visible URL, and not an alternative-origins entry — which open_app and resolve_app resolve from an app name or URL; this tool takes the origin itself, not a raw website URL. `account` names one of the user's accounts (list_app_accounts returns them); omitted, the app's default account is used. The result echoes `derived_for_origin`, `requested`, and `acted_as_principal`, so an origin mismatch is visible. Read-only calls — Candid query methods and OQL queries — go through canister_query. `candid` supplies the interface as `.did` text when the canister's own metadata can't be read, so args and replies stay typed.",
annotations(title = "Make a canister update call", read_only_hint = false, destructive_hint = true, idempotent_hint = false, open_world_hint = true),
output_schema = schema_for_output::(),
)]
@@ -518,6 +520,7 @@ impl IcCanisterTools {
canister_id,
method,
args,
+ application_origin,
derivation_origin,
account,
candid,
@@ -528,20 +531,37 @@ impl IcCanisterTools {
Ok(p) => p,
Err(e) => return Ok(err(format!("invalid canister id: {e}"))),
};
- // The financial-transactions gate (see `compliance`), in four scopes,
- // before any network work: the standardized value-moving method names
- // (the ICRC transfer/approval surface) and the mixed-purpose
- // governance entry point manage_neuron, both refused on EVERY
- // canister; the system ledgers'/cycles-minting canister's own
- // value-moving methods on those canisters; and EVERY update method on
- // a listed financial-service canister, so a refusal here does not
- // depend on the method name alone. Each scope's refusal claims only
- // what that scope knows about the call, and points the user outside
- // this connector. Queries need no gate — a query cannot commit state,
- // so it cannot move funds.
- if let Some(refusal) = compliance::disallowed_update_method(&principal, &method) {
- return Ok(err(refusal));
- }
+ // Resolve which principal to act as BEFORE the gate: none = anonymous;
+ // else the app's effective (canonical) II derivation origin, from the
+ // caller's explicit `derivation_origin` (obtained once via open_app /
+ // resolve_app). Pure string work, no I/O — and the gate needs the
+ // canonical effective form to check that this application is one that
+ // acts as that identity.
+ let target = match resolve_identity_target(derivation_origin) {
+ Ok(t) => t,
+ Err(e) => return Ok(err(e)),
+ };
+ // The write gate, in full (see `crate::authorization`): the call must
+ // name a registered application whose developer accepted the current
+ // ICP MCP Developer Terms and which acts as the identity being signed
+ // as; this canister must be both pinned by that registration and
+ // declared in the application's own live /.well-known/ic-architecture
+ // manifest; and the financial guard (`crate::compliance`) must not
+ // refuse the method — every check, or no call. Runs before ANY canister
+ // work, so an unauthorized call reaches neither the canister's
+ // interface nor its method. Queries need no gate: a query cannot commit
+ // state, so it cannot move funds or change anything.
+ let authorization = match authorization::authorize_update_call(
+ application_origin.as_deref(),
+ target.as_ref().map(|t| t.origin.as_str()),
+ &principal,
+ &method,
+ )
+ .await
+ {
+ Ok(a) => a,
+ Err(refusal) => return Ok(err(refusal)),
+ };
// The interface to encode/decode against: the canister's own
// candid:service if exposed, else the caller-supplied `candid`. Update calls
// are never redirected (OQL is read-only), so no oql_query_redirect here.
@@ -550,13 +570,6 @@ impl IcCanisterTools {
Ok(b) => b,
Err(e) => return Ok(err(e)),
};
- // Resolve which principal to act as: none = anonymous; else the app's
- // effective (canonical) II derivation origin, from the caller's explicit
- // `derivation_origin` (obtained once via open_app / resolve_app).
- let target = match resolve_identity_target(derivation_origin) {
- Ok(t) => t,
- Err(e) => return Ok(err(e)),
- };
let origin = target.as_ref().map(|t| t.origin.as_str());
let (agent, acted_as_principal) = match self
.resolve_agent(&ctx, origin, account.as_deref(), "calling")
@@ -586,6 +599,8 @@ impl IcCanisterTools {
}
let output = calls::CanisterUpdateCallOutput {
canister_id, method, reply,
+ application_origin: authorization.application_origin,
+ declared_as: authorization.canister_role,
acted_as_principal, derived_for_origin, requested, derivation_origin_source,
is_anonymous,
};
@@ -1130,7 +1145,11 @@ impl IcCanisterTools {
above to canister_query (read) and canister_update_call (write); for an OQL canister, \
call get_canister_oql_schema for the entity/field names, then canister_query with \
the `oql` argument — plus an optional account from list_app_accounts. A \"my/our…\" \
- question is an AUTHENTICATED read: pass the origin.",
+ question is an AUTHENTICATED read: pass the origin. A WRITE additionally needs the \
+ application_origin above (a different value from derivation_origin): it authorizes \
+ the call, and only a registered application whose own ic-architecture manifest \
+ declares the canister can receive one — so a canister listed above WITHOUT the \
+ ic-architecture provenance is readable but not writable.",
);
let output = discover::OpenAppOutput {
app_url,
@@ -1149,7 +1168,7 @@ impl IcCanisterTools {
}
#[tool(
- description = "Resolve an application URL to its Internet Identity derivation context. `app_url` is a URL the caller already has — from the user, from open_app's known-app resolution, or from the app's official site; a lookalike domain is an unrelated or squatted site, and when the derivation origin would have to be assumed from the URL itself, this tool refuses an origin that shows no evidence of being an Internet Computer app rather than returning a wrong identity. Returns the `application_origin`, the `derivation_origin` the identity tools take, 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 apps with custom derivation origins, used when no usable declaration was read; or \"app_url_default\" — no usable declaration was read and the registry has no entry, so the IC-served origin is assumed to be its own derivation origin, which holds only if the app has no custom one. Reading a declaration is fail-soft: a fetch that fails, a non-success response, malformed JSON, or an unusable declaration all take the assumed path, so these two sources mean \"none was read\", not \"none exists\". One case is NOT fail-soft: a cross-origin declaration is accepted only if the DECLARED origin authorizes this app in its /.well-known/ii-alternative-origins, and an unauthorized one is REFUSED outright rather than falling back — resolution fails instead of deriving a possibly wrong identity), and the app's `alternative_origins`, which are the inverse relation and do not identify the derivation origin. No principal is returned, since no account has been chosen: get_app_principal and list_app_accounts take the resolved origin. open_app resolves an app name as well as a URL, and also returns the app's canisters. No authenticated session is required.",
+ description = "Resolve an application URL to its Internet Identity derivation context. `app_url` is a URL the caller already has — from the user, from open_app's known-app resolution, or from the app's official site; a lookalike domain is an unrelated or squatted site, and when the derivation origin would have to be assumed from the URL itself, this tool refuses an origin that shows no evidence of being an Internet Computer app rather than returning a wrong identity. Returns the `application_origin`, the `derivation_origin` the identity tools take, how it was determined (`derivation_origin_source`: \"declared\" — the app published it in /.well-known/ii-derivation-origin, the ICP service-discoverability protocol's identity layer, or in the superseded /.well-known/ic-app.json, authoritative; \"known\" — from the connector's built-in registry of apps with custom derivation origins, used when no usable declaration was read; or \"app_url_default\" — no usable declaration was read and the registry has no entry, so the IC-served origin is assumed to be its own derivation origin, which holds only if the app has no custom one. Reading a declaration is fail-soft: a fetch that fails, a non-success response, a malformed body, or an unusable declaration all take the assumed path, so these two sources mean \"none was read\", not \"none exists\". One case is NOT fail-soft: a cross-origin declaration is accepted only if the DECLARED origin authorizes this app in its /.well-known/ii-alternative-origins, and an unauthorized one is REFUSED outright rather than falling back — resolution fails instead of deriving a possibly wrong identity), and the app's `alternative_origins`, which are the inverse relation and do not identify the derivation origin. No principal is returned, since no account has been chosen: get_app_principal and list_app_accounts take the resolved origin. open_app resolves an app name as well as a URL, and also returns the app's canisters. 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::(),
)]
@@ -1203,7 +1222,7 @@ impl IcCanisterTools {
}
#[tool(
- description = "Discover the Internet Computer canisters behind a web domain (e.g. \"opencloud.org\"). `domain` is a domain, not an app name; open_app takes a name directly. When discovery succeeds, a domain with no Internet-Computer evidence yields an empty `canisters` list with a note saying so, rather than a guess; a domain that cannot be reached at all (DNS, TLS, timeout) is a plain error instead, so an empty list means no findings rather than a failed lookup — open_app and resolve_app are the tools that refuse such an origin, and then only where the derivation origin would have to be assumed from the URL itself. Returns up to 50 canister ids, with provenance, most authoritative first (unlabelled ids mined from the JS bundle are capped at 20); any id dropped by those bounds is counted in `omitted` rather than left out silently: 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 (its canisters and their roles, honoured up to the first 100 entries — a truncation there is NOT counted in `omitted`, which accounts for the output bounds only) — then the `x-ic-canister-id` header (the frontend/asset canister), an `/env.json` runtime config (e.g. `backend_canister_id`), and labelled or bare canister-id literals mined from the JS bundle. App-declared entries are the app's own claim about itself; env.json and bundle entries are mined candidates, distinguished by label (production and IC ids) and confirmable with get_canister_candid.",
+ description = "Discover the Internet Computer canisters behind a web domain (e.g. \"opencloud.org\"). `domain` is a domain, not an app name; open_app takes a name directly. When discovery succeeds, a domain with no Internet-Computer evidence yields an empty `canisters` list with a note saying so, rather than a guess; a domain that cannot be reached at all (DNS, TLS, timeout) is a plain error instead, so an empty list means no findings rather than a failed lookup — open_app and resolve_app are the tools that refuse such an origin, and then only where the derivation origin would have to be assumed from the URL itself. Returns up to 50 canister ids, with provenance, most authoritative first (unlabelled ids mined from the JS bundle are capped at 20); any id dropped by those bounds is counted in `omitted` rather than left out silently: app-declared metadata — the app's own /.well-known/ic-architecture manifest (the ICP service-discoverability protocol's composition layer: the canisters it declares, with their names and roles) and the superseded /.well-known/ic-app.json manifest (a read-only fallback for apps that have not migrated, honoured up to the first 100 entries — a truncation there is NOT counted in `omitted`, which accounts for the output bounds only) — then the `x-ic-canister-id` header (the frontend/asset canister), an `/env.json` runtime config (e.g. `backend_canister_id`), and labelled or bare canister-id literals mined from the JS bundle. App-declared entries are the app's own claim about itself; env.json and bundle entries are mined candidates, distinguished by label (production and IC ids) and confirmable with get_canister_candid. The ic-architecture manifest is also the one source a canister_update_call can be authorized against; every other source here, ic-app.json included, bears on reading only.",
annotations(title = "Discover canisters behind a domain", read_only_hint = true, destructive_hint = false, open_world_hint = true),
output_schema = schema_for_output::(),
)]
@@ -1231,11 +1250,14 @@ impl IcCanisterTools {
));
}
out.push_str(
- "\n`ai-connect.html` and `ic-app.json` entries are DECLARED by the app itself \
- (its main backend, and its own canister manifest with roles) — treat them as \
- the app's claim about its composition. The `header` (x-ic-canister-id) entry \
- is the frontend/asset canister. Others come from env.json or the JS bundle \
- and may include multiple environments (prefer the production/IC ids). A \
+ "\n`ic-architecture` entries are DECLARED by the app itself, in its own \
+ manifest with names and roles — the app's claim about its composition, and \
+ the ONLY provenance a state-changing call can be authorized against. \
+ `ic-app.json` is the superseded manifest, read as a fallback. The `header` \
+ (x-ic-canister-id) entry is the frontend/asset canister; others come from \
+ env.json or the JS bundle. Everything except `ic-architecture` is a \
+ READ-ONLY hint — such entries may include multiple environments (prefer the \
+ production/IC ids) and cannot authorize an update call. A \
«name» (type) is the IC dashboard's label for that id. `[oql]`/`[api-doc]` \
flags are from a Candid probe of the app's own canisters. Confirm an interface \
with get_canister_candid before calling.",
@@ -1775,8 +1797,8 @@ fn unverified_app_url_error(application_origin: &str) -> String {
let mut msg = format!(
"{application_origin} is reachable but shows NO evidence of being an Internet Computer \
app — no valid `x-ic-canister-id` gateway header (the IC HTTP gateway sets one on every \
- response) — and its /.well-known/ic-app.json couldn't be fetched or declares no Internet \
- Identity derivation origin. Refusing to treat it as an app. "
+ response) — and its /.well-known/ii-derivation-origin couldn't be fetched or names no \
+ Internet Identity derivation origin. Refusing to treat it as an app. "
);
if let Some(m) = discover::similar_known_app(application_origin) {
msg.push_str(&format!(
@@ -1816,14 +1838,14 @@ fn resolution_note(resolved: &discover::AppIdentity, effective: &str) -> Option<
match resolved.derivation_origin_source {
discover::DerivationSource::Declared => None,
discover::DerivationSource::Known => Some(format!(
- "This app didn't declare a derivation origin in /.well-known/ic-app.json, but it's \
+ "This app didn't declare a derivation origin in /.well-known/ii-derivation-origin, but it's \
a known app that pins a custom one, so this used the built-in value {effective}. \
The app's own declaration, if it ships one, would override this."
)),
discover::DerivationSource::AppUrlDefault => Some(format!(
"This origin showed evidence of being served from the Internet Computer (its \
responses carry the gateway's `x-ic-canister-id` header), but its \
- /.well-known/ic-app.json couldn't be fetched or declares no `derivation_origin`, \
+ /.well-known/ii-derivation-origin couldn't be fetched or names no origin, \
and it isn't in the built-in known-app registry — so this ASSUMED the application \
origin, canonicalized to {effective} (what II derives against). That is correct \
for apps without a custom derivation origin; if this app pins a custom one, the \
@@ -1982,11 +2004,20 @@ fn identity_annotation(target: &IdentityTarget, acted_as: Option<&str>) -> Strin
/// have to be kept in sync forever, and a refused call already gets a refusal
/// accurate for its own scope. Neither surface names a venue for a refused
/// operation.
+///
+/// The write gate ([`crate::authorization`]) is stated here as well, in the
+/// same factual register: it is a property of the surface — an update call
+/// runs only for a registered application that declares the target canister —
+/// so a client's model reading this knows what an update call needs before it
+/// makes one, and can tell a gate refusal from the network's rejection of a
+/// Questions-only session. `the_write_gate_is_taught_on_the_tool_and_in_the_instructions`
+/// pins both surfaces.
const SERVER_INSTRUCTIONS: &str = "Internet Computer tools: read canister interfaces and data, resolve apps and the user's identity at them, and make calls on the user's behalf.\n\n\
Candid values — the arguments and replies of a canister's own methods, on canister_query's `method` path and on canister_update_call — are textual Candid, the `(...)` syntax, e.g. `(record { owner = principal \"aaaaa-aa\"; amount = 5 : nat })`, never the binary form. The `candid://textual-syntax` resource documents that syntax and `candid://reference` the type system; IC how-to guides are served as `skill://` resources. Nothing else uses it: an OQL query is plain JSON, the canister-scoped reads take a canister id, and the app and identity tools take app URLs and derivation origins.\n\n\
Tool names signal scope. The `…_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 its URL; the `…canister…` names (get_canister_candid, get_canister_api_doc, get_canister_oql_schema, canister_query, canister_update_call) act on one canister. `icp_oql_guide` documents the OQL dialect the canister reads use. An app's features are reached through its canisters rather than through per-feature tools, and open_app resolves an app name or URL to both its derivation origin and its canisters in one call.\n\n\
An app's derivation origin is the exact origin Internet Identity derives the user's principal from. It is not necessarily the app's visible URL, and an alternative-origins entry does not identify it; open_app and resolve_app resolve it, and the identity-bearing tools take the origin itself rather than a URL. There is no on-chain name-to-URL directory: open_app matches a name against a built-in registry of well-known apps, and where the derivation origin would have to be assumed from the URL itself, open_app and resolve_app refuse an origin with no evidence of being an Internet Computer app, while discover_app_canisters returns an empty result for such a domain. This server's OQL read path requires a derivation origin and rejects an anonymous read; that is this connector's own rule, not a statement about what a canister stores or how it authorizes callers. A Candid `method` read may be anonymous. Account delegations are short-lived and derived on demand from this connection's standing Internet Identity credential, which is obtained at connect time and lasts for the chosen session duration (up to 30 days). Internet Identity's consent screen offers two access levels, and they govern the calls signed with that session's account delegation — the ones that carry a derivation origin: on a \"Questions only\" session those reads work and those update calls are rejected by the network, while \"Actions & questions\" permits both. A call made with no derivation origin is not signed with the delegation at all; it runs as the anonymous principal, so those access levels do not govern it. This server's own checks still do — the financial-transactions guard runs before any identity or network work, so a call it refuses is refused whether or not an origin was passed — and past that the canister decides whether to accept it.\n\n\
Canister values are stored in canonical, locale-neutral forms: timestamps are usually nanoseconds since the Unix epoch in UTC (IC time), and physical quantities are SI or app-defined units, which `get_canister_api_doc` documents for canisters that publish a doc.\n\n\
+ UPDATE CALLS ARE GATED. canister_update_call takes an `application_origin`, and the call runs only when every check passes: that origin is a registered application whose developer has accepted the ICP MCP Developer Terms; the application's own /.well-known/ic-architecture manifest — the ICP service-discoverability protocol's composition layer, re-read on every call — declares the target canister; that canister was recorded when the application registered, so a manifest can narrow the recorded set but never widen it; the derivation origin the call is signed with is one recorded for that same application, since one application does not act as another's identity; and the guard below does not refuse the method. Otherwise the call is refused, and the refusal names which check failed; there is no fallback. Finding a canister id behind a domain some other way — the gateway's x-ic-canister-id header, an /env.json, a JS bundle — makes it readable, not writable. `application_origin` and `derivation_origin` are different values: applications can share a derivation origin, and the manifest is served at the application origin; open_app and resolve_app return each. A gate refusal is a different failure from a Questions-only session's network rejection: it names the application origin or the Developer Terms, and reconnecting does not change it. The guard below is independent of registration, so its refusals stand inside a registered application's own surface too.\n\n\
FINANCIAL TRANSACTIONS ARE NOT SUPPORTED, to protect the user: do not use canister_update_call to move assets. Recognized asset-moving calls are refused before they reach the network, and the refusal says why — but that guard is a safeguard, not a complete filter, so treat this policy, rather than the absence of a refusal, as the limit. For financial operations (token transfers, spending approvals, payments, trades), recommend the user performs the operation outside this connector, in a trusted interface they control.\n\n\
Compiling Motoko or Rust to Wasm happens in the client\'s own environment, and this connector serves no tools for creating, funding, deploying or managing canisters: the user does that with the icp CLI in their own terminal.";
@@ -2458,6 +2489,77 @@ mod tests {
assert!(!ins.contains("oisy.com"), "the instructions name no venue: {ins}");
}
+ // The write gate is taught on BOTH surfaces an agent reads: the tool's own
+ // description (so a client that only reads the schema still learns that
+ // `application_origin` is required and what it is), and the server
+ // instructions (the policy in full). Without the description half, an
+ // agent's first update call is a guaranteed refusal it can't anticipate.
+ #[test]
+ fn the_write_gate_is_taught_on_the_tool_and_in_the_instructions() {
+ let tools = super::IcTools::all_tools();
+ let tool = tools
+ .iter()
+ .find(|t| &*t.name == "canister_update_call")
+ .expect("canister_update_call tool not found");
+ let desc = tool.description.as_deref().unwrap_or_default();
+ for expected in [
+ "`application_origin` is the application the call belongs to",
+ "ic-architecture",
+ "registered application",
+ // and that it is not the derivation origin, the confusable value
+ "`application_origin` and `derivation_origin` are different values",
+ ] {
+ assert!(
+ desc.contains(expected),
+ "the description must teach {expected:?}: {desc}"
+ );
+ }
+ // The argument is REQUIRED in the schema, not merely present in it — so
+ // a client sends it rather than discovering the requirement from a tool
+ // error. (The Rust type stays `Option` so a client that omits it anyway
+ // reaches the gate's instructive refusal instead of rmcp's opaque
+ // invalid-params error; see `calls::CanisterUpdateCallArgs`.)
+ let schema = serde_json::to_value(&tool.input_schema).expect("input schema");
+ let required: Vec<&str> = schema["required"]
+ .as_array()
+ .expect("the schema declares required arguments")
+ .iter()
+ .map(|v| v.as_str().expect("a required name"))
+ .collect();
+ for expected in ["canister_id", "method", "application_origin"] {
+ assert!(required.contains(&expected), "{expected} must be required: {required:?}");
+ }
+ assert!(
+ schema["properties"]["application_origin"].is_object(),
+ "and described as a property: {schema}"
+ );
+ // …while a client that omits it anyway still DESERIALIZES, so it reaches
+ // the gate's instructive refusal rather than rmcp's invalid-params error.
+ // This is the half `#[serde(default)]` would have provided; serde gives
+ // it for an `Option` field for free, and adding `default` back would
+ // silently undo the `required` above.
+ let args: crate::calls::CanisterUpdateCallArgs = serde_json::from_value(serde_json::json!({
+ "canister_id": "dmp3l-2yaaa-aaaae-aamva-cai",
+ "method": "set_name",
+ }))
+ .expect("omitting application_origin must still deserialize");
+ assert!(args.application_origin.is_none());
+
+ let ins = super::SERVER_INSTRUCTIONS;
+ for expected in [
+ "UPDATE CALLS ARE GATED",
+ "application_origin",
+ "/.well-known/ic-architecture",
+ "ICP MCP Developer Terms",
+ "readable, not writable",
+ ] {
+ assert!(
+ ins.contains(expected),
+ "the instructions must state {expected:?}"
+ );
+ }
+ }
+
// The local binary's login tools (`authenticate`/`auth_status`) live on its
// OWN wrapper handler, never on these routers: this surface IS what the
// hosted server advertises on `tools/list`, so a login tool landing here
diff --git a/crates/imcp2-local/README.md b/crates/imcp2-local/README.md
index c745c28..e316d64 100644
--- a/crates/imcp2-local/README.md
+++ b/crates/imcp2-local/README.md
@@ -5,15 +5,30 @@ AI tool (Claude Desktop, Claude Code, Codex, Cursor, Antigravity, the
Perplexity macOS app, …) spawns on your machine and talks to over **stdio**.
It serves the same tools as the hosted server at
[mcp.internetcomputer.org](https://mcp.internetcomputer.org) — canister reads
-and writes in textual Candid, app discovery, OQL, canister management —
-against the same **IC mainnet** and the same **production Internet
-Identity**. What it drops is the hosted server's entire OAuth 2.1 layer: a
+in textual Candid, app discovery, OQL, and writes **under the same
+authorization gate** (see below) — against the same **IC mainnet** and the same
+**production Internet Identity**. What it drops is the hosted server's entire OAuth 2.1 layer: a
single-user process reached over a pipe needs no bearer tokens, so your II
login never passes through a third-party server.
Cloud-only AI surfaces (claude.ai web/mobile, Perplexity web, Codex Cloud)
cannot spawn local processes; they keep using the hosted server.
+**Update calls carry the hosted server's authorization gate.** "The same tools"
+includes the same write policy: `canister_update_call` requires an
+`application_origin` that is a *registered* application — its developer having
+accepted the [ICP MCP Developer Terms](https://internetcomputer.org/icp-mcp/developer-terms/)
+— whose own `/.well-known/ic-architecture` manifest declares the target
+canister, plus the financial guard inside that surface (see
+[Update-call authorization](../../README.md#update-call-authorization)). The gate
+lives in the shared `imcp2-core` tool implementation, so this binary cannot opt
+out of it, and there is no local-mode bypass: **writing to your own canister
+through this binary is refused unless it is registered.** Reads —
+`canister_query`, `get_canister_candid`, the OQL tools, discovery — are
+unaffected and need no registration. To install code, change settings, or run
+lifecycle operations on canisters you control, use the
+[`icp` CLI](https://github.com/dfinity/icp-cli).
+
## Install
Release binaries (macOS arm64/x64, Linux x64/arm64, Windows x64) ship from
diff --git a/crates/imcp2-local/src/server.rs b/crates/imcp2-local/src/server.rs
index 918208f..74494f0 100644
--- a/crates/imcp2-local/src/server.rs
+++ b/crates/imcp2-local/src/server.rs
@@ -504,6 +504,79 @@ mod tests {
assert!(is_error, "an invalid canister id is a tool error");
assert!(text.contains("invalid canister id"), "{text}");
+ // The write gate, end to end through a real MCP client: an update call
+ // with no `application_origin` is refused with the recovery, before any
+ // network work (this test touches no network, so a refusal that came
+ // later would hang or fail differently).
+ let (is_error, text, _) = call(
+ "canister_update_call",
+ Some(serde_json::json!({
+ "canister_id": "dmp3l-2yaaa-aaaae-aamva-cai",
+ "method": "set_name",
+ "args": "()",
+ })),
+ )
+ .await;
+ assert!(
+ is_error,
+ "an update call with no application_origin must be refused"
+ );
+ assert!(text.contains("`application_origin` is required"), "{text}");
+
+ // …and an application origin with no accepted Developer Terms on file is
+ // refused too, however real the canister is: discovery cannot authorize
+ // a write.
+ let (is_error, text, _) = call(
+ "canister_update_call",
+ Some(serde_json::json!({
+ "canister_id": "dmp3l-2yaaa-aaaae-aamva-cai",
+ "method": "set_name",
+ "args": "()",
+ "application_origin": "https://unregistered.example",
+ })),
+ )
+ .await;
+ assert!(
+ is_error,
+ "an unregistered application origin must be refused"
+ );
+ assert!(text.contains("Developer Terms"), "{text}");
+ assert!(
+ text.contains(imcp2_core::DEVELOPER_TERMS_VERSION),
+ "the refusal names the current revision: {text}"
+ );
+
+ // Layer ordering, end to end: the financial guard is evaluated FIRST,
+ // so a value-moving request gets the answer it actually needs — do it
+ // yourself, outside this connector — rather than a registration message
+ // that would read as though registering could make the transfer
+ // possible.
+ // The refusal is therefore the same whether or not the named
+ // application is registered.
+ let (is_error, text, _) = call(
+ "canister_update_call",
+ Some(serde_json::json!({
+ "canister_id": "ryjl3-tyaaa-aaaaa-aaaba-cai",
+ "method": "icrc1_transfer",
+ "args": "()",
+ "application_origin": "https://unregistered.example",
+ })),
+ )
+ .await;
+ assert!(is_error, "a value-moving method must be refused");
+ assert!(
+ text.contains("icrc1_transfer"),
+ "the financial guard answers first: {text}"
+ );
+ assert!(
+ text.contains("outside this connector, in a trusted interface they control"),
+ "and redirects the user outside this connector: {text}"
+ );
+ assert!(
+ !text.contains("Developer Terms"),
+ "a transfer must not be answered with a registration message: {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
diff --git a/docs/anthropic-directory-submission.md b/docs/anthropic-directory-submission.md
index 24c7206..0e834a4 100644
--- a/docs/anthropic-directory-submission.md
+++ b/docs/anthropic-directory-submission.md
@@ -159,7 +159,24 @@ transactions:**
the boundary node rejects them. No dedicated management tooling is served,
and
none of this moves funds.
-- `canister_update_call` **refuses the standardized value-moving methods** —
+- **State-changing calls reach a registered surface only.**
+ `canister_update_call` requires an `application_origin`, and executes only
+ when that origin is a registered application — its developer having accepted
+ the ICP MCP Developer Terms
+ ( ) — whose own
+ `/.well-known/ic-architecture` manifest, published under the
+ [ICP service-discoverability protocol][protocol] and re-read on every call,
+ declares the target canister. Every failure refuses; there is no fallback. A
+ canister the connector can otherwise discover behind a domain (the gateway's
+ `x-ic-canister-id` header, an `/env.json`, a JavaScript bundle) can be
+ **read** and never **written to**. So an arbitrary ledger, minter, or
+ exchange canister is out of reach of a state-changing call regardless of
+ method name: none of them is a registered application, and none declares a
+ manifest naming itself. The registry ships empty and grows only by reviewed
+ change, so the set of applications that may receive writes is public and
+ auditable.
+- Within that registered surface, `canister_update_call`
+ **refuses the standardized value-moving methods** —
the ICRC-standard transfer/approval names
(ICRC-1/ICRC-2 plus ICRC-4/-7/-37) and the NNS/SNS governance method
`manage_neuron` (neuron staking and disbursement, on every SNS DAO's
@@ -177,9 +194,13 @@ transactions:**
and a unit test holds it there. What those instructions state is the policy
itself, not its implementation: the method families and canister scopes are
in the guard and in the refusal an attempted call receives, so the
- instructions carry no copy of that list to keep in sync.
+ instructions carry no copy of that list to keep in sync. This guard is
+ deliberately origin-blind, so registration cannot launder a financial call
+ through it: a registered application gets access to its own declared
+ canisters, never the right to move value.
- The README and the server instructions both state explicitly that
- financial transactions are not supported. The landing page is no longer one
+ financial transactions are not supported, and both additionally state the
+ registration requirement for state-changing calls. The landing page is no longer one
of them: #165 moved it to ,
maintained in dfinity/internetcomputer-org, and the page committed there
carries no policy text. Stating otherwise here would be a claim about
@@ -187,8 +208,12 @@ transactions:**
page belongs in that repository.
**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 no funding or management tools are
+needs:** no tool initiates or executes a transfer of the user's funds. Two
+independent reasons, either sufficient: state-changing calls reach only
+applications registered under the ICP service-discoverability protocol whose
+developers accepted the ICP MCP Developer Terms (no ledger, minter, or exchange
+is one), and within that surface the standardized financial methods and known
+finance-related canisters are refused anyway. 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.
@@ -300,7 +325,9 @@ Paste-and-adapt; portal limits in parentheses.
> With your consent it can also act as your Internet Identity accounts at a
> 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.
+ > no funding or canister-management tools. Actions are limited to
+ > applications registered with DFINITY for that purpose; anything else is
+ > read-only.
>
> On the Internet Identity consent screen you explicitly choose the session
> duration (10 minutes to 30 days) and the access level: "Questions only"
@@ -390,15 +417,22 @@ Paste-and-adapt; portal limits in parentheses.
> this connector, in a trusted interface you control — that behavior is
> intended. The message names no specific venue, and a test enforces
> that, so do not expect it to name a wallet.
+> 6. State-changing calls to an application that is not registered with us are
+> also refused by design, with a message naming the ICP MCP Developer Terms
+> and offering the read instead. The registry ships empty, so **every**
+> update call a reviewer tries will be refused — that is the intended
+> posture, not a defect. Reads are unaffected, and are what the walkthrough
+> above exercises.
### The seven compliance acknowledgments
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 no funding or management tools are served (users run
-those operations with the icp CLI).
+tool initiates or executes a transfer of the user's funds — state-changing calls
+reach only registered applications (a ledger or exchange is never one), financial
+ledger methods are refused within that surface as well, 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
diff --git a/docs/icp-mcp-developer-terms-draft.md b/docs/icp-mcp-developer-terms-draft.md
new file mode 100644
index 0000000..64757a3
--- /dev/null
+++ b/docs/icp-mcp-developer-terms-draft.md
@@ -0,0 +1,157 @@
+# ICP MCP Developer Terms (source text)
+
+> This is the source text for the page served at
+> `https://internetcomputer.org/icp-mcp/developer-terms/`
+> (dfinity/internetcomputer-org, `public/icp-mcp/developer-terms/index.html`;
+> `https://mcp.internetcomputer.org/developer-terms` permanently redirects
+> there). Keep the two in sync: the served page is what publishers actually
+> read and accept, and it is the URL
+> [`imcp2_core::DEVELOPER_TERMS_URL`](../crates/imcp2-core/src/authorization.rs)
+> points registrants at.
+>
+> The **revision below is the one the write gate enforces**: a registration
+> authorizes state-changing calls only while its recorded acceptance equals
+> `DEVELOPER_TERMS_VERSION`. Changing the revision here means changing that
+> constant (and re-collecting acceptances) — a test pins the two together, so
+> they cannot drift.
+
+---
+
+## ICP MCP Developer Terms
+
+**Revision 2026-08-28 · in effect from 2026-08-28**
+
+These Developer Terms ("Developer Terms") govern the registration of an
+application for state-changing access through the ICP MCP server (the
+"Service"), operated by DFINITY Stiftung, Genferstrasse 11, 8002 Zürich,
+Switzerland ("DFINITY Foundation", "we"). They are addressed to the publisher
+of an application — the person or entity that operates it and its canisters —
+not to the end users who connect an AI assistant to the Service. End users are
+governed by the [ICP MCP Terms of Service](https://internetcomputer.org/icp-mcp/terms/).
+
+By registering an application, or by asking us to register one, you accept
+these Developer Terms on behalf of the publisher.
+
+### 1. What registration is for
+
+The Service lets an AI assistant read public information from the Internet
+Computer and, under an end user's Internet Identity authorization, act on that
+user's behalf. Reading a canister requires no registration.
+
+Registration governs state-changing (update) calls: the Service makes an update
+call to a canister only when
+
+- the call names your application's origin;
+- that origin is registered under these Developer Terms, at the revision
+ currently in effect;
+- your application serves a `/.well-known/ic-architecture` manifest at that
+ exact origin, per the
+ [ICP service discoverability protocol](https://docs.internetcomputer.org/guides/frontends/service-discoverability/);
+ and
+- the manifest declares the canister being called.
+
+The manifest is read afresh on every such call. Nothing else authorizes a
+state-changing call — in particular, a canister id the Service can otherwise
+discover behind your domain (a response header, an `/env.json`, a JavaScript
+bundle) does not.
+
+### 2. Publishing the manifest accurately
+
+You are responsible for what your manifest declares. By registering, you
+represent and warrant that:
+
+- **You are entitled to expose every canister the manifest lists.** You control
+ it, or the party that controls it has authorized you to expose it through the
+ Service for state-changing calls. You must not list a canister operated by
+ anyone else — a shared ledger, another application's backend, a system
+ canister — in order to reach it through the Service.
+- **The manifest describes your application's real composition**, and its
+ `name`, `role`, and `description` fields do not misdescribe what a canister is
+ or does.
+- **You keep it current**: a canister you no longer operate, or no longer intend
+ to be reachable, is removed promptly. Because the Service re-reads your
+ manifest on every state-changing call, your removal takes effect on its next
+ one.
+- **The origin you register is one you control**, served over HTTPS.
+
+### 3. What your MCP-reachable operations must not do
+
+The Service is not a financial tool, and it must not become one by proxy. You
+are responsible for the operations your declared canisters expose to it,
+including operations they perform downstream on other canisters. For every
+update method reachable through the Service, you must ensure that it:
+
+- **does not transfer, trade, or move value, and does not grant spending
+ rights** — neither directly nor by forwarding to a ledger, minter, exchange,
+ wallet, or staking service — whatever the method is named. The Service
+ independently refuses the standardized value-moving methods and calls to known
+ finance-related canisters, but that guard is a backstop, not your compliance
+ boundary: a bespoke method that moves value is a breach of these Developer
+ Terms even where no automated check catches it;
+- **is safe for an AI assistant to call** on a user's behalf under an
+ instruction the user gave in their own words: not irreversible in a way a user
+ would not expect from the request, not destructive of data the user cannot
+ recover, and not a privileged administrative operation exposed to ordinary
+ users;
+- **handles the personal data it receives lawfully**, and only for the purpose
+ the user's request implies. Data your canisters return through the Service
+ reaches the user's AI assistant provider; you are responsible for your own
+ lawful basis for that disclosure and for what your application does with the
+ data it receives. The Service's own handling of personal data is described in
+ the [ICP MCP Privacy Policy](https://internetcomputer.org/icp-mcp/privacy-policy/).
+
+You must not use registration to circumvent the Service's authorization, rate,
+or safety mechanisms, or to expose an operation you would not expose in your own
+application's user interface.
+
+### 4. Publishing a manifest is not registration
+
+Serving a `/.well-known/ic-architecture` manifest is a technical statement about
+your application's composition. It carries none of the promises in these
+Developer Terms: not that you accepted them, not that you are entitled to expose
+the canisters you list, not that your methods are safe to call, and not that
+your application stays inside the policies above. Registration is what records
+those promises, and both are required for a state-changing call.
+
+### 5. Changes, suspension, and revocation
+
+We may change these Developer Terms; when we do, we change the revision
+identifier and effective date at the top of this page. A registration
+authorizes state-changing calls only against the revision currently in effect —
+when the revision changes, we will ask you to accept the new one, and until you
+do your application's registration no longer authorizes those calls. Reads are
+unaffected.
+
+We may suspend or remove a registration at any time, with or without notice
+where a security risk, a breach of these Developer Terms, or a legal obligation
+requires it. Removal takes effect from the first call after we deploy it, and
+nothing cached can keep a removed registration alive past that point. You may
+ask us to remove your registration at any time. Registration is free of charge,
+gives you no entitlement to the Service's availability, and grants no rights to
+DFINITY Foundation's names, logos, or other trademarks.
+
+### 6. Liability
+
+The Service is provided "as is" and "as available", without warranties of any
+kind. To the maximum extent permitted by law, DFINITY Foundation is not liable
+for damages arising from your application's registration or from calls made to
+your canisters through the Service. Nothing here excludes or limits liability
+that cannot be excluded under applicable law, including liability under Swiss
+law for damage caused by unlawful intent or gross negligence. You remain
+responsible to your own users under your own terms; we are not a party to that
+relationship.
+
+### 7. Governing law and jurisdiction
+
+These Developer Terms are governed by Swiss substantive law, excluding its
+conflict of law rules. The exclusive place of jurisdiction is Zürich,
+Switzerland.
+
+### 8. Registering, and contact
+
+To register an application, or to change or remove a registration, write to
+mcp@dfinity.org from an address you can show controls the origin, naming the
+origin and confirming acceptance of revision 2026-08-28 of these Developer
+Terms. Registrations are recorded in the Service's open-source repository at
+[github.com/dfinity/imcp2](https://github.com/dfinity/imcp2), so the set of
+applications that may receive state-changing calls is public and reviewable.
diff --git a/docs/openai-directory-submission.md b/docs/openai-directory-submission.md
index 03ce1d0..56c7a12 100644
--- a/docs/openai-directory-submission.md
+++ b/docs/openai-directory-submission.md
@@ -122,6 +122,19 @@ submission states, not from a narrower reading:
methods either: those calls must carry the target canister as the request's
effective canister id, and the update-call path does not set one, so the
boundary node rejects them.
+- **State-changing calls reach a registered surface only.**
+ `canister_update_call` requires an `application_origin` and executes only
+ when that origin is a registered application — its developer having accepted
+ the ICP MCP Developer Terms
+ ( ) — whose own
+ `/.well-known/ic-architecture` manifest, published under the
+ [ICP service-discoverability protocol][protocol] and re-read on every call,
+ declares the target canister. Every failure refuses; there is no fallback,
+ and the registry ships empty. A ledger, minter, or exchange canister is never
+ a registered application, so it is unreachable by a state-changing call
+ whatever the method is named, and a canister id the plugin can otherwise
+ discover behind a domain (gateway header, `/env.json`, JS bundle) is
+ read-only.
- **No tool initiates or executes a transfer of the user's funds.**
`canister_update_call` refuses the standardized value-moving methods
(ICRC-1/ICRC-2 and the ICRC-4/-7/-37 equivalents, plus the NNS/SNS
@@ -131,7 +144,9 @@ submission states, not from a narrower reading:
canisters, and every update call on the financial-service canisters it
carries. The refusal tells the user to perform the
operation outside the connector, in a trusted interface they control, and
- names no venue.
+ names no venue. This guard is origin-blind, so registration cannot launder
+ a financial call through it: a registered application reaches its own
+ declared canisters, never the right to move value.
- **The descriptions match the behavior**, as the guidelines require ("tools
should behave exactly as their names, descriptions, and inputs indicate";
"side effects should never be hidden or implicit"):
@@ -151,10 +166,13 @@ submission states, not from a narrower reading:
prices, or markets.
So the attestation is a clean yes. The README and the server instructions
-both state that financial transactions are not supported. The landing page is
+both state that financial transactions are not supported, and both state the
+registration requirement for state-changing calls. The landing page is
not a third: #165 moved it to ,
maintained in dfinity/internetcomputer-org, and it carries no policy text of
its own — adding it there is a separate change in that repository.
+[protocol]: https://docs.internetcomputer.org/guides/frontends/service-discoverability/
+
### 4. Test cases (authoring work)
@@ -167,8 +185,9 @@ Positive:
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.
+3. "What canisters are behind https://opencloud.org?" → discovery returns
+ the app's canisters with provenance, its own `/.well-known/ic-architecture`
+ manifest first.
4. "Open opencloud.org and list my accounts there" (signed in) → resolves
the derivation origin, lists II accounts.
5. "Resolve https://opencloud.org to its Internet Identity derivation
@@ -179,18 +198,30 @@ Negative:
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 state-changing call as your app account (canister_update_call with a
- `derivation_origin`, so it is signed with the session's delegation) on a
- "Questions only" session → the network rejects it and the tool reports the
- failed call; the server instructions describe the two Internet Identity
- access levels, so the assistant can explain why and what reconnecting
- changes. (Without a `derivation_origin` the call runs as the anonymous
- principal and the access level does not apply, so pass one to exercise
- this gate.)
+2. "Transfer 1 ICP" → refused before any network call, with the policy and a
+ recommendation to perform the operation outside this connector, in a trusted
+ interface the user controls (the refusal names no venue). The financial
+ guard is evaluated first, so this is the answer whatever
+ `application_origin` is passed.
3. Any authenticated tool with no sign-in → clean 401 → OAuth flow starts
(no crash, no hang).
-4. "Call an update method on a canister that rejects this caller" → the
- canister/network rejects it; the error is surfaced legibly.
+4. "Call an update method on an app of your choosing" → refused, naming the
+ ICP MCP Developer Terms: a state-changing call needs an
+ `application_origin` that is a registered application whose own
+ `/.well-known/ic-architecture` manifest declares the canister. With the
+ registry shipping empty this is the outcome for every application, so it is
+ the reviewer's expected result; the refusal offers the read path instead,
+ and reads (positive cases 1-5) are unaffected.
+5. A state-changing call as your app account (canister_update_call with a
+ `derivation_origin`, so it is signed with the session's delegation) on a
+ "Questions only" session → refused by the registration gate above, before
+ the access level is ever tested. Once an application is registered, such a
+ call is rejected by the network instead and the tool reports the failed
+ call; the server instructions describe the two Internet Identity access
+ levels, so the assistant can explain why and what reconnecting under
+ "Actions & questions" changes. (Without a `derivation_origin` the call runs
+ as the anonymous principal and the access level does not apply, so pass one
+ to exercise that gate.)
### 5. Decisions for the submitter
diff --git a/src/main.rs b/src/main.rs
index 0f27f43..6aeaafa 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -98,6 +98,9 @@ fn serve_metrics() -> bool {
/// itself — the landing page and its `/privacy-policy`, `/support` and
/// `/terms` subpages — are maintained in one place, dfinity/internetcomputer-org
/// (`public/icp-mcp/`), and served at internetcomputer.org under this prefix.
+/// `/developer-terms` — the publisher-facing agreement the write gate enforces
+/// (see [`imcp2_core::DEVELOPER_TERMS_URL`]) — lives there too, and never had a
+/// copy here: its source text is `docs/icp-mcp-developer-terms-draft.md`.
/// This origin answers their old paths with permanent redirects instead of
/// copies, so every published link keeps working — the directory listings'
/// policy URLs, old bookmarks, search results — while the content exists
@@ -115,6 +118,7 @@ fn landing_redirects_router() -> Router {
("/privacy-policy", "/privacy-policy/"),
("/support", "/support/"),
("/terms", "/terms/"),
+ ("/developer-terms", "/developer-terms/"),
];
let mut router = Router::new();
for (path, target) in PAGES {
@@ -655,6 +659,10 @@ mod tests {
("/privacy-policy", "https://internetcomputer.org/icp-mcp/privacy-policy/"),
("/support", "https://internetcomputer.org/icp-mcp/support/"),
("/terms", "https://internetcomputer.org/icp-mcp/terms/"),
+ (
+ "/developer-terms",
+ "https://internetcomputer.org/icp-mcp/developer-terms/",
+ ),
] {
let resp = landing_redirects_router()
.oneshot(Request::get(path).body(axum::body::Body::empty()).unwrap())
@@ -667,6 +675,47 @@ mod tests {
}
}
+ // The Developer Terms the write gate enforces, the text publishers accept,
+ // and the URL every registration refusal names must all be the same thing.
+ // The page itself lives on the landing site now (see [`LANDING_SITE`]), so
+ // the pin is on the two things this repository still owns: the source text
+ // carries the revision the gate compares acceptances against, and the URL
+ // the gate hands out is the one this origin's `/developer-terms` redirects
+ // to. A revision bump therefore fails here until the text is updated too.
+ #[test]
+ fn the_developer_terms_source_matches_the_enforced_revision() {
+ const SOURCE: &str = include_str!("../docs/icp-mcp-developer-terms-draft.md");
+ let revision = imcp2_core::DEVELOPER_TERMS_VERSION;
+ // Matched on collapsed whitespace, so a phrase the markdown wraps
+ // across lines still counts as present.
+ let flat = SOURCE.split_whitespace().collect::>().join(" ");
+ assert!(
+ flat.contains(&format!("**Revision {revision} · in effect from {revision}**")),
+ "the source text must carry revision {revision} as its effective revision"
+ );
+ assert!(
+ flat.contains(&format!("acceptance of revision {revision} of these Developer Terms")),
+ "and ask registrants to confirm that same revision"
+ );
+ // The obligations the protocol itself cannot establish, which is why
+ // these Terms exist at all: entitlement to the canisters listed, no
+ // value movement, lawful data handling.
+ for expected in [
+ "entitled to expose every canister",
+ "does not transfer, trade, or move value",
+ "lawfully",
+ ] {
+ assert!(flat.contains(expected), "the Developer Terms must state {expected:?}");
+ }
+ // Addressed to publishers, with the end-user Terms a separate document.
+ assert!(flat.contains("/icp-mcp/terms/"), "the end-user Terms must be linked");
+ assert_eq!(
+ imcp2_core::DEVELOPER_TERMS_URL,
+ format!("{}/developer-terms/", super::LANDING_SITE),
+ "the URL refusals name must be the page this origin redirects to"
+ );
+ }
+
#[tokio::test]
async fn favicon_is_served_as_a_cacheable_svg() {
let resp = site_metadata_router()