Skip to content

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

Merged
aterga merged 21 commits into
mainfrom
claude/discoverability-protocol-restrictions-njf4x3
Sep 1, 2026
Merged

Restrict update calls to canisters an app declares (service discoverability)#166
aterga merged 21 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.

That path is the only one that authorizes. This server proposed the same document at /.well-known/ic-app.json before the protocol existed; discovery still reads it, but it does not permit a write, because the apps serving it published it under different terms and never accepted the ones the standard manifest now signifies. Consent that was never given cannot be inherited from a path this server invented.

Apps already publish the standard path. An earlier revision of this description claimed nothing did — that was wrong, and the correction is load-bearing enough to state here rather than bury. Apps built with caffeine.ai publish the Layer 1 manifest, and the whole chain is verified live against one of them, svault.tech: it declares its frontend and backend at /.well-known/ic-architecture, pins a cross-origin derivation origin at /.well-known/ii-derivation-origin (https://skill-vault-iv4.dev.caffeine.xyz, which lists svault.tech back in its ii-alternative-origins), the gate authorizes a declared canister there, and the identity binding accepts the pair. So the write path is live rather than inert, and the YES branch has real coverage instead of a test that could only skip.

The manifest and the identity are bound. app_url picks which manifest is read; derivation_origin picks whose principal signs. Left unbound those are separable, and separable is exploitable: publishing a manifest is free and the gate deliberately does not prove ownership, so an attacker's origin could declare someone else's canister while the call went out under the principal the user holds at an app they actually trust. So where a call carries both, the app at app_url is resolved to the derivation origin Internet Identity derives its users from, and a call naming a different one is refused. An attacker's manifest can then only ever authorize writes made as the attacker's own app identity, which is worth nothing to them. svault.tech is why this resolves the app rather than comparing app_url to derivation_origin: a literal comparison would have false-refused every write to it.

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, and one svault.tech would hit today.

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.

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. Two limits worth stating outright: an anonymous write skips the identity binding (there is no app identity to protect), and an authenticated one binds to the caller's own app while still reaching any victim method that accepts an arbitrary principal. Neither grants a capability an attacker lacked — the IC accepts ingress from anywhere, so both calls can be sent with an ordinary agent and no connector at all. What the gate withholds is this connector's willingness to make them on a user's behalf; the binding keeps the user's own app principals out of it; and declared_by/declared_at makes a write traceable to the claim that authorized it. Closing the rest needs an association the target attests to — see open question 7.
  • 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

Privacy-policy wording split out as #174.

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 / only the pre-protocol document published / published but this canister is not in it / the manifest is too long to have checked / the app is not the one being signed as); each manifest refusal names the standard path, links the guide, points at skill://service-discoverability, and states that reads are unaffected. A failure retrying cannot clear — a stopped redirect, a 401/403, an over-size document, an unauthorized cross-origin claim — says so instead of advising a retry.
  • 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.
  • Only /.well-known/ic-architecture authorizes. ManifestProbe::Absent carries the legacy document when the origin still serves one, so an early adopter gets a refusal naming what they do publish and saying that serving the same JSON at the standard path is the whole fix.
  • The identity binding. The comparison is against what the app resolves to — its declared Layer 5 origin, else a known-app value, else its own origin — not the app URL literally, so apps whose derivation origin differs from their website still pass (13 of 17 in the built-in registry, and svault.tech live); resolve_app_identity enforces Internet Identity's own ii-alternative-origins rule on the way. Fails closed, runs concurrently with the manifest fetch, and applies only when the caller named an app and is acting as someone.
  • Every fetch on these paths is pinned to the origin that was probed (answered_by, shared by fetch_well_known, fetch_alternative_origins and ic_evidence_from). The redirect policy permits same-host/different-port and public-IP hops, so without this a 3xx could let another origin supply an app's Layer 5 declaration, its ii-alternative-origins authorization list, or a manifest attributed to it in discovery — each a true-looking but wrong statement about who declared what. A redirected answer is Unreachable, never Absent: a document we chose to ignore is not one the app does not have, and treating it as absent would quietly select the default principal.
  • The known-app registry is keyed on the origin, not the bare host. https://oisy.com:8443 is a different origin everywhere else in this codebase, so it no longer inherits Oisy's derivation origin — which, at the gate, would have meant a manifest served there authorizing a write signed as the user's real Oisy principal.
  • discover reads /.well-known/ic-architecture (top authority tier, above the legacy path) and /.well-known/ii-derivation-origin, concurrently. Manifest labels fall back to the protocol's name when no role is given.
  • Dropped the /ai-connect.html source (per review): the App Connect bridge page is not part of the protocol.
  • Fail-closed, in the places review sharpened — the theme being that a value we could not read is not a value the app does not have: the SPA catch-all reads as "publishes none" and the refusal names it; the Layer 5 file's documented format is enforced rather than coerced; a transport failure is not an absent declaration; a non-success status only becomes "publishes no manifest" for 404/410; a body that dies mid-read or exceeds its cap is detected rather than passed off as whole (with overflow meaning "not that document" only where the form bounds the size — getting that uniform first broke oisy.com, caught by a live test and pinned); an over-long manifest yields an indeterminate verdict rather than a false "does not declare".
  • Canister-principal rule — the guide says an id "must be a canister principal"; the gate enforces that rather than merely "parses as a principal", which notably stops a manifest declaring the management canister aaaaa-aa.
  • Bounded, sanitized echoesapp_url is reduced to its origin before it is checked, fetched or echoed, and every echoed origin and transport error goes through a control-char scrub capped at 200 chars.
  • Docs: README gains a "Writes are gated on the discoverability manifest" section covering both halves of the gate and both limits of the ownership claim; the app-declared-metadata section leads with the protocol; the tool table, typical flow and derivation-origin precedence notes are updated; the SSRF section records that canister_update_call makes an untrusted outbound fetch.
  • Merged main three times (3473724, 2279f91, f596c65), each verified with a build and the full suite. For Describe the surface in model-readable metadata, don't direct the model #167 this branch's metadata was in the old directive register, so the resolution is main's text with this branch's facts restated descriptively.

Open questions for the maintainer

  1. Nothing published on the standard path yet. Answered, premise was wrong — caffeine.ai-built apps publish it, svault.tech among them. The legacy path still does not authorize; the write path is live rather than inert.
  2. The submission docs' demo app. Answered in review: a manifest will be published on OpenCloud's side rather than moving the docs off opencloud.org, which restores both worked examples as written — and the demo app in a directory submission should be one that has actually opted in.
  3. Privacy policy. Split out as Privacy policy: two sentences go stale once writes fetch the app's origin #174, with both sentences and both file locations. Legally reviewed text in two repos, so not appended to this diff.
  4. Derivation-origin coupling. Decided and implemented — the identity binding above.
  5. System-canister denylist. Answered by Refuse update calls to known finance-related canisters #163, merged in.
  6. Manifest version. Currently ignored. svault.tech publishes "1.0.0". 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". Still open.
  7. Target-attested opt-in — the ownership gap. A manifest is a claim by whoever controls a domain, so an attacker can publish one naming a victim canister and satisfy this gate. My read is that this costs the victim nothing an attacker did not already have — the IC accepts ingress from anywhere — and that what the gate withholds is this connector acting on a user's behalf. If the intended claim is stronger, the fix is an association the target attests to, and nothing a canister publishes today names its owning app origin. That is a change to the published protocol, not something to invent privately here. Worth taking to the protocol's owners?
  8. Legacy-probe latency. Accepted as-is in review: a latency tail rather than a correctness defect, and it disappears when the legacy read is retired — not worth restructuring concurrency on an approved diff.

Testing

  • cargo build --locked --workspace --all-targets
  • cargo test --locked --workspace --all-targets — 266 pass, 0 fail. Live coverage against svault.tech for the YES path and the cross-origin identity binding; against MULTI/DEX that the legacy path no longer authorizes; and that crossing one app's manifest with another's identity never binds. Everything the live tests assert is also pinned on constructed input (the_decision_authorizes_only_a_declared_canister, only_a_definitive_absence_reads_as_not_published, a_permanent_failure_is_not_described_as_transient, known_registry_does_not_match_a_non_default_port), since the live ones depend on someone else's deploy staying up. The origin-pinning checks are not test-pinned — driving one needs a final URL differing from the probed one, and the SSRF guard refuses loopback, so there is no local server to do it from; that limitation is recorded in the code, and the blast radius was checked live across all six known-app origins, none of which redirects on either well-known path.
  • cargo clippy --locked --workspace --all-targets — 11 warnings, 10 pre-existing.
  • Verified live: svault.tech publishes the Layer 1 manifest and a cross-origin Layer 5 file and authorizes end to end; oisy.com and opencloud.org answer the well-known paths with their SPA shell (the fail-closed case); MULTI/DEX publishes a complete legacy manifest and is refused.
  • cargo fmt --all — deliberately not run: main already carries 491 rustfmt deviations, the house style is hand-formatted, and CI does not check it.
  • 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>
Comment thread crates/imcp2-core/src/discover.rs Outdated
claude added 2 commits August 31, 2026 10:11
The App Connect bridge page is not part of the service-discoverability
protocol, so discovery no longer probes /ai-connect.html or reads its
ic:canister-id meta. App-declared metadata is now exactly the two manifest
documents: /.well-known/ic-architecture (Layer 1, the only source that
authorizes an update call) and the legacy /.well-known/ic-app.json.

The HTML meta parser and its attribute tokenizer went with it: nothing else
called them. The authority ranking closes the gap the removed tier left, so
the protocol manifest now leads; docs and tool descriptions follow.

No discovery coverage is lost in practice. Of the apps we probe, only
multidex.ai actually serves the meta, and it declares that same backend id
in its ic-app.json manifest; the others answer /ai-connect.html with their
SPA catch-all, which never carried the meta.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JarhapgjytMcVSCW7d8sqB
Both #163 (financial-canister refusals) and #165 (landing site dropped)
landed under this branch and touched the same files.

Conflicts and how they were taken:

- `SERVER_INSTRUCTIONS`: keep this branch's "WRITES GO ONLY TO APPS THAT
  OPTED IN" paragraph, take main's rewritten FINANCIAL paragraph (which
  names no venue and states the guard is a safeguard, not a complete
  filter). The two paragraphs are about different things and both stand.
- `canister_update_call` README row: main's fuller financial-policy text,
  plus this branch's `app_url` argument and declaration gate.
- `discover_app_canisters` description: this branch's provenance list, with
  main's example domain — its new test refuses any ".com" in a tool
  description, which the old "oisy.com" example would now trip.
- Test comments: this branch's two gate tests, then main's rewritten note on
  `financial_policy_is_a_server_instruction_not_a_description`.

The gate ordering survives the merge: `compliance::disallowed_update_method`
(now four scopes) still runs first, then the discoverability gate, then any
network work.

Build and the full suite pass on the merge: 254 tests, 0 failures.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JarhapgjytMcVSCW7d8sqB
@aterga
aterga marked this pull request as ready for review August 31, 2026 14:21
@aterga
aterga requested review from a team and a balanced review from Copilot August 31, 2026 14:21
Only /.well-known/ic-architecture authorizes an update call now. Publishing
that manifest is the act by which an app's operators opt in, under this
connector's terms; the operators who adopted this server's pre-protocol
/.well-known/ic-app.json proposal published it against different terms and
never agreed to those. Consent that was never given cannot be inherited from
a path this server invented, so an early adopter is refused.

Discovery still READS the legacy document — reads were never gated — and the
gate still fetches it, but only as evidence for the refusal: an origin that
serves it gets its own message naming the document it DOES publish, saying
that serving the same JSON at the standard path is the whole fix, and, when
the older document lists the target, making clear the refusal is about where
the declaration lives rather than about an unknown canister. Reporting these
apps as "publishes no manifest" would send their operators hunting for a file
that is already there.

ManifestProbe::Absent carries that document; only ARCHITECTURE_PATH can
produce Declared. The log line changes with it: the origins still on the old
path are now recorded as refused rather than as authorized, which is the
same adoption signal read the other way round.

Verified live: multidex.ai, the one origin that authorized anything through
the legacy path, is now refused — a new live test pins exactly that, against
the id its own legacy document declares.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JarhapgjytMcVSCW7d8sqB

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds service-discoverability authorization for canister update calls.

Changes:

  • Gates writes using app manifests and returns declaration provenance.
  • Adds protocol and derivation-origin discovery.
  • Updates tool guidance, documentation, and tests.

Reviewed changes

Copilot reviewed 8 out of 8 changed files in this pull request and generated 5 comments.

Show a summary per file
File Description
README.md Documents write gating and discovery.
docs/openai-directory-submission.md Updates discovery terminology.
crates/imcp2-core/src/tools.rs Enforces the gate in update calls.
crates/imcp2-core/src/management.rs Clarifies management-call scope.
crates/imcp2-core/src/lib.rs Registers the new module.
crates/imcp2-core/src/discoverability.rs Implements authorization and refusals.
crates/imcp2-core/src/discover.rs Adds protocol manifest discovery.
crates/imcp2-core/src/calls.rs Extends update inputs and outputs.
Suppressed comments (1)

crates/imcp2-core/src/discoverability.rs:186

  • This refusal also echoes the raw caller-supplied origin without a length cap. A valid URL with a very large path/query is reduced to its origin for the fetch but reproduced in full here, defeating this module's stated bound on externally influenced refusal text. Scrub/cap it before formatting.
    let mut msg = format!(
        "{app_origin} publishes no service-discoverability manifest at {ARCHITECTURE_PATH}, so \

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread crates/imcp2-core/src/discoverability.rs
Comment thread README.md
Comment thread crates/imcp2-core/src/tools.rs Outdated
Comment thread crates/imcp2-core/src/discover.rs Outdated
Comment thread crates/imcp2-core/src/discoverability.rs
claude added 2 commits August 31, 2026 14:26
#167 rewrote the served metadata to describe the surface rather than direct
the model, and added a test that scans the instructions, all 11 descriptions
and every argument/reply schema for the manipulations the directories
prohibit. This branch's metadata was written in the old register, so the
resolution is main's text plus this branch's facts restated in the new one:

- SERVER_INSTRUCTIONS: main's rewrite, with the write gate as a descriptive
  paragraph. The old "WRITES GO ONLY TO APPS THAT OPTED IN" block told the
  model what to do when refused ("do NOT keep retrying", "tell the user");
  the replacement states what the gate is, that reads are not gated that way,
  and who can change it. The refusal texts still carry the actionable detail
  — they are tool results, not metadata.
- canister_update_call: main's description plus the gate, `app_url`, and the
  `declared_by`/`declared_at` echo, as statements.
- calls.rs `app_url`: same, in the schema doc ("Omitted, `derivation_origin`
  is used as that origin instead" rather than "omit it only when…").
- resolve_app: main's description with the declared source corrected to
  /.well-known/ii-derivation-origin, which this branch made take precedence
  over the legacy field.
- discover_app_canisters + the README row: main's text with the ai-connect
  provenance replaced by the two manifest documents, per review.
- open_app: main's description and reply text, noting that `app_url` is the
  origin the write gate reads the manifest from.

Two of this branch's own assertions pinned the old wording (READABLE, "WRITES
GO ONLY…"); they now pin the same facts in the new text.

Build and the full suite pass on the merge: 259 tests, 0 failures, including
main's new metadata scan over the text added here.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JarhapgjytMcVSCW7d8sqB
Follows the gate change: the README's write-gate section gains the reason the
legacy document cannot stand in (its publishers adopted a pre-protocol
proposal under different terms), the refusal-cause list gains that case, and
the app-declared-metadata section no longer reads as though the legacy path
keeps writes working. The discover module's own docs say the same: the legacy
manifest is read for discovery, not for authorization.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JarhapgjytMcVSCW7d8sqB
Copilot AI review requested due to automatic review settings August 31, 2026 14:28

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 8 out of 8 changed files in this pull request and generated 5 comments.

Suppressed comments (3)

crates/imcp2-core/src/discover.rs:856

  • A transport failure of the higher-authority Layer 5 probe is collapsed to None, so resolve_declared_origin silently falls back to the legacy manifest. If an app has migrated from legacy origin B to Layer 5 origin A and only the Layer 5 request times out, this signs as the stale B principal. Preserve the probe error state and fail closed instead of consulting a lower-authority declaration when the higher-authority source could not be checked.
    let Ok(resp) = client.get(format!("{application_origin}{path}")).send().await else {
        return (None, false);

README.md:379

  • This updated precedence statement conflicts with README.md:337-342, which still says the legacy manifest's top-level derivation_origin is the only authoritative source and tells apps to publish it there. That section now directs new apps away from the newly preferred Layer 5 file; update it to name /.well-known/ii-derivation-origin first and describe the top-level field as legacy fallback.
> (`/.well-known/ii-derivation-origin`, else the legacy `/.well-known/ic-app.json`
> → `derivation_origin`), else a built-in **known-app**

crates/imcp2-core/src/discover.rs:1455

  • The protocol manifest is the only source that can authorize a write, but its request error is ignored whenever the legacy probe returns any response. A path-specific timeout can therefore be reported as “no manifest” or “legacy only,” including instructions to stop retrying, even though the authoritative path was never checked. Propagate an architecture error regardless of the legacy probe result.
    // Unreachable on BOTH probes: we cannot tell "publishes no manifest" from
    // "we could not ask", so report the failure instead of the absence.
    if let (Err(e), Err(_)) = (&architecture, &legacy) {
        return Err(format!("could not reach {origin}: {e}"));

Comment thread crates/imcp2-core/src/tools.rs Outdated
Comment thread crates/imcp2-core/src/discover.rs Outdated
Comment thread crates/imcp2-core/src/discover.rs
Comment thread crates/imcp2-core/src/calls.rs Outdated
Comment thread crates/imcp2-core/src/discoverability.rs
The manifest gate establishes that someone published a document at the origin
the caller named. It said nothing about whose identity the call goes out
under, and those were separable inputs: app_url picked the manifest,
derivation_origin picked the principal. Publishing a manifest is free and the
gate deliberately does not prove ownership, so an attacker's origin could
declare someone else's canister while the call was signed with the principal
the user holds at an app they actually trust.

Where a call carries both, the app at app_url is now resolved to the
derivation origin Internet Identity derives its users from, and a call naming
a different one is refused. An attacker's manifest can then only ever
authorize writes made as the attacker's own app identity, which is worth
nothing to them.

The comparison is against what the app RESOLVES to — its declared Layer 5
origin, else a known-app value, else its own origin — not against the app URL
literally, so the many apps whose derivation origin differs from their website
(13 of 17 in the built-in registry) still pass. resolve_app_identity also
enforces Internet Identity's own rule on the way: a cross-origin declaration
counts only if the declared origin authorizes this app in its
ii-alternative-origins, so an app cannot claim another's identity to satisfy
the check. It fails closed — an origin that cannot be resolved refuses rather
than being treated as a match — and the two probes run concurrently, so the
binding costs no extra round trip.

The binding applies only when the caller named an app AND is acting as
someone: with no app_url the manifest is read at the identity's own origin, so
the two are the same app by construction, and an anonymous call carries no app
identity for a foreign manifest to misuse.

Also in this commit, the review findings on the same path — the binding rests
on the first of them, and the rest are one-line corrections it would have been
artificial to separate:

- A transport failure on the identity path no longer collapses into "not
  declared". fetch_well_known now distinguishes Served / Absent / Unreachable,
  and an unreachable probe is an error rather than a fallback: a Layer 5 file
  we failed to fetch is not "no Layer 5 file", and defaulting past it would
  derive — and sign as — a principal the app does not pin. Refusing is
  recoverable; acting as the wrong identity is not.
- app_url is reduced to its origin before it is checked, fetched or echoed, so
  a refusal cannot be flooded through the unbounded path of an otherwise valid
  URL, and every echoed origin goes through the module's own cap.
- The gate's decision is split from the fetch that feeds it. With nothing
  published on the standard path yet, every live test of the YES branch can
  only skip, and a local fixture is unreachable behind the SSRF guard — so the
  decision is now pinned on constructed input, including that the legacy
  document does not authorize.
- Three resolution messages and a README paragraph still named only the legacy
  /.well-known/ic-app.json, which stopped being the only source of a declared
  derivation origin when this branch gave Layer 5 precedence.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JarhapgjytMcVSCW7d8sqB
Copilot AI review requested due to automatic review settings August 31, 2026 14:42

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 8 out of 8 changed files in this pull request and generated 5 comments.

Suppressed comments (1)

crates/imcp2-core/src/calls.rs:265

  • The output schema says declared_at may be the legacy path, but decide only constructs a successful Declaration from ManifestProbe::Declared, which is exclusively ic-architecture; a legacy manifest always returns a refusal. Advertising the legacy value here contradicts the gate and can make API consumers believe that path can authorize writes.
    /// The well-known path that declaration was read from:
    /// `/.well-known/ic-architecture` (the protocol) or `/.well-known/ic-app.json`
    /// (this server's legacy pre-protocol path).

Comment thread crates/imcp2-core/src/discover.rs Outdated
Comment thread crates/imcp2-core/src/discover.rs Outdated
Comment thread crates/imcp2-core/src/tools.rs Outdated
Comment thread crates/imcp2-core/src/tools.rs Outdated
Comment thread crates/imcp2-core/src/tools.rs
Three findings from the second review pass, all on the same theme: a value
we could salvage is not the same as a value the app declared.

- The Layer 5 file's documented format is now ENFORCED rather than coerced.
  It was read through the general origin normalizer, which accepts a bare host
  and quietly drops a path — so `example.com` or `https://example.com/path`
  became an authoritative identity declaration. This file decides which
  principal the user acts as, and a wrong one fails silently, as a call that
  simply isn't them. Anything that is not exactly one non-empty line carrying
  an explicit https origin now reads as "declares nothing", which lands the app
  on its own visible origin: the default the protocol already specifies for an
  absent file. The legacy manifest's field keeps the looser parse it shipped
  with; only the protocol's own document has a published format to hold to.

- A non-success status no longer collapses into "publishes no manifest". That
  refusal tells the app's operators to publish one and the agent to stop
  retrying, so only a definitive absence may produce it: 404 and 410 do, while
  429 and the 5xx family are a failure to find out, and a 403 is the origin
  declining to say. The rule is a small pure function so it can be pinned
  without a network, which the SSRF guard would otherwise make impossible.
  The gate's probe follows: only the standard path's failure is fatal now,
  since a legacy probe can only enrich a refusal that is happening anyway.

- `declared_at` documented a value a successful call can no longer carry. It is
  always the standard path, and says so.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JarhapgjytMcVSCW7d8sqB
Comment thread crates/imcp2-core/src/calls.rs Outdated
Copilot AI review requested due to automatic review settings August 31, 2026 14:46

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 8 out of 8 changed files in this pull request and generated 1 comment.

Suppressed comments (2)

Previously missed (2) — in code that hasn't changed since the last review.

crates/imcp2-core/src/discoverability.rs:433

  • For an over-limit manifest, this sentence still states that the app “does not declare” the target before the next sentence admits that later entries were never checked. If the target is entry 101, that is the false assertion this overflow handling is intended to avoid. Use an indeterminate/too-long conclusion whenever omitted > 0, and reserve “does not declare” for fully inspected manifests.
        "{} does not declare {canister_id} in its manifest ({}), so this connector will not make a \
         state-changing call to it. It {declares}.{overflow} {} Use one of the declared canisters for this \

crates/imcp2-core/src/discoverability.rs:385

  • This message labels every resolution failure “likely transient,” but bind_identity also routes permanent failures here, such as a cross-origin derivation declaration that is not authorized by ii-alternative-origins. Retrying cannot repair that configuration and conflicts with the embedded cause. Make the retry advice conditional and identify the persistent configuration fix.
fn identity_unresolvable_refusal(app_origin: &str, cause: &str) -> String {
    format!(
        "Could not establish that {} is the app this call would be signed as: {}. The call is          refused rather than made blind, because a manifest only authorizes a write when it comes          from the app whose identity is signing. This is likely transient — retry; if it persists,          confirm that `app_url` names the app you are acting at (open_app returns its `app_url`          and `derivation_origin` together). {READS_ARE_FINE}",
        safe_origin(app_origin),
        safe_cause(cause)
    )

Comment thread crates/imcp2-core/src/discover.rs Outdated
Both describe code this branch changed, and both would mislead a maintainer
about exactly the semantics the change exists to establish:

- WellKnown::Absent still said "any non-2xx". Only 404 and 410 reach it now;
  every other non-success status is Unreachable, because an origin declining
  or failing to serve the path has not told us the app declares nothing. Both
  variants now say what they mean and what they exclude.
- resolve_declared_origin still said a "missing/unsuccessful/undeclared"
  declaration yields the application-origin default. Only a declaration the
  app ANSWERED without providing does; a probe that did not complete is an
  error, since defaulting past it would derive, and sign as, a principal the
  app does not pin.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JarhapgjytMcVSCW7d8sqB
Copilot AI review requested due to automatic review settings August 31, 2026 15:26

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 9 out of 9 changed files in this pull request and generated 3 comments.

Comment thread crates/imcp2-core/src/discoverability.rs
Comment thread README.md
Comment thread crates/imcp2-core/src/tools.rs
A non-default port is a different origin everywhere else in this codebase
— Internet Identity derives a different principal for it, and
identities::target_origin keeps it rather than stripping it the way it
strips :443 — but KNOWN_DERIVATION_ORIGINS is keyed by host alone, so
https://oisy.com:8443 inherited https://oisy.com. At the write gate that
is the whole comparison: a manifest served on that port would authorize
a write signed as the user's principal at the real app.

The registry stays host-keyed; the lookup for anything derived from a
URL now refuses a non-default port and drops to app_url_default, which
being a distinct origin then fails the binding on its own. Reaching the
case needs control of the registered host, so this closes the gap rather
than a live break.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 9 out of 9 changed files in this pull request and generated 2 comments.

Suppressed comments (4)

Previously missed (2) — in code that hasn't changed since the last review.

crates/imcp2-core/src/discover.rs:889

  • The legacy probe has the same unconditional transient diagnosis: a 403, blocked redirect, or over-cap document can never be fixed by retrying, yet this error explicitly recommends it. Avoid classifying every Unreachable cause as transient so callers receive an actionable response for permanent serving failures.

This issue also appears on line 1050 of the same file.

        WellKnown::Unreachable(e) => Err(format!(
            "could not read {LEGACY_MANIFEST_PATH} at {application_origin}: {e}. Refusing to \
             assume this app derives against its own origin while a declaration it may publish is \
             unreadable — this is likely transient, so retry."

crates/imcp2-core/src/discover.rs:1706

  • When the standard path follows an allowed redirect to a different origin, this branch ignores the response and later returns Absent. The resulting refusal falsely says the app publishes no manifest and tells callers to stop retrying, although the actual failure is the redirect-origin policy. Return a persistent check error for the standard path; the legacy response can still be ignored because it never authorizes.

This issue also appears on line 1849 of the same file.

        if doc.served_from != origin {
            tracing::warn!(
                probed = %origin,
                served_from = %doc.served_from,
                path,

crates/imcp2-core/src/discover.rs:1852

  • fetch_success_body discards FetchedDocument.served_from, so discovery accepts a manifest reached through an allowed different-origin redirect and labels its canisters as ic-architecture declarations by the requested app. Preserve the response metadata here and only add manifest findings when served_from == origin, matching the authorization path's provenance rule.
    let (architecture, legacy_manifest) = tokio::join!(
        fetch_success_body(&client, &architecture_url, MAX_META_BYTES),
        fetch_success_body(&client, &legacy_url, MAX_META_BYTES),
    );

crates/imcp2-core/src/discover.rs:1054

  • WellKnown::Unreachable includes deterministic failures such as HTTP 401/403, stopped redirects, and oversized documents, but this message always calls them “likely transient” and tells resolve_app/open_app callers to retry. Use neutral guidance or classify persistent causes here as the discoverability refusal already does.
        WellKnown::Unreachable(e) => {
            return Err(format!(
                "could not read {DERIVATION_ORIGIN_PATH} at {application_origin}: {e}. Refusing to \
                 derive an identity from a lower-priority source while the app's own declaration \
                 is unknown — this is likely transient, so retry."

Comment thread crates/imcp2-core/src/discover.rs
Comment thread crates/imcp2-core/src/discover.rs
fetch_declared_manifest honours a manifest only when the origin that
answered is the origin that was probed. The identity path did not: only
the IC-evidence capture compared the final URL, while the BODY was
parsed whatever origin produced it. The shared redirect policy refuses a
cross-domain hop but permits same-host different-port hops and hops to
global IP literals, so a 3xx could hand another origin's bytes to Layer
5 — the document that decides which principal every call is signed as.

Same rule, in the one place fetch_well_known already funnels every
identity probe through. A redirected answer is Unreachable rather than
Absent: Absent means "the app declares nothing, derive against the
default", and a document we deliberately ignored is not one the app does
not have.

Neither pin can be driven from a local server (both need a final URL
that differs from the probed one, and the SSRF guard refuses loopback),
so both stay on the live tests, as the existing comment now records. The
full suite, live tests included, is unchanged at 266.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings August 31, 2026 16:21

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 9 out of 9 changed files in this pull request and generated 1 comment.

Suppressed comments (1)

crates/imcp2-core/src/discover.rs:964

  • The final-origin check happens only after this status branch. If an allowed same-host/different-port or public-IP redirect ends in a 404/410, this returns Absent and may fall back to a legacy/default derivation origin even though the application origin never answered the Layer 5 probe. Check resp.url().origin() before interpreting any status so every redirected response remains Unreachable.
    let status = resp.status();
    if !status.is_success() {
        // Same rule as the gate's probe, for the same reason and with more at
        // stake: only a definitive "not here" may mean the app declares nothing.
        // A 429 or 5xx during an outage would otherwise fall through to the

Comment thread crates/imcp2-core/src/discover.rs
Two gaps the previous commit left, both raised in review.

The origin pin in fetch_well_known ran AFTER the status classification,
so a redirect target's 404 still became Absent — "the app declares
nothing, derive against the default" — which is the bug the pin exists
to prevent, reached by a different route. Nothing a foreign origin says
about this path is an answer about this app, so the pin now comes first
and the status is only read once the probed origin is the one answering.

fetch_success_body dropped served_from entirely, so discovery attributed
a redirect target's manifest to the app that was probed, at the protocol
path — the top authority tier, which is what the model picks a canister
on. Discovery authorizes nothing, but it does make a provenance claim,
and that claim was capable of being wrong in exactly the way
fetch_declared_manifest already pins against.

Suite unchanged at 266, live tests included.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings August 31, 2026 16:31

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 9 out of 9 changed files in this pull request and generated 2 comments.

Suppressed comments (3)

Previously missed (2) — in code that hasn't changed since the last review.

crates/imcp2-core/src/tools.rs:1865

  • This branch is reached only after resolve_app_identity completed both required probes; transport failures and other non-success statuses return earlier as errors. Saying the files “couldn't be fetched” therefore reports a network failure that did not occur. Describe this as neither file declaring a usable origin.
         response) — and neither /.well-known/ii-derivation-origin nor the legacy \
         /.well-known/ic-app.json could be fetched or declares an Internet Identity derivation \
         origin. Refusing to treat it as an app. "

crates/imcp2-core/src/discoverability.rs:336

  • Every non-manifest 2xx response gets the SPA-catch-all diagnosis, even when the captured type is application/json (for example {} or an error envelope). That sends operators to change rewrite rules when the actual problem is the JSON shape. Reserve the index.html guidance for text/html; describe other types as an invalid manifest body.
    if let Some(kind) = served_non_manifest {
        let kind = safe_cause(kind);
        msg.push_str(&format!(
            "It DOES answer that path, but with {} rather than the manifest JSON — the usual cause \
             is a single-page-app catch-all returning index.html for unknown paths, which the app \
             fixes by exempting /.well-known/* from the SPA rewrite. ",
            if kind.is_empty() { "a non-manifest document".to_string() } else { format!("`{kind}`") }
        ));

crates/imcp2-core/src/discover.rs:1599

  • The response status is classified before the final response origin is checked. An allowed same-host/different-port or public-IP redirect that ends in 404/410 therefore becomes Ok(None), so the gate claims the original app publishes no manifest even though that origin never supplied the absence. Validate resp.url().origin() before status handling and return an error on a mismatch.
    let resp = client.get(url).send().await.map_err(|e| e.to_string())?;
    let status = resp.status();
    if !status.is_success() {
        return if means_not_published(status) {
            Ok(None)

Comment thread crates/imcp2-core/src/discover.rs
Comment thread crates/imcp2-core/src/discoverability.rs
ii-alternative-origins is the list that decides whether a cross-origin
derivation-origin claim is honoured, and it was the one fetch on this
path still parsing whatever a successful response carried. A redirect
target's answer is not the declared origin's statement, so it cannot
grant the claim; it now returns the empty list every other failure here
already returns, which is the fail-closed value.

The rule the last two commits applied in two places is now one
predicate, answered_by, shared by all three (ic_evidence_from already
had it inline).

Suite unchanged at 266, live tests included — a_cross_origin_declared_
identity_binds still passes, so the real cross-origin case is untouched.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings August 31, 2026 16:44

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 9 out of 9 changed files in this pull request and generated no new comments.

Suppressed comments (1)

crates/imcp2-core/src/discover.rs:1623

  • Validate the final response origin before interpreting the status. As written, an allowed redirect to another origin that returns 404/410 becomes Ok(None), so the gate reports that the probed app publishes no manifest instead of treating the redirected answer as an indeterminate check. This also reintroduces the status-ordering issue previously marked fixed; derive the probed origin from url, compare it with resp.url() immediately, and only then classify the status.
    let status = resp.status();
    if !status.is_success() {
        return if means_not_published(status) {
            Ok(None)

@aterga
aterga merged commit 5ce966f into main Sep 1, 2026
13 checks passed
sea-snake added a commit that referenced this pull request Sep 1, 2026
#166 restricts update calls to canisters an app declares, which changed
behaviour this branch describes, so the conflicts were reconciled on the facts
rather than textually.

- canister_update_call gains `app_url` and the manifest gate. Its description
  and the `app_url` schema now carry both, in this branch's shorter form but
  using the exact phrases #166's own test pins.
- The instructions gain a WRITES section: reading any canister is open, a write
  reaches only a canister its owning app declares, and a refusal does not close
  the app to reads.
- `sources` values changed on main: "ai-connect.html" is gone and
  "ic-architecture" replaces it, with only that one authorizing a write. This
  branch's trust framing would have named a source that no longer exists.
- open_app now says the app URL it returns is what canister_update_call takes.

Two things the merge surfaced, unrelated to the conflict:

`candid://reference` was named as though a model could act on it. It is an MCP
resource, reachable only through a client that surfaces resources, so both
mentions now say so rather than dangling a URI.

candid_syntax_guide described its own content as "the `(...)` form and the
literal for each type", which says nothing. It now names what the document
holds: the literal form per Candid type, and when a value needs an explicit
`: type` annotation.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
sea-snake added a commit that referenced this pull request Sep 1, 2026
Descriptions taken from review, mostly verbatim:

- canister_update_call loses the discoverability gate. It is a precondition the
  caller can neither check nor act on before calling: `app_url` is documented on
  its own argument, and a refusal names the manifest path and links the guide.
  The three description assertions in #166's gate test go with it; its
  instructions assertions and the app_url schema check stay, so the gate is
  still stated once, server-wide.
- open_app loses the no-constructed-domain warning. That safeguard existed
  because resolve_app and discover_app_canisters accepted URLs only, so a caller
  holding a name had to invent one. Both are gone; open_app takes a name, and
  its first sentence says so. open_app_metadata_forbids_a_constructed_domain is
  removed with the warning, and the `app` schema trimmed to match. The refusal
  itself is untouched.
- get_app_principal, list_app_accounts, get_canister_oql_schema and
  get_canister_api_doc take the reviewer's wording. get_app_principal keeps one
  clause on the query-only session, the only pointer to where the access level
  is reported.
- canister_query takes the suggestion minus one sentence whose two halves were
  each restated by the two that follow it.
- Ordering imperatives ("Use this before ...") are gone from four descriptions;
  "Use this when ..." triggers stay, as the review's own suggestions keep them.

icp_find_app_by_name is deleted. It was a pure offline read of the same
four-app registry open_app already consults, returning a strict subset of what
open_app returns, and it was not served. The find_app_by_name function stays:
open_app builds its unknown-name refusal from it.

Two accuracy fixes:

- The instructions still said canisters "expose OQL instead" — the sixth copy of
  a claim fixed five times elsewhere. They now say a canister declares OQL in
  addition, that this server refuses a Candid `method` query on it, and that
  update calls are unaffected.
- `omitted` said the least authoritative "went first", which reads as a claim
  about list order and inverts it. It now says the list is ordered most
  authoritative first and drops come off the other end.

Declined: four review comments asking for the name-based nature of the OQL and
api-doc detection to be restored. It is an implementation detail no client can
act on, and the actionable consequence is already stated.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
aterga pushed a commit that referenced this pull request Sep 1, 2026
* docs(metadata): describe the surface once, not in every field

The model-readable metadata taught the same three concepts in every tool:
what textual Candid is, what a derivation origin is, and how a session is
authorized. Each description then re-derived them, qualified them, and
restated what the schemas already said, so a client paid ~11.5k tokens
before its first call.

The server instructions now teach those three concepts once, and every
description is written assuming them. Each states what the tool does, when
to use it, which sibling owns the adjacent case, and the one prerequisite
that gates success — nothing else.

Facts were relocated rather than dropped: argument semantics to the
argument's own schema, the meaning of a returned value to the output
schema, diagnosis and repair to the result text, and internal thresholds,
metadata keys and legacy parameter aliases to source comments, which are
not sent to a model at all.

Two narrowings the policy gate caught in this change's own text
("use this for anything...") are fixed rather than worked around, and the
schema-scan liveness sentinel now compares on collapsed whitespace so a
rewrap cannot masquerade as a broken scan.

Metadata: 46,006 -> 23,655 chars (~11.5k -> ~5.9k tokens).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs(metadata): fix what four review passes found

Accuracy defects, each traced to the code that contradicts the claim:

- `args` claimed unconditional typed encoding; `encode_args` falls back to
  `to_bytes()` whenever no interface is available, where a bare `42` needs its
  own annotation. A model trusting the old wording emits an unencodable arg.
- "does not answer Candid data queries" was wrong twice: this server refuses
  the call, not the canister, and it refuses every `method` query on an OQL
  canister, not only data queries.
- `open_app` asserted the no-IC-evidence refusal unconditionally; it fires only
  where the derivation origin had to be assumed. `resolve_app` kept the
  qualifier, `open_app` had dropped it.
- `get_app_principal` said "without calling any canister" while deriving a
  delegation against the Internet Identity canister.
- The instructions understated the update guard as "asset-moving calls": it also
  refuses neuron management and every update call to a listed financial service.
- "publishes an API doc" promised what a declaration check cannot establish.
- `read_only` named a consequence that is not on the served surface, and read
  as proof of write access when the level is merely unknown.
- The anonymous-principal rule was stated globally but does not hold for the
  OQL reads, which reject a call with no origin.

The policy scan now covers every tool DEFINED here, not the 11 served. That gap
is why a hyphenated `web-search` errand sat in a schema field and why the
deferred half kept its `ALWAYS call this FIRST` era wording; the widened scan
caught the former on its first run.

Two gate corrections it forced. `web-search` joins the blocklist, since the
hyphen alone walked past it. The bare token `base64` leaves it: the banned shape
is an instruction to decode, and `icp_install_code` legitimately takes
`wasm_base64`, so the word cannot be contraband without making that parameter
lie about its format.

`open_world_hint` is now true on the two skills tools, which fetch
skills.internetcomputer.org; only the bundled `skill://` resources retrieve
nothing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs(metadata): OQL does not replace Candid, and this server does the refusing

"declares an OQL surface instead of Candid queries" was wrong three ways.
`has_oql` checks that the interface declares two ordinary Candid methods,
`schema` and `execute`, so nothing is replaced; update calls on the same
canister still go through Candid, since `oql_query_redirect` is never applied to
them; and the refusal is this connector's read-path rule, not a limit of the
canister.

The same wording, and the same "data query" narrowing that hid the fact that
EVERY Candid `method` query is refused on such a canister, appeared in five
places. All five now say what the flag means and who acts on it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs(metadata): fix four false claims, drop one that is always true

Verified every description against its handler. Four said something the code
does not do:

- open_app claimed names are matched against the registry and an unknown name
  is refused. `classify_app_query` matches a name OR a bare host, so a
  wrong-TLD guess repairs to the canonical app, and an unknown DOTTED host is
  resolved as a URL rather than refused; only an unmatched bare word is.
- icp_cycles_balance said "the user's principal". `cycles_balance` goes through
  `management_agent`, the identity derived at this connector's own origin, and
  the user has a different principal at every app, so that named nothing
  specific.
- get_canister_api_doc asserted the content of a reply the canister controls; a
  canister can declare the method and return anything.
- icp_oql_guide read as though the guide were itself a read path.

"Requires an authenticated session" is gone from twelve descriptions. On the
hosted server the whole MCP router sits behind `require_token`, so a tool call
cannot reach a handler without a validated session and the sentence is a
constant. Where a session genuinely varies the fact stays: the runtime error
that names it, the SESSIONS paragraph, the local binary's SIGNING IN paragraph,
and icp_canister_status' access level, which is a real variable rather than a
constant. The derivation-origin half of those sentences also stays, since that
is the caller's choice.

Descriptions: 19,823 -> 7,629 chars across all 26.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs(metadata): fix what a client got wrong when actually driven

Drove the surface with an agent that had only the server's own metadata, over
five requests. Three defects it hit are fixed here; a fourth it reported was an
artefact of the harness (output schemas were not shown to it) and is not real.

- With a URL in hand, open_app, resolve_app and discover_app_canisters all
  applied and nothing said which to prefer; the client called it the weakest
  separation on the surface and picked the narrower tool on a hunch. Each of the
  two narrow tools now says what it alone gives you.
- The session's access level is reported by get_app_principal, but no
  description said so, so a client picking tools by description could not find
  it and concluded the level was unreachable.

It also refused the token-transfer request correctly, and correctly predicted
that a benign rename on an NNS canister would be refused by the canister-scoped
guard — both read straight off the instructions, which is the accuracy fix in
the previous commit doing its job.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs(metadata): point the Candid args at the syntax reference they need

Every `candid://`, `oql://` and `skill://` mention in model-readable text sat in
the server instructions, which the MCP schema calls a hint the client MAY use.
A client that drops that field got one inline example of textual Candid and no
route to the syntax reference this server already serves, on the two tools whose
arguments are nothing but textual Candid.

The `oql` argument already pointed at icp_oql_guide, so that dialect survived
without the instructions; the Candid syntax did not. Both `args` docs now name
`candid://textual-syntax`, which is read at the moment the value is written.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs(metadata): strip how-we-found-it from the discovery surface

Scanned the built tools/list JSON, not the source, for leak vocabulary and
fixed every hit in model-readable text. What a caller reads in a reply stays;
how this server obtained it goes.

- discover_app_canisters described its own scraping: "the frontend canister seen
  at the gateway, or an id mined from the site's runtime config or scripts". A
  caller needs the trust level, not the technique.
- `sources` keeps its literal values, since a caller reads them and must know
  how far to trust each, but loses the gloss explaining what each artifact is.
- `label` no longer enumerates where a label came from.
- "canonicalization" is gone from four field docs; the decision it existed for,
  comparing `requested` against `derived_for_origin`, stays.
- "Null when it was not probed" becomes "Null when unknown": what matters is
  that null is not false.
- "the output caps dropped" and two always-constant clauses ("Always false
  here", "then always true") are reworded to state the rule rather than the
  constant or the internal bound.
- The api-doc method names and the OQL `schema`/`execute` pair are dropped from
  output docs; nothing calls them through this surface.

Wire text: 34,103 -> 31,627 chars.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* feat(tools): serve the textual Candid guide as a tool, not only a resource

Driving the surface with a client that had no server instructions turned up a
hole this change had itself created: both `args` docs pointed at
`candid://textual-syntax`, and no tool on the surface fetches a `candid://` URI.
Resources are read through a client capability, and a client that drops the
instructions field is unlikely to surface resources to a model either, so the
one thing a caller cannot derive — the value syntax every canister call is
written in — was delegated to a document it had no way to reach.

The server had already solved this for OQL: `icp_oql_guide` is a TOOL, which is
why the OQL dialect survived the same test. `candid_syntax_guide` mirrors it,
returning the same 4.6 KB cheat sheet the `candid://textual-syntax` resource
serves. The 25 KB full type reference stays a resource and is mentioned as
supplementary rather than inlined.

Served surface is 12 tools; the count contracts in three tests move with it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* feat(tools)!: drop resolve_app and discover_app_canisters for open_app

open_app already does both halves in one call, and its output is a strict
superset: OpenAppOutput carries everything ResolveAppOutput did, plus everything
DiscoverOutput did apart from the echoed `domain`. Three tools that all took a
name-or-domain and all returned pieces of the same answer were the weakest
separation on the surface — a client driven against it said so unprompted, and
picked between them on a hunch.

One behaviour goes with them. discover_app_canisters answered "is this domain on
the Internet Computer?" with an empty list, where open_app refuses a URL whose
origin would have to be assumed and shows no Internet-Computer evidence. The
question is still answerable, but as a refusal carrying the reason rather than
an empty result.

Everything that pointed at the two is repointed at open_app: five argument
schemas, the anonymous-read remediation, the OQL data-access note, the
known-app and unknown-name notes, and the discovery-failure retry. Their
argument and output types are deleted rather than left orphaned, and open_app's
trigger clause is widened now that it is the only entry point.

Served surface: 12 tools -> 10. Wire text 31,627 -> 28,245 chars.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs(metadata): apply PR review; drop icp_find_app_by_name

Descriptions taken from review, mostly verbatim:

- canister_update_call loses the discoverability gate. It is a precondition the
  caller can neither check nor act on before calling: `app_url` is documented on
  its own argument, and a refusal names the manifest path and links the guide.
  The three description assertions in #166's gate test go with it; its
  instructions assertions and the app_url schema check stay, so the gate is
  still stated once, server-wide.
- open_app loses the no-constructed-domain warning. That safeguard existed
  because resolve_app and discover_app_canisters accepted URLs only, so a caller
  holding a name had to invent one. Both are gone; open_app takes a name, and
  its first sentence says so. open_app_metadata_forbids_a_constructed_domain is
  removed with the warning, and the `app` schema trimmed to match. The refusal
  itself is untouched.
- get_app_principal, list_app_accounts, get_canister_oql_schema and
  get_canister_api_doc take the reviewer's wording. get_app_principal keeps one
  clause on the query-only session, the only pointer to where the access level
  is reported.
- canister_query takes the suggestion minus one sentence whose two halves were
  each restated by the two that follow it.
- Ordering imperatives ("Use this before ...") are gone from four descriptions;
  "Use this when ..." triggers stay, as the review's own suggestions keep them.

icp_find_app_by_name is deleted. It was a pure offline read of the same
four-app registry open_app already consults, returning a strict subset of what
open_app returns, and it was not served. The find_app_by_name function stays:
open_app builds its unknown-name refusal from it.

Two accuracy fixes:

- The instructions still said canisters "expose OQL instead" — the sixth copy of
  a claim fixed five times elsewhere. They now say a canister declares OQL in
  addition, that this server refuses a Candid `method` query on it, and that
  update calls are unaffected.
- `omitted` said the least authoritative "went first", which reads as a claim
  about list order and inverts it. It now says the list is ordered most
  authoritative first and drops come off the other end.

Declined: four review comments asking for the name-based nature of the OQL and
api-doc detection to be restored. It is an implementation detail no client can
act on, and the actionable consequence is already stated.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs: bring the docs to the surface this branch actually ships

Three tools were removed and one added, so the prose describing them was stale
in twenty places. Removing a tool and leaving its table row is a broken PR, not
a metadata change.

- README: the `resolve_app`, `discover_app_canisters` and (never present)
  `icp_find_app_by_name` rows are gone, `candid_syntax_guide` gains one, and the
  "narrower tools remain" escape hatch in Typical flow goes with the tools it
  pointed at. Fifteen inline `open_app`/`resolve_app` pairs collapse to the tool
  that remains.
- docs/scoping-local-deployment.md: the served-tool list is corrected.
- docs/openai-directory-submission.md: two claims that cited the removed tools
  now cite open_app, which is what enforces them.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs(metadata): two more claims that compression made false

Both found by review on the merged branch, both mine, both the same pattern:
an instance fixed and its siblings missed.

- The `derivation_origin` argument said "Not the app's website URL" on both
  tools that take one. It is not NECESSARILY the website URL: most apps pin no
  custom origin, which is why `app_url_default` exists as a source and why
  resolve_app's own text says the origin is "assumed to equal the app's own
  origin". As written it invited a caller to discard the correct value that
  open_app had just handed it. One word restored.
- README still promised get_app_principal returns "without a call", the claim
  removed from the tool description several commits ago once it was traced to
  `derive_app_delegation`, which makes an update and a query against the
  Internet Identity canister. Swept the README for the other corrections made
  in code; nothing else stale.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
aterga added a commit that referenced this pull request Sep 1, 2026
The write gate (#166), the tool-surface changes that followed, and the
new fronting layer on the public origin had left the privacy-policy
source text and both directory-submission guides describing a
deployment that no longer exists (#174, #122).

Privacy policy source text (both edits flagged in the header as
pending legal review before the served page picks them up):

- Widen "Websites you ask the Service to look up" to "look up, or act
  on": since #166 every state-changing call first fetches the target
  application's published declarations, which discloses the request to
  whoever runs that site (#174).
- Under "The authorization is not restricted to particular
  applications", state the action-reach limit: state-changing actions
  reach only applications whose operators publish the service
  declaration, and are signed with that application's own identity
  (#174).
- Record the hosting-model drift for #122: the public origin is now
  fronted by Internet Computer HTTP-gateway infrastructure that
  terminates TLS and forwards only the MCP/OAuth paths, while sections
  2, 3 and 5 still describe the application host alone; the header now
  requires reconciling those sections with the operators before the
  next republication.

Minimize routine identifiers, per the OpenAI submission guidance to
drop identifiers a reply does not need:

- list_app_accounts no longer returns last-used timestamps; the II
  wire timestamp is dropped at the Candid decode boundary and never
  carried further.
- No routine reply echoes the per-app principal any more: the
  acted_as_principal field is gone from the canister_query,
  canister_update_call and get_canister_oql_schema outputs, and the
  text annotation names the derivation origin only, which is what
  carries the mismatch safeguard. get_app_principal still returns the
  principal when asked.

Regenerate the submission claims from a live scan (2026-09-01):

- 10 tools, 9 read-only, longest name 23 chars (both guides said 11
  tools / 10 read-only, verified against a surface two releases old).
- The status dashboard, /version, and the OpenAI domain-verification
  endpoint are cut off by the gateway front today; the affected
  readiness rows, blockers and checklist steps now say so and name the
  fronting-layer allowlist fix each needs.


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

Co-authored-by: Claude <noreply@anthropic.com>
@aterga aterga mentioned this pull request Sep 1, 2026
10 tasks
sea-snake pushed a commit that referenced this pull request Sep 1, 2026
A minor bump rather than a patch, because both the library API and the
served tool surface changed incompatibly since 0.2.0.

`imcp2` no longer contains the tool surface and the II session engine: they
moved to a new sibling crate, `imcp2-core` (#150, #157), which `imcp2` now
depends on by version and which publishes alongside it. The re-exports in
`src/lib.rs` keep the familiar names resolving (`IiInstance`,
`SessionGauges`, `Agent`, `IC_URL`), but the crate an embedder links has
changed shape, and `IcTools` is gone — split into `IcCanisterTools` and
`IcProtocolTools` (#157).

The surface an MCP client sees changed too: only the app/canister tools are
served (#158), state-changing calls now reach only applications that publish
a service declaration (#166), the ledger transfer/approval methods are
refused (#154, #163), `icp_top_up_canister` is instructions-only (#153), and
routine replies no longer echo the per-app principal (#177).

Under cargo's 0.x rules `0.2` is the compatibility range, so shipping this
as 0.2.1 would hand the break to everyone on `imcp2 = "0.2"` — the
requirement our own README recommends — the next time they ran
`cargo update`. 0.3.0 opens a new range instead; existing users stay on
0.2.x until they choose to move. The README's dependency line follows, as
`"0.2"` would no longer resolve to a current release.

`imcp2-core` moves in lockstep: publish-crate.yml checks the tag against
both manifests, and the workspace pin that binds them
(`imcp2-core = { path = …, version = … }`) has to name the same number or
the path dependency stops resolving. `imcp2-local` moves with them — it
releases on its own `imcp2-local-v*` tags and has never been cut, so there
is nothing to keep it behind, and its `--version` output would otherwise
report 0.2.0 from a 0.3.0 tree.


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

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.

5 participants