Describe the surface once, not in every field - #173
Draft
sea-snake wants to merge 8 commits into
Draft
Conversation
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>
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>
There was a problem hiding this comment.
Pull request overview
Consolidates model-facing metadata to reduce repetition while preserving tool semantics.
Changes:
- Centralizes Candid, identity, session, and financial-policy guidance.
- Shortens descriptions and schema documentation across 26 tools.
- Expands marketplace-policy scanning to deferred tools.
Reviewed changes
Copilot reviewed 6 out of 6 changed files in this pull request and generated 4 comments.
Show a summary per file
| File | Description |
|---|---|
crates/imcp2-local/src/server.rs |
Streamlines authentication metadata. |
crates/imcp2-core/src/tools.rs |
Reworks tool descriptions, instructions, and policy tests. |
crates/imcp2-core/src/management.rs |
Documents the reserved-cycles unit. |
crates/imcp2-core/src/identities.rs |
Condenses identity schema documentation. |
crates/imcp2-core/src/discover.rs |
Condenses discovery schema documentation. |
crates/imcp2-core/src/calls.rs |
Refines call argument and output documentation. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| Canister values are stored in canonical, locale-neutral forms: timestamps are usually nanoseconds since the Unix epoch in UTC (IC time), and physical quantities are SI or app-defined units, which `get_canister_api_doc` documents for canisters that publish a doc.\n\n\ | ||
| FINANCIAL TRANSACTIONS ARE NOT SUPPORTED, to protect the user: do not use canister_update_call to move assets. Recognized asset-moving calls are refused before they reach the network, and the refusal says why — but that guard is a safeguard, not a complete filter, so treat this policy, rather than the absence of a refusal, as the limit. For financial operations (token transfers, spending approvals, payments, trades), recommend the user performs the operation outside this connector, in a trusted interface they control.\n\n\ | ||
| Compiling Motoko or Rust to Wasm happens in the client\'s own environment, and this connector serves no tools for creating, funding, deploying or managing canisters: the user does that with the icp CLI in their own terminal."; | ||
| const SERVER_INSTRUCTIONS: &str = "Tools for the Internet Computer: read what a canister offers, read and write its data, and act as the user's own identity at an app.\n\nCANISTERS. Each canister publishes a Candid interface declaring its methods and their types. Method arguments and replies are textual Candid, the `(...)` syntax, e.g. `(record { owner = principal \"aaaaa-aa\"; amount = 5 : nat })`. The `candid://textual-syntax` and `candid://reference` resources document that syntax. Some canisters expose OQL instead, a JSON query language documented by `oql://usage`. IC how-to guides are served as `skill://<name>` resources.\n\nAPPS AND IDENTITY. An app is a website backed by canisters. Internet Identity gives the user a different principal at each app, derived from that app's derivation origin, which is not always the app's visible URL; open_app and resolve_app resolve it, and the tools that act as the user take the resolved value. Within one app the user may hold several accounts.\n\nSESSIONS. Calls that act as the user are signed with the Internet Identity credential this connection was authorized with. Internet Identity offers two access levels at that point: \"Questions only\", where reads work and state-changing calls are rejected by the network, and \"Actions & questions\", which permits both.\n\nFINANCIAL TRANSACTIONS ARE NOT SUPPORTED, to protect the user: do not use canister_update_call to move assets. Value-moving methods, neuron management, and every update call to a known wallet, ledger, exchange or staking canister are refused before they reach the network; that guard is a safeguard, not a complete filter, so treat this policy, rather than the absence of a refusal, as the limit. For financial operations (token transfers, spending approvals, payments, trades), the user works outside this connector, in a trusted interface they control.\n\nBuilding and deploying canisters happens in the user's own environment with the icp CLI; no tool here creates, funds or deploys one."; |
Comment on lines
+43
to
+45
| /// True when the canister exposes an OQL query surface. Its data is then read with | ||
| /// get_canister_oql_schema and canister_query's `oql` argument; a Candid data query is | ||
| /// rejected. |
Comment on lines
+130
to
+131
| /// When `available` is false: true if the interface was read and declared no doc method, | ||
| /// which is the normal case and will not change on a retry. |
Comment on lines
+72
to
+74
| /// Whether this canister exposes an OQL query surface, so its data is read with the OQL | ||
| /// tools rather than a Candid data query. Null when it was not probed, or its interface | ||
| /// could not be read. |
… 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>
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>
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>
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>
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>
…ource 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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
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. Every description then re-derived them, qualified them, and restated what the schemas already said. A client paid ~11.5k tokens of prose before its first call.
This rewrites the metadata as one system rather than shortening it sentence by sentence. The server instructions now teach those three concepts once, under
CANISTERS,APPS AND IDENTITYandSESSIONS, and all 26 descriptions are written assuming a reader who has 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 was deleted, only re-homed: 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 never sent to a model.
canister_update_call, 1,259 chars to 382:For calibration: Anthropic's own worked example of a good tool description is 400 characters.
Accuracy defects fixed
Four review passes (compliance, code-truth, naive-reader, redundancy) ran over the result. Each finding below is a claim the code contradicts:
argstyped-encoding was asserted unconditionally.encode_argsfalls back toto_bytes()whenever no interface is available, where a bare42needs its own: typeannotation. The old wording made a model emit args the canister rejects.methodquery on an OQL canister, not only data queries.open_appasserted the no-IC-evidence refusal unconditionally. It fires only where the derivation origin had to be assumed;resolve_appkept that qualifier andopen_apphad lost it.get_app_principalclaimed "without calling any canister" while deriving a delegation against the Internet Identity canister.storeon a wallet frontend was unpredictable from the metadata.read_onlynamed a consequence that is not on the served surface, and read as proof of write access when the level is merely unknown.reserved_cycles_limitwas the one argument with no unit while all five siblings had one, so its value had to be invented.open_world_hintwasfalseon the two skills tools, which fetchskills.internetcomputer.org. Only the bundledskill://resources retrieve nothing.The scan gap that let this accumulate
model_readable_metadata_respects_marketplace_policyscannedIcTools::all_tools()— the 11 served tools. The 13 deferred descriptions ship in the crate and were never scanned, which is how they keptALWAYS call this FIRST,NEVER guess a domainandWEB SEARCH the app's official URL. The scan now covers every tool defined in the crate.It immediately paid for itself: on its first run it caught a schema field announcing
an instruction to web-search the app's URL, which had walked past the blocklist on the hyphen alone.web-searchis now listed.The bare token
base64comes off the blocklist. The banned shape is an instruction to decode, andicp_install_codelegitimately acceptswasm_base64; keeping the word contraband would force that parameter to lie about its own format. The smuggling patterns (b64decode,rot13,decode and,decode the following) stay, and the gate's own sample is caught bydecode and.The schema-scan liveness sentinel now compares on collapsed whitespace. A doc comment carries the line breaks it was wrapped at, so the raw-text match failed on a rewrap rather than on a broken scan — a check that reported a problem it was not testing for.
Two narrowings in this change's own drafts (
Use this for anything that...) were caught by the gate and fixed rather than worked around.A second accuracy pass over all 26
Every description was then re-verified claim by claim against its handler, after review found that fixing the instance of a defect had left four other copies of it standing.
instead/data query, in five places. "declares an OQL surface instead of Candid queries" was wrong three ways:has_oqlchecks for two ordinary Candid methods (schema,execute), so nothing is replaced; update calls on the same canister still go through Candid, sinceoql_query_redirectis never applied to them; and the refusal is this connector's read-path rule, not a limit of the canister. The "data query" narrowing alongside it hid that every Candidmethodquery is refused there, not only data queries.open_appclaimed names are matched against the registry and an unknown name is refused.classify_app_querymatches 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_balancesaid "the user's principal".cycles_balancegoes throughmanagement_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_docasserted the content of a reply the canister controls.icp_oql_guideread 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(src/lib.rs:305-308), so atools/callcannot reach a handler without a validated bearer token andbearer_session_resolveralways returnsSome. The sentence was a constant, and a constant implies a distinction that does not exist. The real variable is anonymous vs acting as the user, which is the derivation origin, and that half of those sentences stays.Where a session genuinely varies, the fact stays: the runtime error that names it, the
SESSIONSparagraph, the local binary'sSIGNING INparagraph, andicp_canister_status' access level, which is a real variable rather than a constant.Compliance
Checked against the Software Directory Policy, the pre-submission checklist, and the OpenAI Apps SDK and plugin guidelines. The checklist reduces to one line — "Describe what the tool does. Do not tell Claude how to behave" — and both directories still expect a description to say when a tool applies and what is unsafe to pass, so the line drawn here is scope, not imperative mood:
Use this when X; Y goes through the sibling toolis the endorsed form.Unchanged by design: the read/write split, the three test-pinned no-constructed-domain clauses (OpenAI explicitly requires not depending on the model guessing identifiers), the financial policy staying server-wide, and the 11-tool served surface.
Open questions for review
WEB SEARCHthe app's URL (unverified_app_url_error,app_url_error_with_guidance,discover_app_canisters' empty result, andopen_app's unknown-name refusal), with a test pinning that wording. These are repair guidance after a wrong guess, so they are genuinely useful — but the policy's no-external-errands rule is written about the software, not only about descriptions. Left as-is pending a call.ShouldDecided: no. The financial policy stays server-wide and the description is unchanged. Considered and rejected deliberately, so the trade-off is on the record: MCP's schema callscanister_update_call's description state the pre-flight refusal?instructionsa hint the client MAY use, and some clients drop it, so a client reading onlytools/listdoes not learn the guard exists.financial_policy_is_a_server_instruction_not_a_descriptioncontinues to pin the current arrangement.icp_start_canister/icp_stop_canistercarrydestructiveHint: false. Reversible run-state changes, so this matches OpenAI's definition ("irreversible or difficult to reverse"), but Anthropic's phrasing asks for one of the two hints to be set, and stopping a production canister is disruptive. Judgement call, currently pinned by a test.method/oql" and the required-in-practicederivation_originasoneOf/requiredin the schemas would let the schema carry constraints prose currently carries. That is a behaviour change —derivation_originis deliberately optional so omitting it yields a useful error rather than a schema failure — so it belongs in its own PR.BUNDLED_SKILLSwould remove the network dependency and makeopenWorldHint: falsecorrect again.Test plan
cargo test --workspacegreen: 149 + 21 + 52 + 7 + 10 + 1 passing, 0 failed.cargo clippy --workspace --all-targets: no new warnings (the 6 pre-existingmap_or/ redundant-closure suggestions are in untouched code).🤖 Generated with Claude Code