Skip to content

Restrict update calls to canisters an app declares (service discoverability) - #166

Draft
aterga wants to merge 5 commits into
mainfrom
claude/discoverability-protocol-restrictions-njf4x3
Draft

Restrict update calls to canisters an app declares (service discoverability)#166
aterga wants to merge 5 commits into
mainfrom
claude/discoverability-protocol-restrictions-njf4x3

Conversation

@aterga

@aterga aterga commented Aug 28, 2026

Copy link
Copy Markdown
Collaborator

Summary

Reading the Internet Computer is open; writing is not. A state-changing call runs against someone's live application, and a canister being publicly callable is not a statement by its operators that they want an agent driving it.

canister_update_call is now made only to a canister the owning app declares in its service-discoverability manifest at /.well-known/ic-architecture. Per the published guide, publishing that manifest is exactly how an app's operators opt in: it says "these are my canisters; an agent handed my URL may work them out and use them". An app that publishes none stays fully readable and discoverable, but cannot be written to through this server.

The manifest lives at the app's origin, not on chain, so canister_update_call takes a new app_url argument (open_app already returns it) naming the app whose manifest authorizes the write, falling back to derivation_origin when the app serves its manifest at that same origin. A successful call echoes declared_by/declared_at, so a write's provenance is visible in the reply, not only to the gate.

Supporting protocol work in discover was needed because the gate keys on the protocol rather than on this server's earlier proposal: /.well-known/ic-architecture is now read during discovery, and /.well-known/ii-derivation-origin (Layer 5) now takes precedence over the legacy manifest's derivation_origin field. Without that second piece, an app that follows the published guide and pins a custom derivation origin would resolve to the wrong principal — a bug this change would otherwise have created, since it makes the protocol the thing writes key on.

This pairs with dfinity/developer-docs#372 (merged), which states on the guide itself that publishing the Layer 1 manifest is the act that opts an app in to ICP MCP, under its terms and privacy policy. The doc and the gate now say the same thing.

What this gate is not, stated in the module doc and the README so nobody over-claims it:

  • It is not proof of ownership. Whoever controls a domain controls what its manifest says, so a manifest can name a canister its publisher does not own. What it establishes is that someone published a document, at an origin the caller named, claiming that canister as part of their app, and that a write can be traced back to that claim. It bounds blast radius; it does not stop a determined attacker.
  • It is not a substitute for the canister's own authorization. The IC still decides what the calling principal may do; this decides what this server is willing to send.
  • It is not a read restriction. canister_query, get_canister_candid, get_canister_api_doc, the OQL surface and every discovery tool are untouched.

Related issues

None linked.

Changes

  • New discoverability module — the policy and every refusal text, sitting alongside compliance (the financial-methods gate). Refusals distinguish their causes (no origin given / origin unreachable / no manifest published / published but this canister is not in it, which lists what the app does declare); each names the standard path, links the guide, points at skill://service-discoverability for the deploy-time recipe, and states that reads are unaffected.
  • canister_update_call gains app_url and returns declared_by/declared_at. The gate runs after the financial-methods refusal and before fetching the target's Candid, so a canister that will not be written to is not touched at all.
  • discover reads /.well-known/ic-architecture (ranked just under the App Connect main-backend meta, above the legacy path) and /.well-known/ii-derivation-origin. The well-known probes on each path run concurrently, so honouring both the protocol and the legacy path costs no extra round trip. Manifest labels fall back to the protocol's name when no role is given.
  • Fail-closed on the SPA catch-all the guide calls out as the most common misconfiguration (it answers /.well-known/* with index.html, at 200, so status alone would not catch it). A body that is not a manifest document reads as "publishes none" rather than "declares nothing", and an HTML first line is not an origin, so a mis-served Layer 5 file leaves the app deriving against its visible origin instead of garbage. The refusal names the misconfiguration and its fix.
  • Redirect pinning — a manifest is honoured only when the origin that answered is the origin that was probed, so a 3xx cannot let one origin borrow another's declaration and make declared_by a true-looking but wrong statement.
  • Canister-principal rule — the guide says an id "must be a canister principal"; the gate enforces that (10-byte opaque, 0x01 tag) rather than merely "parses as a principal". Notably this stops a manifest declaring the management canister aaaaa-aa, whose blob is empty and which Principal::from_text accepts.
  • Sanitized, capped error echo — a transport error can carry a hostile origin's TLS certificate subject or redirect URL straight into the model's context; it goes through a control-char scrub capped at 200 chars.
  • Over-long manifests are reported, not silently truncated — entries past the 100-entry read cap are declared, so refusing one as "not declared" would be a false statement about the app; the refusal blames the manifest's length instead.
  • Docs: README gains a "Writes are gated on the discoverability manifest" section; the app-declared-metadata section leads with the protocol; the tool table, typical flow, and derivation-origin precedence notes are updated; and the SSRF section records that canister_update_call makes an untrusted outbound fetch, which became true the moment the gate landed. The server-level instructions gain a "WRITES GO ONLY TO APPS THAT OPTED IN" paragraph. A note on mgmt_call records why the deferred management writes sit outside this gate.
  • Merged main (3473724), which moved under this branch. No textual conflict, but that series touched tools.rs, IcTools::new and the README, so the merge was verified rather than assumed: it builds and the full suite passes.

Open questions for the maintainer

Deliberately not decided in this PR:

  1. Nothing published on the standard path yet. Probed live: opencloud.org and oisy.com answer 200 text/html on every well-known path; the only origin that authorizes anything is MULTI/DEX, and only via the legacy ic-app.json. So the legacy fallback is load-bearing today — dropping it would make the write tool dead everywhere. Kept, with a tracing::warn naming the origin on every legacy authorization so retirement can be decided on evidence. When does legacy stop authorizing, and on what trigger?
  2. The submission docs' demo app. opencloud.org publishes no manifest, so the worked examples in docs/anthropic-directory-submission.md and docs/openai-directory-submission.md — including the "exercise canister_update_call" checklist item — are unsatisfiable against it. Either that app publishes a manifest, or those examples move to one that does. Not edited here.
  3. Privacy policy — timely, since main just re-dated it to August 28. Two specific spots in docs/icp-mcp-privacy-policy-draft.md:
    • "Websites you ask the Service to look up. When you ask it to find the application behind a web address, the Service fetches metadata from that address…" is now incomplete. The Service also fetches from the app's address when you act: every update call reads that app's manifest before the write. The trigger widens from "when you ask it to look up an app" to "when you look up an app, or act on one".
    • "The authorization is not restricted to particular applications." stays true of the credential (a per-app identity is derived for whichever app is named), but a reader will take it to mean the assistant can act at any application, and that is no longer so — actions now reach only apps that publish a manifest. Arguably a sentence in the assistant's favour, but it should say so rather than overstate.
      Legally reviewed text with a published dated version at src/assets/privacy-policy.html; flagged, not edited.
  4. Derivation-origin coupling. The sharpest remaining hole: app_url=https://evil.example (whose manifest declares canister V) plus derivation_origin=https://gooddapp.com writes to V signed as the user's good-app principal. Closing it means requiring the app origin's own resolved derivation origin to match — which needs a multi-origin set (13 of 17 known-app entries have derivation_origin != application_origin; NNS's is a different canister) or it false-refuses NNS and Oisy. Worth doing, but it is a behaviour change that deserves its own decision.
  5. System-canister denylist. manage_neuron on NNS governance is irreversible and value-moving and is not covered by compliance.rs. A denylist would refuse it even to an app that legitimately declares it (the NNS dapp being exactly that case). This is a pre-existing compliance.rs gap that the gate narrows but does not close.
  6. Manifest version. Currently ignored. Refusing an unknown major fails closed but bricks every write the day 2.0.0 ships; the spec's own evolution rule is "unknown fields must be ignored". MULTI/DEX omits the field entirely.

Testing

  • cargo build --locked --all-targets
  • cargo test --locked --all-targets — 264 pass, 0 fail, on the merge with current main. New: 14 unit tests over the refusal texts and the gate's fail-closed parsing, 3 live-network tests over the gate itself, and 2 pinning the gate's statement on the tool description, the input schema, and the server instructions. Negative pins assert no gate refusal borrows the vocabulary of the read-only-session rejection ("reconnect", "Actions & questions") or of the financial-methods refusal ("oisy.com"), since either would send the user to fix something that is not broken.
  • cargo clippy --locked --workspace --all-targets — 11 warnings, 10 pre-existing. The one added is repeat().take() in a new test, matching the identical idiom in the test 80 lines above it; repeat_n there would leave two adjacent tests spelling the same thing differently.
  • Verified live against the reference app and three others, including that all four answer /.well-known/ic-architecture with the SPA shell today — the exact case the fail-closed tests pin.
  • cargo fmt --all — deliberately not run: main already carries 491 rustfmt deviations, the house style is hand-formatted, and CI does not check it, so reformatting would bury the change.
  • npm test --prefix monitoring/mcp-status — not applicable, the dashboard is unchanged.

Checklist

  • I have read the Contributing guidelines.
  • Docs (README / comments) updated for any user-visible change.
  • No secrets, credentials, or internal-only information are included.

claude added 3 commits August 28, 2026 10:47
…bility)

Reading the Internet Computer is open; writing is not. A state-changing
call runs against someone's live application, and a canister being
publicly callable is not a statement by its operators that they want an
agent driving it. `canister_update_call` is now made ONLY to a canister
the owning app DECLARES in its service-discoverability manifest at
/.well-known/ic-architecture — publishing that manifest is, per the
published guide, exactly how an app's operators opt in.

The manifest lives at the app's origin, not on chain, so the tool takes a
new `app_url` argument (open_app already returns it) naming the app whose
manifest authorizes the write; it falls back to `derivation_origin` when
the app serves its manifest at that same origin. A successful call echoes
`declared_by`/`declared_at`, so a write's provenance is visible in the
reply, not only to the gate.

The new `discoverability` module holds the policy and every refusal text,
alongside `compliance` (the financial-methods gate). Refusals distinguish
their four causes — no origin given, origin unreachable, no manifest
published, published but this canister is not in it (that one lists what
the app does declare) — and each one names the standard path, links the
guide so an app owner can act on a relayed refusal, and states that reads
are unaffected, so an agent answers what it can rather than concluding
the whole app is off limits. The module doc is explicit about what the
gate is not: it is a consent-and-provenance gate, not proof of ownership
(whoever controls a domain controls what its manifest says), and not a
substitute for the canister's own authorization.

Supporting protocol work in `discover`, needed because the gate keys on
the protocol rather than on this server's earlier proposal:

- /.well-known/ic-architecture is now read during discovery, ranked just
  under the App Connect main-backend meta and above the legacy path. The
  legacy /.well-known/ic-app.json is still read, at lower authority and
  still gate-eligible, so the apps that adopted the proposal are not cut
  off the day the standard path lands — the one known adopter today
  publishes only that path.
- /.well-known/ii-derivation-origin (Layer 5) now takes precedence over
  the legacy manifest's `derivation_origin` field. Without it, an app
  that follows the published guide and pins a custom derivation origin
  would resolve to the wrong principal — a bug the gate would otherwise
  have created, since it makes the protocol the thing writes key on.
- The three well-known probes on each path run concurrently, so honouring
  both the protocol and the legacy path costs no extra round trip.
- Manifest labels fall back to the protocol's `name` when no `role` is
  given, so a spec-shaped manifest yields a label, not a bare principal.

Everything fails closed on the misconfiguration the guide calls out as the
most common one: an SPA catch-all answering /.well-known/* with
index.html, which returns 200. A manifest body that is not a manifest
document reads as "publishes none" rather than "declares nothing", and an
HTML first line is not an origin, so a mis-served Layer 5 file leaves the
app deriving against its visible origin instead of garbage. Verified
against the live reference app, which today serves exactly that shape at
both protocol paths.

Reads are untouched: canister_query, get_canister_candid,
get_canister_api_doc, the OQL surface and every discovery tool work on
any canister, as before.

Claude-Session: https://claude.ai/code/session_01JarhapgjytMcVSCW7d8sqB

Co-authored-by: Claude <noreply@anthropic.com>
…t refusals

An independent design review of the gate confirmed the shape but found five
things worth fixing. All are small and none changes which canisters
authorize a write.

Redirect pinning. A manifest is now honoured only when the origin that
ANSWERED is the origin we PROBED. The shared redirect policy already
refuses a cross-domain hop, but it permits same-host different-port hops
and hops to global IP literals, so without this an origin could 3xx and
have another origin's declaration attributed to it -- making the
`declared_by` provenance the caller is shown a true-looking but wrong
statement. A skipped redirect is logged.

Sanitized, capped error echo. `unreachable_refusal` interpolated the raw
transport error, which for a hostile origin can carry an attacker-chosen
TLS certificate subject or redirect URL straight into the model's
context. It now goes through the same control-char scrub the manifest
labels use, capped at 200 chars (CWE-150).

Truncation no longer masquerades as absence. `manifest_canister_ids`
silently dropped entries past the 100-entry cap, so declared entry 101
drew a "not declared" refusal -- a false statement about the app. The
overflow is carried and reported, and the refusal blames the manifest's
length rather than the app's declaration.

The SPA catch-all is named, not just detected. When the protocol path
answers 2xx with something that is not a manifest, the refusal now says
what it answered with and that the usual cause is an SPA catch-all
returning index.html, with the fix (exempt /.well-known/* from the
rewrite). The guide calls this the most common failure; an operator told
only "absent" would go looking for a file that is already there. This
changes the message, not the verdict -- the shape check already rejected
the HTML.

The no-manifest refusal stops the retry loop. It previously read as
"try a different app_url", which invites an agent that already has the
right origin to burn turns re-resolving it. It now says outright that
re-running open_app will not change anything and to stop.

Also: legacy-path authorizations are logged with the origin, so the
fallback can be retired on evidence rather than on a guess (today it is
the only path that authorizes anything in the known-app registry); a note
on `mgmt_call` records why the deferred management writes sit outside
this gate and what to revisit if that group is ever served; and the
README's SSRF section now says `canister_update_call` makes an untrusted
outbound fetch, which was true the moment the gate landed and was not
stated.

New tests pin the negative space too: no gate refusal may borrow the
vocabulary of the read-only-session rejection ("reconnect", "Actions &
questions") or of the financial-methods refusal ("oisy.com"), since an
agent sent down either path would ask the user to fix something that is
not broken.

Claude-Session: https://claude.ai/code/session_01JarhapgjytMcVSCW7d8sqB

Co-authored-by: Claude <noreply@anthropic.com>
The guide says an `id` "is required and must be a canister principal". The
gate only checked that it parsed as A principal, which is a weaker rule
than the one the protocol states: user principals (29 bytes, 0x02), the
anonymous principal (1 byte, 0x04) and the management canister aaaaa-aa
(an EMPTY blob) all parse happily.

The management canister is the one that matters: without this, a manifest
declaring aaaaa-aa would authorize canister_update_call against it.

Opaque canister ids are 10 bytes with the 0x01 type tag, verified against
real ids rather than re-derived from memory, and every real app canister
is one. Enforced on the gate's read of a manifest only -- the display path
that feeds discovery listings is unchanged, since showing an odd declared
id is informative while acting on one is not.

Deliberately the spec's own type rule and not a policy list of ours:
WHICH canisters an app may declare is the app's business, but WHAT KIND of
principal an `id` may be is the protocol's.

Claude-Session: https://claude.ai/code/session_01JarhapgjytMcVSCW7d8sqB

Co-authored-by: Claude <noreply@anthropic.com>
marc0olo pushed a commit to dfinity/developer-docs that referenced this pull request Aug 28, 2026
…CP MCP (#372)

## Summary

Adds a visible disclaimer to
`docs/guides/frontends/service-discoverability.md` stating that adopting
the protocol opts an app in to being discovered by ICP MCP, under its
terms of service and privacy policy.

The protocol's whole purpose is that an agent handed only an app's URL
can work the rest out unattended, so publishing the manifest is the
moment the app becomes usable by agents that read it. The page did not
say so. It does now, above the fold, before any of the instructions it
qualifies.

Wording:

> **Adopting this protocol opts your app in**
>
> By implementing this service discoverability protocol for your app,
you agree that your app should be discovered by [ICP
MCP](https://internetcomputer.org/icp-mcp/), that you accept its [terms
of service](https://internetcomputer.org/icp-mcp/terms/), and that you
comply with its [privacy
policy](https://internetcomputer.org/icp-mcp/privacy-policy/).
Publishing the Layer 1 manifest at `/.well-known/ic-architecture` is the
act that opts your app in: the layers are otherwise independently
adoptable, and the rest of them describe how an agent works with an app
once it has been discovered.

Two smaller changes came out of review: the page `description` now names
ICP MCP, and the opening line speaks of "an agent working through ICP
MCP" rather than an abstract AI agent.

## Structural decisions

- **Placement:** immediately after the intro paragraph and before `##
The five layers`, so it is read before the instructions it qualifies
rather than being buried under them.
- **Aside type:** `:::caution` (used 11 times elsewhere in `docs/`)
rather than `:::note`, since the point was that it be clearly visible,
and it carries an agreement rather than an aside.
- **Naming the trigger:** without the second sentence, "implementing
this service discoverability protocol" sat in tension with the page's
own "each layer is independently adoptable", leaving it unclear whether
exposing only `getApiDoc` or an `ii-derivation-origin` also opts an app
in. It does not, and naming the manifest makes the page agree with what
the connector actually keys on
([dfinity/imcp2#166](dfinity/imcp2#166) gates
writes on exactly that document).
- **Why the consent statement is not in the frontmatter `description`:**
Starlight puts that field only into the page's `description` and
`og:description` meta tags, which cannot render markdown, so links there
would show as literal brackets in search results and link previews. The
statement lives in the aside, where it renders and where a reader sees
it.
- **Links:** all three target `internetcomputer.org/icp-mcp/`, verified
live (HTTP 200; the pages render "ICP MCP Terms of Service" and "ICP MCP
Privacy Policy").

## Verified

- `npm run build` passes on every commit; the page renders the aside as
`starlight-aside--caution` with all links intact, confirmed on the
deployed preview.
- No em-dashes, no `dfx`, no banned vocabulary (checked against
`icp-brand-voice`).
- The two remaining "AI agents" in the built page come from the
site-wide `agent-signaling` blockquote and llms.txt footer, injected
into every page by the `agent-docs` integration; out of scope here.
- `package-lock.json`, touched by `npm install` during setup, is
deliberately not included.

## Note for reviewers

@sea-snake approved at `b7e5e2b`. Two further commits landed after that,
both addressing review comments on this PR (`d9fe5df`, `389828e`), so
the approving commit is no longer the head and the approval may need
refreshing.

---------

Co-authored-by: Claude <noreply@anthropic.com>
claude added 2 commits August 28, 2026 15:55
main now ships a service-discoverability skill (#164's static bundle):
the deploy-time recipe for generating /.well-known/ic-architecture with
real per-environment canister IDs. That is exactly what someone who has
just hit this refusal needs, and the person reading it is sometimes the
one who can ship the manifest.

So the refusal names skill://service-discoverability alongside the
published guide, mirroring how compliance.rs points at skill://icp-cli
and skill://cycles-management for the operations it declines. "Publish a
manifest" is a much weaker handoff than the recipe for generating one.

Also merges main, which moved under this branch while it was open. No
textual conflict, but main's series touched tools.rs, IcTools::new and
the README, so the merge was verified rather than assumed: it builds and
the full suite passes (264 tests), and clippy is unchanged at 11.

The skill and this gate agree, which is worth stating: the skill tells an
app how to publish the manifest, and the gate makes publishing it the act
that permits a write.

Claude-Session: https://claude.ai/code/session_01JarhapgjytMcVSCW7d8sqB

Co-authored-by: Claude <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants