Restrict update calls to canisters an app declares (service discoverability) - #166
Conversation
…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>
…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>
…y-protocol-restrictions-njf4x3
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>
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
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
There was a problem hiding this comment.
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.
#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
There was a problem hiding this comment.
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, soresolve_declared_originsilently 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_originis 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-originfirst 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
architectureerror 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}"));
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
There was a problem hiding this comment.
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_atmay be the legacy path, butdecideonly constructs a successfulDeclarationfromManifestProbe::Declared, which is exclusivelyic-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).
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
There was a problem hiding this comment.
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_identityalso routes permanent failures here, such as a cross-origin derivation declaration that is not authorized byii-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)
)
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
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>
There was a problem hiding this comment.
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
Unreachablecause 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_bodydiscardsFetchedDocument.served_from, so discovery accepts a manifest reached through an allowed different-origin redirect and labels its canisters asic-architecturedeclarations by the requested app. Preserve the response metadata here and only add manifest findings whenserved_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::Unreachableincludes deterministic failures such as HTTP 401/403, stopped redirects, and oversized documents, but this message always calls them “likely transient” and tellsresolve_app/open_appcallers 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."
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>
There was a problem hiding this comment.
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
Absentand may fall back to a legacy/default derivation origin even though the application origin never answered the Layer 5 probe. Checkresp.url().origin()before interpreting any status so every redirected response remainsUnreachable.
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
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>
There was a problem hiding this comment.
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_identitycompleted 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 theindex.htmlguidance fortext/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. Validateresp.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)
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>
There was a problem hiding this comment.
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 fromurl, compare it withresp.url()immediately, and only then classify the status.
let status = resp.status();
if !status.is_success() {
return if means_not_published(status) {
Ok(None)
#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>
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(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>
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>
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>
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_callis 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.jsonbefore 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 listssvault.techback in itsii-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_urlpicks which manifest is read;derivation_originpicks 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 atapp_urlis 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.techis why this resolves the app rather than comparingapp_urltoderivation_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_calltakes a newapp_urlargument (open_appalready returns it) naming the app whose manifest authorizes the write, falling back toderivation_originwhen the app serves its manifest at that same origin. A successful call echoesdeclared_by/declared_at, so a write's provenance is visible in the reply, not only to the gate.Supporting protocol work in
discoverwas needed because the gate keys on the protocol rather than on this server's earlier proposal:/.well-known/ic-architectureis now read during discovery, and/.well-known/ii-derivation-origin(Layer 5) now takes precedence over the legacy manifest'sderivation_originfield. 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 onesvault.techwould 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:
declared_by/declared_atmakes a write traceable to the claim that authorized it. Closing the rest needs an association the target attests to — see open question 7.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
discoverabilitymodule — the policy and every refusal text, sitting alongsidecompliance(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 atskill://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_callgainsapp_urland returnsdeclared_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./.well-known/ic-architectureauthorizes.ManifestProbe::Absentcarries 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.svault.techlive);resolve_app_identityenforces Internet Identity's ownii-alternative-originsrule 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.answered_by, shared byfetch_well_known,fetch_alternative_originsandic_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, itsii-alternative-originsauthorization list, or a manifest attributed to it in discovery — each a true-looking but wrong statement about who declared what. A redirected answer isUnreachable, neverAbsent: 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.https://oisy.com:8443is 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.discoverreads/.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'snamewhen noroleis given./ai-connect.htmlsource (per review): the App Connect bridge page is not part of the protocol.oisy.com, caught by a live test and pinned); an over-long manifest yields an indeterminate verdict rather than a false "does not declare".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 canisteraaaaa-aa.app_urlis 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.canister_update_callmakes an untrusted outbound fetch.mainthree 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
Nothing published on the standard path yet.Answered, premise was wrong — caffeine.ai-built apps publish it,svault.techamong them. The legacy path still does not authorize; the write path is live rather than inert.The submission docs' demo app.Answered in review: a manifest will be published on OpenCloud's side rather than moving the docs offopencloud.org, which restores both worked examples as written — and the demo app in a directory submission should be one that has actually opted in.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.Derivation-origin coupling.Decided and implemented — the identity binding above.System-canister denylist.Answered by Refuse update calls to known finance-related canisters #163, merged in.version. Currently ignored.svault.techpublishes"1.0.0". Refusing an unknown major fails closed but bricks every write the day2.0.0ships; the spec's own evolution rule is "unknown fields must be ignored". Still open.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-targetscargo test --locked --workspace --all-targets— 266 pass, 0 fail. Live coverage againstsvault.techfor 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.svault.techpublishes the Layer 1 manifest and a cross-origin Layer 5 file and authorizes end to end;oisy.comandopencloud.organswer 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:mainalready 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