Skip to content

Describe the surface once, not in every field - #173

Draft
sea-snake wants to merge 8 commits into
mainfrom
docs/describe-not-direct-metadata
Draft

Describe the surface once, not in every field#173
sea-snake wants to merge 8 commits into
mainfrom
docs/describe-not-direct-metadata

Conversation

@sea-snake

@sea-snake sea-snake commented Aug 31, 2026

Copy link
Copy Markdown
Collaborator

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 IDENTITY and SESSIONS, 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.

Surface Before After
Tool descriptions (26) 19,823 7,629
Server instructions 4,262 1,982
Doc comments across the schema modules 104,769 94,944

canister_update_call, 1,259 chars to 382:

Change a canister's state by calling one of its update methods. Use this when the user asks for something a canister must record; read-only requests go through canister_query. Method names and argument types come from the canister's Candid interface, which get_canister_candid returns. Runs anonymously unless given a derivation origin, which acts as the user's account at that app.

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:

  • args typed-encoding was asserted unconditionally. encode_args falls back to to_bytes() whenever no interface is available, where a bare 42 needs its own : type annotation. The old wording made a model emit args the canister rejects.
  • "does not answer Candid data queries" was wrong twice over: this server refuses the call before the network, 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 that qualifier and open_app had lost it.
  • get_app_principal claimed "without calling any canister" while deriving a delegation against the Internet Identity canister.
  • The update guard was understated as "asset-moving calls". It also refuses neuron management and every update call to a listed financial service, so a refusal of store on a wallet frontend was unpredictable from the metadata.
  • "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.
  • reserved_cycles_limit was the one argument with no unit while all five siblings had one, so its value had to be invented.
  • open_world_hint was false on the two skills tools, which fetch skills.internetcomputer.org. Only the bundled skill:// resources retrieve nothing.

The scan gap that let this accumulate

model_readable_metadata_respects_marketplace_policy scanned IcTools::all_tools() — the 11 served tools. The 13 deferred descriptions ship in the crate and were never scanned, which is how they kept ALWAYS call this FIRST, NEVER guess a domain and WEB 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-search is now listed.

The bare token base64 comes off the blocklist. The banned shape is an instruction to decode, and icp_install_code legitimately accepts wasm_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 by decode 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_oql checks for two ordinary Candid methods (schema, 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 "data query" narrowing alongside it hid that every Candid method query is refused there, not only data queries.
  • 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.
  • 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 (src/lib.rs:305-308), so a tools/call cannot reach a handler without a validated bearer token and bearer_session_resolver always returns Some. 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 SESSIONS paragraph, the local binary's SIGNING IN paragraph, and icp_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 tool is 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

  1. Four runtime error strings still tell the model to WEB SEARCH the app's URL (unverified_app_url_error, app_url_error_with_guidance, discover_app_canisters' empty result, and open_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.
  2. Should canister_update_call's description state the pre-flight refusal? Decided: 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 calls instructions a hint the client MAY use, and some clients drop it, so a client reading only tools/list does not learn the guard exists. financial_policy_is_a_server_instruction_not_a_description continues to pin the current arrangement.
  3. icp_start_canister / icp_stop_canister carry destructiveHint: 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.
  4. Encoding "exactly one of method / oql" and the required-in-practice derivation_origin as oneOf / required in the schemas would let the schema carry constraints prose currently carries. That is a behaviour change — derivation_origin is deliberately optional so omitting it yields a useful error rather than a schema failure — so it belongs in its own PR.
  5. The two skills tools live-fetch with a 15-minute cache and no fallback to the compiled bundle. If they are ever served, pointing them at BUNDLED_SKILLS would remove the network dependency and make openWorldHint: false correct again.

Test plan

  • cargo test --workspace green: 149 + 21 + 52 + 7 + 10 + 1 passing, 0 failed.
  • cargo clippy --workspace --all-targets: no new warnings (the 6 pre-existing map_or / redundant-closure suggestions are in untouched code).

🤖 Generated with Claude Code

sea-snake and others added 2 commits August 31, 2026 17:15
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>

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

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 thread crates/imcp2-core/src/calls.rs Outdated
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 thread crates/imcp2-core/src/discover.rs Outdated
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.
sea-snake and others added 6 commits August 31, 2026 17:39
… 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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants