Describe the surface in model-readable metadata, don't direct the model - #167
Conversation
The server instructions and the 11 served tool descriptions told a model how to work — "START WITH open_app", "ALWAYS call this FIRST", "NEVER guess a domain", "web search the official URL", a numbered seven-step flow across tools, and a routing chain to run before answering. Both directories read these fields: OpenAI scans server instructions and prohibits model-readable fields that steer how the model works, and descriptions that tell Claude how to behave are a rejection trigger. Every surface now states what it is and how it behaves. The instructions go from 10005 to 3521 characters and cover the value encoding, what the two tool families act on, what a derivation origin is and which reads it gates, the two Internet Identity access levels, how canister values are stored, the financial-transactions policy, and where building and deploying happen. Each description says what its tool does, what it returns, and what it rejects. Behavior that a caller needs stays, stated as fact rather than instruction: an anonymous OQL read is rejected, a Candid data query on an OQL canister is rejected, a URL with no Internet Computer evidence is refused. Argument schemas follow the same rule. A unit test pins the property across the instructions and all 11 descriptions, so the phrasings that crept in before cannot come back. The submission docs record the posture against the guidelines each directory publishes. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LvwAVXfx5kLNc4SqKXkavk
Keeps this stacked branch current with its base (#163), so the metadata diff shows only its own changes. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LvwAVXfx5kLNc4SqKXkavk
|
CI is red on The failing check is The IC dashboard API is down — that URL returns HTTP 524 and then times out on three consecutive direct attempts from here. This PR changes tool descriptions, the server instructions, and argument-schema documentation; it changes no request path. The same test failed at the same time on #164, an unrelated diff, and this branch's own previous head ( Nothing to port and nothing to weaken: the test asserts against a live service, and relaxing it to survive an outage would cost the assertion its value. The re-run waits for the dashboard; a check-in is scheduled and will report back here only if the result is anything other than green. For the record, the head also carries a merge of this PR's base branch (#163's Generated by Claude Code |
|
|
||
| #[tool( | ||
| description = "Make an UPDATE call (a state-changing call) on an Internet Computer canister method, with textual Candid in and out. Financial operations and update calls to known financial-service canisters are not supported and return an error. Args are encoded against the method's declared Candid types (so plain literals like 42 coerce correctly — no `: type` annotations needed). Omit `derivation_origin` to call anonymously, or pass it to call AS your account at that app — a short-lived account delegation is derived on demand from this connection's standing Internet Identity credential. `derivation_origin` is the app's EXACT canonical II derivation origin (not necessarily its visible URL; don't infer it from alternativeOrigins). Get it once from open_app / resolve_app (which turn an app name or URL into the derivation origin under the guessed-domain gate) and reuse it here — this tool does NOT accept a raw website URL. By default this uses the app's default account; pass `account` (a name from list_app_accounts) for a specific one. The result echoes `derived_for_origin` + `requested` + `acted_as_principal` so you can catch an origin mismatch. For READ-only calls (Candid query methods or OQL queries) use canister_query instead. If get_canister_candid couldn't fetch the interface, pass the `.did` text as `candid` so args/replies are still typed.", | ||
| description = "Make an update call (a state-changing call) on an Internet Computer canister method, with textual Candid in and out. Financial operations and update calls to known financial-service canisters are not supported and return an error. Args are encoded against the method's declared Candid types, so plain literals like 42 coerce correctly without `: type` annotations. Omitting `derivation_origin` calls anonymously; passing it calls as the user's account at that app, with a short-lived account delegation derived on demand from this connection's standing Internet Identity credential. `derivation_origin` is the app's exact canonical Internet Identity derivation origin — not necessarily its visible URL, and not an alternative-origins entry — which open_app and resolve_app resolve from an app name or URL; this tool takes the origin itself, not a raw website URL. `account` names one of the user's accounts (list_app_accounts returns them); omitted, the app's default account is used. The result echoes `derived_for_origin`, `requested`, and `acted_as_principal`, so an origin mismatch is visible. Read-only calls — Candid query methods and OQL queries — go through canister_query. `candid` supplies the interface as `.did` text when the canister's own metadata can't be read, so args and replies stay typed.", |
There was a problem hiding this comment.
Yet again it says "Financial operations and update calls to known financial-service canisters are not supported and return an error." is here, this should be omitted! It's not up for discussion, @aterga also agrees that tools should not mention financial in any sort of way in their descriptions!.
There was a problem hiding this comment.
It's already gone — your review raced the push by about a minute. The sentence came out of #163 in 2fe9274, and this branch (stacked on #163) picked it up in the merge 990e685, which resolved this exact hunk: the description here keeps its factual text minus that sentence. grep -c "Financial operations and update calls" crates/imcp2-core/src/tools.rs returns 0 on the current head.
Nothing about it is up for discussion from my side either — I raised it on #163 rather than pushing my own answer, precisely because it was your rule against @aterga's request, and @aterga settled it your way (drop it entirely rather than reword it).
It is also now enforced rather than merely done, in two layers: financial_policy_is_a_server_instruction_not_a_description asserts that no served tool description contains "financial", and as of 10c2f3d the directive scan covers every tool's input and output schemas too — a bot review found that a field doc had slipped a "never infer" past the description-only scan.
Generated by Claude Code
There was a problem hiding this comment.
Pull request overview
Reframes MCP metadata as factual capability descriptions rather than model directives.
Changes:
- Rewrites server, tool, and argument descriptions.
- Adds a regression test for directive wording.
- Updates directory-submission documentation.
Reviewed changes
Copilot reviewed 6 out of 6 changed files in this pull request and generated 5 comments.
Show a summary per file
| File | Description |
|---|---|
docs/openai-directory-submission.md |
Documents OpenAI metadata compliance. |
docs/anthropic-directory-submission.md |
Documents Anthropic metadata posture. |
crates/imcp2-core/src/tools.rs |
Rewrites served metadata and adds testing. |
crates/imcp2-core/src/identities.rs |
Revises resolver argument metadata. |
crates/imcp2-core/src/discover.rs |
Revises discovery argument metadata. |
crates/imcp2-core/src/calls.rs |
Revises call argument metadata. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| | No machine-to-machine grants (client credentials etc. unsupported by ChatGPT) | ✅ user-consent authorization-code flow only | | ||
| | Tools explicitly annotated `readOnlyHint` / `destructiveHint` / `openWorldHint` — "incorrect or missing action labels are a common cause of rejection" | ✅ set on all 11 tools. The unit test enforces annotation presence and the `readOnlyHint`/`destructiveHint` values; `openWorldHint` is declared everywhere but not asserted by the test, so re-check it in the portal's Scan Tools step | | ||
| | Tool names "human-readable, specific, and descriptive"; accurate descriptions; minimum-information requests | ✅ reviewed against the same bar for the Anthropic listing | | ||
| | "Tools should behave exactly as their names, descriptions, and inputs indicate"; "side effects should never be hidden or implicit" | ✅ each description states what the tool does, returns, and rejects — `canister_update_call`'s names its refusal of financial operations and known financial-service canisters | |
There was a problem hiding this comment.
Fixed in 10c2f3d — and the sentence needed rewriting anyway, not just repair: it claimed canister_update_call's description names its financial refusal, which stopped being true when that sentence was removed. The row now reads that each description states what the tool does, returns, and rejects, with the financial-transactions policy stated in the server-level instructions rather than in any description.
Generated by Claude Code
| /// name, or from the app's official site. A domain derived from an app's name | ||
| /// is not resolvable here: lookalike domains are unrelated or squatted sites | ||
| /// and are refused. |
There was a problem hiding this comment.
Fixed in 10c2f3d. The field now describes the check that runs: when the derivation origin has to be assumed from the URL, the origin is checked for Internet-Computer evidence (a gateway x-ic-canister-id) and the call is refused without it — so a lookalike that is not an IC app does not resolve, stated as the mechanism rather than as a rule about where the URL came from.
Generated by Claude Code
| /// given. A domain derived from a name rather than known is not resolvable | ||
| /// here: an unknown bare name is refused with instructions for finding the | ||
| /// real URL, and a URL with no Internet-Computer evidence is refused. |
There was a problem hiding this comment.
Correct, and it's the same class of error the PR set out to remove: a claim about the caller's provenance, which the server has no way to observe. Fixed in 10c2f3d.
OpenAppArgs.app now states the two refusals that actually happen — an unknown bare name is refused with instructions for finding the real URL, and a URL whose origin shows no Internet-Computer evidence is refused — and drops the "a domain derived from a name is not resolvable here" clause, which was unenforceable: an explicit URL is resolved as given, whatever the caller derived it from.
ResolveAppArgs.app_url (your other comment) had the same claim and got the same treatment.
Generated by Claude Code
|
|
||
| #[tool( | ||
| description = "Discover the Internet Computer canisters behind a web domain (e.g. \"opencloud.org\"). The domain must be one you actually have (from the user, open_app's known-app resolution, or a web search) — NEVER a domain guessed from an app's name; when you only know a NAME, call open_app with the name first. Returns every canister id found, with provenance, most authoritative first: app-declared metadata — the App Connect page's `ic:canister-id` meta at /ai-connect.html (the app's MAIN backend) and the app's own /.well-known/ic-app.json manifest (ALL its canisters, with roles) — then the `x-ic-canister-id` header (the frontend/asset canister), a `/env.json` runtime config (e.g. backend_canister_id), and labelled/bare canister-id literals mined from the JS bundle. App-declared entries are the app's own claim about itself; env.json/bundle entries are mined candidates: pick by label (prefer production/IC ids) and confirm with get_canister_candid before calling.", | ||
| description = "Discover the Internet Computer canisters behind a web domain (e.g. \"opencloud.org\"). `domain` is a domain the caller already has — from the user, from open_app's known-app resolution, or from the app's official site — rather than one derived from an app's name; open_app takes a name directly. Returns every canister id found, with provenance, most authoritative first: app-declared metadata — the App Connect page's `ic:canister-id` meta at /ai-connect.html (the app's main backend) and the app's own /.well-known/ic-app.json manifest (all its canisters, with roles) — then the `x-ic-canister-id` header (the frontend/asset canister), an `/env.json` runtime config (e.g. `backend_canister_id`), and labelled or bare canister-id literals mined from the JS bundle. App-declared entries are the app's own claim about itself; env.json and bundle entries are mined candidates, distinguished by label (production and IC ids) and confirmable with get_canister_candid.", |
There was a problem hiding this comment.
Verified and fixed in 10c2f3d. bound_findings caps results at 50 ids, and unlabelled bundle literals at 20 before that, folding everything dropped into omitted — so "every canister id found" was a promise the tool doesn't keep on a big app.
The description now says it returns up to 50 ids, notes the tighter cap on unlabelled bundle literals, and says any id dropped by those bounds is counted in omitted rather than left out silently. The same edit removed the provenance clause from that description, per your other finding.
Generated by Claude Code
| for tool in super::IcTools::all_tools() { | ||
| surfaces.push(( | ||
| tool.name.to_string(), | ||
| tool.description.as_deref().unwrap_or_default().to_string(), | ||
| )); |
There was a problem hiding this comment.
This is the finding that mattered most in the round — the test was checking the surface it was cheapest to check, not the surface the rule covers. Fixed in 10c2f3d.
The scan now includes every served tool's serialized input_schema and output_schema alongside its description and the server instructions. A field doc reaches the model exactly like a description does, so there was no principled reason to exclude them.
And you're right that there was a live violation behind the hole: OpenAppOutput.alternative_origins said "never infer the derivation origin from it". It now states the same thing as a fact — an entry there is an origin that may derive from the derivation origin, the inverse relation — with no instruction to the model.
One addition beyond the ask: the test now asserts the scan actually sees field documentation (open_app's output schema must contain a known phrase). Without that, a serialization change that stopped carrying description keys would make the new coverage silently vacuous and the test would still pass — which is the same failure mode as the original hole.
Generated by Claude Code
Resolves two conflicts in tools.rs against this branch's metadata work: - canister_update_call keeps this branch's factual description, minus the financial sentence #163 has now removed from every description. - The two instructions-only tool tests go with the tools they covered; this branch's model_readable_metadata_states_capabilities_not_directives test stays, and now runs over the 11-tool served surface. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LvwAVXfx5kLNc4SqKXkavk
The directives test read tool descriptions and the server instructions but not the schemas — and a directive in an argument or reply field's doc comment reaches the model exactly like one in a description. Review found the hole and a live violation through it: OpenAppOutput's alternative_origins said "never infer". The scan now covers every served tool's input and output schema, that field states the inverse relation as a fact instead, and a guard asserts the scan still sees field documentation so the new coverage cannot go vacuous. Three descriptions claimed things the server has no way to observe or enforce: - open_app and resolve_app said a domain "derived from an app's name" is not resolvable. The server cannot know how a caller arrived at a URL; what it actually does is refuse an unknown bare name, and refuse an assumed origin that shows no Internet-Computer evidence. They now say that. - discover_app_canisters promised every canister id found, while results are bounded at 50 (20 for unlabelled bundle literals) with the dropped count reported in `omitted`. The description now states the bound and the reporting. The OpenAI submission doc's descriptions-match row loses a garbled possessive and the claim that canister_update_call's description names its financial refusal, which is no longer the design. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LvwAVXfx5kLNc4SqKXkavk
One conflict, in SERVER_INSTRUCTIONS: this branch's factual instruction text is kept, and its doc comment takes #163's correction — the policy is stated server-wide and in no tool description, held there by a test that now also covers the schemas. The instruction text itself loses the same hedge sea-snake asked to remove from the docs: it said "a curated list of known financial-service canisters", which invites the reading that some services are outside the list. It now says the financial-service canisters the guard carries. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LvwAVXfx5kLNc4SqKXkavk
…ster-disallow' into claude/icp-marketplace-compliance-vpawel-10-simplify-metadata
Summary
The server instructions and the served tool descriptions told a model how to work:
START WITH open_app,ALWAYS call this FIRST,NEVER guess a domain, "web search the official URL", a numbered seven-step flow across tools, and a routing chain to run before answering anything. Both directories read these fields — OpenAI scans server instructions and prohibits model-readable fields that steer how the model works, and descriptions that tell Claude how to behave are a rejection trigger — so every surface now describes what it is and how it behaves instead.Nothing a caller needs was dropped; it is restated as fact rather than instruction. "An anonymous OQL read is rejected" and "a URL with no Internet Computer evidence is refused" tell a model everything the old imperatives did, without telling it what to do.
Stacked on #163 (base is that branch, so the diff shows this change alone; GitHub retargets to
mainwhen #163 merges). It rewrites the same regions #163 just touched — the update-call description and the instructions' policy paragraph — so branching offmainwould have lost that work.Related issues
None — follow-up in the directory-submission series.
Changes
START WITHentry-point rule, the routing chain, the numbered "Typical flow", the locale-conversion directives, "web search or ask the user", and the caps imperatives. The policy paragraph keeps its wording (and its pinned phrases) from Refuse update calls to known finance-related canisters #163.canister_queryloses its "start with open_app first" preamble;open_apploses itsROUTINGblock;resolve_appanddiscover_app_canisterslose the "NEVER guess / web search" rules and keep the fact that a derived domain is not resolvable.canister_update_callkeeps the refusal disclosure added in Refuse update calls to known finance-related canisters #163.calls.rs,discover.rs,identities.rs):NOT/NEVER/REQUIREDimperatives become plain statements, and "or ask the user for it" becomes what actually provides the value.model_readable_metadata_states_capabilities_not_directiveschecks the instructions and all 11 descriptions for the phrasings that crept in before, so they cannot come back.Out of scope: the 15 deferred
IcProtocolToolsdescriptions are not served and are unchanged.Testing
cargo build --locked --all-targetscargo test --locked --all-targets— 154 core (up from 153) + 52/10/9/20/1, all greencargo clippy --all-targets— unchanged 10-warning baseline (this repo is not rustfmt-clean; no repo-widecargo fmt, matching prior PRs)npm test --prefix monitoring/mcp-status(dashboard unchanged)Checklist
Generated by Claude Code