diff --git a/Cargo.lock b/Cargo.lock index 3ad7b1b..0d183f6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -956,7 +956,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -1406,9 +1406,9 @@ dependencies = [ [[package]] name = "ic-agent" -version = "0.48.0" +version = "0.49.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "af4add8891f35fa99c0ff820efd951975c9294240754df6e70a00b6491b613b7" +checksum = "65a32acd8665871aff33d8c2d47ab8bf5da60635652d83a3770bfa34b9ef91ad" dependencies = [ "arc-swap", "async-channel 2.5.0", @@ -1530,9 +1530,9 @@ dependencies = [ [[package]] name = "ic-transport-types" -version = "0.48.0" +version = "0.49.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2baf82406ec203c95891283e2a585b2f56bcc5bb80872445bb824dfbaf95e385" +checksum = "ede9080798410ed4777b6c4e02f011467b256fb9ddd393fc48aef86eb96442e6" dependencies = [ "candid", "hex", @@ -1710,6 +1710,42 @@ dependencies = [ "icu_properties", ] +[[package]] +name = "imcp2" +version = "0.1.0" +dependencies = [ + "anyhow", + "axum", + "base64", + "candid", + "candid_parser", + "crc32fast", + "ed25519-dalek", + "getrandom 0.3.4", + "hex", + "http-body-util", + "ic-agent", + "ic-representation-independent-hash", + "ic-signature-verification", + "p256", + "regex", + "reqwest", + "rmcp", + "schemars 0.8.22", + "serde", + "serde_json", + "sha2 0.11.0", + "tokio", + "tokio-util", + "tower", + "tower-http 0.7.0", + "tracing", + "tracing-subscriber", + "url", + "urlencoding", + "uuid", +] + [[package]] name = "indexmap" version = "2.14.0" @@ -1984,40 +2020,6 @@ version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" -[[package]] -name = "imcp2" -version = "0.1.0" -dependencies = [ - "anyhow", - "axum", - "base64", - "candid", - "candid_parser", - "crc32fast", - "ed25519-dalek", - "getrandom 0.3.4", - "hex", - "ic-agent", - "ic-representation-independent-hash", - "ic-signature-verification", - "p256", - "regex", - "reqwest", - "rmcp", - "schemars 0.8.22", - "serde", - "serde_json", - "sha2 0.11.0", - "tokio", - "tokio-util", - "tower-http 0.7.0", - "tracing", - "tracing-subscriber", - "url", - "urlencoding", - "uuid", -] - [[package]] name = "memchr" version = "2.8.2" @@ -2065,7 +2067,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -2823,7 +2825,7 @@ dependencies = [ "security-framework", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -3201,7 +3203,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -3243,7 +3245,7 @@ dependencies = [ "cfg-if", "libc", "psm", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -3974,7 +3976,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 4d92f61..af28a11 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -15,7 +15,14 @@ rmcp = { version = "1.7", features = [ "transport-streamable-http-server", "auth", ] } -ic-agent = "0.48" +# The Agent is INJECTED by the embedding application (see McpServer::new), so a +# host shares one boundary-node client and one ic-agent across the process. +# Deliberately a direct dependency for now: sourcing ic-agent through ic-bn-lib +# (the shared boundary-node/gateway crate, the eventual goal so the version is +# pinned by the same crate the hosts use) is deferred until ic-bn-lib releases +# against this ic-agent line — its current release pins an older one, which +# would revert the read-only delegation-permissions support this depends on. +ic-agent = "0.49" candid = { version = "0.10", features = ["value"] } candid_parser = "0.3.2" tokio = { version = "1", features = ["macros", "rt-multi-thread", "signal", "time"] } @@ -42,3 +49,9 @@ reqwest = { version = "0.13", default-features = false, features = ["rustls"] } getrandom = "0.3.4" urlencoding = "2.1.3" url = "2" + +[dev-dependencies] +# Router-level contract tests (tests/routers.rs) drive the composed app with +# tower::ServiceExt::oneshot and read bodies via http-body-util. +tower = { version = "0.5", features = ["util"] } +http-body-util = "0.1" diff --git a/Dockerfile b/Dockerfile index 54f8d19..21f5309 100644 --- a/Dockerfile +++ b/Dockerfile @@ -11,7 +11,7 @@ ENV GIT_SHA=${GIT_SHA} ENV BUILD_TIME=${BUILD_TIME} COPY Cargo.toml Cargo.lock ./ COPY src ./src -# static/ is needed at build time too: main.rs/auth.rs include_str! the HTML pages. +# static/ is needed at build time too: tools.rs include_str!s the candid/OQL references. COPY static ./static RUN cargo build --release diff --git a/README.md b/README.md index 6673742..8fd257d 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,85 @@ # IMCP2 -Minimal MCP server that bridges an LLM to the Internet Computer. +Minimal MCP server that bridges an LLM to the Internet Computer — packaged as +an embeddable Rust **library crate** (`imcp2`) plus a thin deployment binary. The LLM only ever speaks **textual Candid**; this server does all the encoding/decoding and signing against the IC via [`ic-agent`](https://github.com/dfinity/agent-rs). The MCP layer is the [official Rust SDK](https://github.com/modelcontextprotocol/rust-sdk) (`rmcp`). +## Use as a library + +One `McpServer` serves one Internet Identity instance as two +[`axum`](https://github.com/tokio-rs/axum) routers: + +- **`McpServer::mcp_router()`** — the MCP streamable-HTTP endpoint (the router + fallback, bearer-token gated) plus the OAuth authorization server under + `/oauth`, nested at the mount path of your choice. +- **`McpServer::well_known_router()`** — the OAuth discovery documents, merged + at the application root (well-known URIs are origin-scoped), **parametric on + the mount path**: the AS issuer is `{public_url}{mcp_path}` (an RFC 8414 path + issuer) and the documents live at the path-inserted well-known locations. + `root_well_known_router()` adds the plain-root fallback documents for the + origin's default instance, and `auth_callbacks_router(&[…])` the + origin-global II auth-callback allow-list covering every instance. + +The IC `Agent` is **inherited from the caller**, not built by the library: a +host — an API boundary node or a gateway — passes in its own route-configured +agent, so the whole process links one `ic-agent` and shares one boundary-node +client. (`ic-agent` stays a direct dependency until +[`ic-bn-lib`](https://github.com/dfinity/ic-bn-lib) — the shared BN/gateway +crate it should eventually be sourced through — releases against ic-agent 0.48; +see `Cargo.toml`.) + +```rust +use imcp2::{auth_callbacks_router, Agent, IiInstance, McpConfig, McpServer, SharedClients, IC_URL}; + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + // Standalone: a default mainnet agent. Embedded: hand in the host's own. + let agent = Agent::builder().with_url(IC_URL).build()?; + let server = McpServer::new(McpConfig { + agent, + instance: IiInstance::beta().map_err(anyhow::Error::msg)?, + public_url: "https://mcp.example.com".into(), + mcp_path: "/mcp".into(), + clients: SharedClients::load(), + }); + server.spawn_session_reaper(); + let app = axum::Router::new() + // nest_service (not nest): it also forwards the trailing-slash form. + .nest_service(server.mcp_path(), server.mcp_router()) + .merge(server.well_known_router()) + // Exactly one instance per origin also answers the root probes and + // serves the origin-global II auth-callback allow-list. + .merge(server.root_well_known_router()) + .merge(auth_callbacks_router(&[&server])); + let listener = tokio::net::TcpListener::bind("0.0.0.0:8000").await?; + axum::serve(listener, app).await?; + Ok(()) +} +``` + +Mounted at `/mcp`, that serves: + +``` +POST /mcp the MCP endpoint (401 + WWW-Authenticate until authorized) +GET /mcp/oauth/authorize ─┐ +GET /mcp/oauth/connect/callback │ the OAuth authorization server +POST /mcp/oauth/connect/redeem │ (login via Internet Identity; +POST /mcp/oauth/token │ issuer https://mcp.example.com/mcp) +POST /mcp/oauth/register ─┘ +GET /.well-known/oauth-authorization-server/mcp (+ root + /mcp/.well-known/… alternates) +GET /.well-known/oauth-protected-resource/mcp (+ root fallback) +GET /.well-known/ii-auth-callbacks (origin-global, all instances) +``` + +Several instances share one origin by giving each its own `McpServer` (one +shared `SharedClients`, one `auth_callbacks_router` over all of them) — exactly +what the bundled binary does with beta and production Internet Identity (see +below). + ## Tools Every tool declares an MCP `outputSchema` and, on success, attaches @@ -287,7 +360,7 @@ flow. ```bash cargo run -# serves http://0.0.0.0:8000 (MCP streamable-HTTP at /mcp, info page at /) +# serves http://0.0.0.0:8000 (MCP at /mcp and /mcp-prod, OAuth under each mount, info page at /) # honours $PORT (default 8000) and $PUBLIC_URL (default http://localhost:8000) ``` @@ -312,22 +385,24 @@ cloudflared tunnel --url http://localhost:8000 # prints https://.tryclou PUBLIC_URL=https://.trycloudflare.com cargo run ``` -## Try it (raw MCP over curl) +## Probe it (curl) + +The MCP endpoint is bearer-gated (see Auth), so tool calls need an OAuth-capable +MCP client — but the discovery surface and the auth handshake are probeable: ```bash -# 1. initialize, grab the session id -SID=$(curl -s -D - -o /dev/null \ - -H 'Accept: application/json, text/event-stream' -H 'Content-Type: application/json' \ +# OAuth discovery documents (path-inserted; root fallbacks also served) +curl -s http://127.0.0.1:8000/.well-known/oauth-authorization-server/mcp | jq +curl -s http://127.0.0.1:8000/.well-known/oauth-protected-resource/mcp | jq +curl -s http://127.0.0.1:8000/.well-known/ii-auth-callbacks | jq + +# Unauthenticated MCP calls answer 401 with the RFC 9728 challenge clients +# use to find the authorization server: +curl -si -X POST \ + -H 'Content-Type: application/json' -H 'Accept: application/json, text/event-stream' \ -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-03-26","capabilities":{},"clientInfo":{"name":"curl","version":"0"}}}' \ - http://127.0.0.1:8000/mcp | grep -i '^mcp-session-id' | tr -d '\r' | awk '{print $2}') - -H=(-H "Accept: application/json, text/event-stream" -H "Content-Type: application/json" -H "Mcp-Session-Id: $SID") -curl -s "${H[@]}" -d '{"jsonrpc":"2.0","method":"notifications/initialized"}' http://127.0.0.1:8000/mcp >/dev/null - -# 2. query a real mainnet canister (ICP ledger) -curl -s "${H[@]}" -d '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"canister_query","arguments":{"canister_id":"ryjl3-tyaaa-aaaaa-aaaba-cai","method":"icrc1_name","args":"()"}}}' \ - http://127.0.0.1:8000/mcp | grep '^data: {' | sed 's/^data: //' | jq -r '.result.content[0].text' -# => ("Internet Computer") + http://127.0.0.1:8000/mcp | grep -i www-authenticate +# -> WWW-Authenticate: Bearer resource_metadata=".../.well-known/oauth-protected-resource/mcp" ``` ## Auth (OAuth 2.1, login via Internet Identity) @@ -338,13 +413,13 @@ in and navigates the browser back to a **pinned callback page** on our origin, carrying a canister-signed delegation in the URL fragment. We bridge that to a single **authorization-code + PKCE** flow, so any OAuth 2.1 client works: -- `/oauth/authorize` validates the client + redirect and PKCE, sets a +- `/mcp/oauth/authorize` validates the client + redirect and PKCE, sets a **browser-binding cookie** (see below), and redirects to II's handshake, carrying this connect's registration public key `pub(X)` in the link fragment (`registration_key`). II certifies a short-lived, two-hop delegation chain and navigates the browser back to our pinned callback page (`GET - /oauth/connect/callback`) with the chain in the fragment. That page POSTs the - chain (with the cookie) to `/oauth/connect/redeem`, which redeems it (one + /mcp/oauth/connect/callback`) with the chain in the fragment. That page POSTs the + chain (with the cookie) to `/mcp/oauth/connect/redeem`, which redeems it (one `mcp_register_v2` call), mints a PKCE-bound code, and returns the client `redirect_uri?code=…&state=…` for the page to navigate to. @@ -354,25 +429,31 @@ flow relies on. Endpoints: -- `GET /.well-known/oauth-authorization-server` — AS metadata (advertises +The default (beta) instance is mounted at `/mcp`, so its AS issuer is +`/mcp` and everything OAuth lives under it: + +- `GET /.well-known/oauth-authorization-server/mcp` — AS metadata (RFC 8414 + path-inserted; also served at the OIDC-style `/mcp/.well-known/…` alternate + and, for this default instance, at the plain root; advertises `grant_types_supported: ["authorization_code"]`) -- `GET /.well-known/oauth-protected-resource` — points clients at the AS -- `POST /oauth/register` — dynamic client registration (RFC 7591); `redirect_uris` +- `GET /.well-known/oauth-protected-resource/mcp` — points clients at the AS + (RFC 9728 §3.1 path-inserted; root fallback also served) +- `POST /mcp/oauth/register` — dynamic client registration (RFC 7591); `redirect_uris` are stored and persisted to `OAUTH_CLIENTS_FILE`; requested `grant_types` are honoured (intersected with `authorization_code`). A **hosted** `redirect_uri` is rejected unless its host is on the allow-list (see the Companion-control note below); loopback redirects are always accepted. -- `GET /oauth/authorize` — validates the client + redirect, requires PKCE, sets +- `GET /mcp/oauth/authorize` — validates the client + redirect, requires PKCE, sets the binding cookie, then redirects to II's handshake (with `registration_key`) -- `GET /oauth/connect/callback` — the **pinned callback page**: II navigates here +- `GET /mcp/oauth/connect/callback` — the **pinned callback page**: II navigates here with the delegation in the URL fragment; the page reads it client-side and POSTs it to the redeem endpoint (it is the sole fragment reader and reflects nothing into the DOM) -- `POST /oauth/connect/redeem` — redeems the fragment delegation (requires the +- `POST /mcp/oauth/connect/redeem` — redeems the fragment delegation (requires the binding cookie), binds the session key via `mcp_register_v2`, mints a PKCE-bound code, and returns the client `redirect_uri?code=…&state=…` -- `POST /oauth/token` — exchanges an authorization `code` (PKCE) for the access token +- `POST /mcp/oauth/token` — exchanges an authorization `code` (PKCE) for the access token Registration is **proven synchronously**: redemption is a signed `mcp_register_v2` that must return `Ok`. Unauthenticated `/mcp` requests get `401` with a @@ -402,10 +483,10 @@ gate) stay JSON — no browser ever lands on them. The `state` in the II connect link is echoed back to the client in the final redirect, so it **cannot by itself prove** that the browser redeeming at -`/oauth/connect/redeem` is the one that started at `/oauth/authorize` — nor that it +`…/oauth/connect/redeem` is the one that started at `…/oauth/authorize` — nor that it is the one that actually **consented** at II. Without a binding there's a session-fixation takeover: an attacker registers a client (open DCR) with their own -`redirect_uri` + PKCE challenge, calls `/oauth/authorize`, reads the II connect link +`redirect_uri` + PKCE challenge, calls `…/oauth/authorize`, reads the II connect link from the 302, and phishes it to a victim who already trusts this origin in II Settings (II's consent screen names only the *origin*, never the OAuth client, so nothing warns the victim). The victim consents; the attacker completes the flow and @@ -413,12 +494,12 @@ redeems the code with their own PKCE verifier — a token acting as the victim. initiator-only cookie does *not* close this: the attacker is the initiator, so it holds the cookie.) -**The fix.** `/oauth/connect/redeem` mints a code only when the requesting browser +**The fix.** `…/oauth/connect/redeem` mints a code only when the requesting browser presents **both** proofs, which can co-reside in one browser only in the legitimate same-browser flow: 1. **initiator** — an unguessable `HttpOnly; Secure; SameSite=Lax` `sid` cookie - (scoped to the instance's `…/oauth` path) set at `/oauth/authorize`; + (scoped to the instance's `…/oauth` path) set at `…/oauth/authorize`; 2. **consenter** — the canister-signed delegation chain, delivered by II *only* to the consenting browser as a URL fragment and *required* to redeem, so only the browser that drove the II consent holds it; @@ -452,10 +533,10 @@ the callback page. > MCP connector vendors' callback paths and widened per deployment with > `OAUTH_ALLOWED_REDIRECT_PREFIXES` (additive; each entry a full `https://host/path` > URL prefix, a bare domain is refused); loopback/native clients are exempt (the code -> resolves on the consenter's own machine). Enforced at both `/oauth/register` and `/oauth/authorize`. A client +> resolves on the consenter's own machine). Enforced at both `…/oauth/register` and `…/oauth/authorize`. A client > turned away is pointed at a contact address to request approval: -> `/oauth/register` says so in its JSON `error_description`, and a browser that -> reaches `/oauth/authorize` gets an on-brand "not allowed" page (not a raw error) +> `…/oauth/register` says so in its JSON `error_description`, and a browser that +> reaches `…/oauth/authorize` gets an on-brand "not allowed" page (not a raw error) > naming the same contact. ### The registration-delegation connect handshake @@ -528,7 +609,7 @@ credentials, `no-store`, 8 KB cap, `application/json` required) and requires the callback URL to be **exactly** (string-equal) one of the declared entries — **fail-closed**, so serving this document is mandatory once #4091 ships. This server serves it for both instances (one origin-global document listing each -instance's `{prefix}/oauth/connect/callback`), built from the same helper that +instance's `{mcp_path}/oauth/connect/callback`), built from the same helper that builds the II links' callback URLs so the two can never drift. The wire shapes match the **merged II contract** (verified against the @@ -554,9 +635,9 @@ Server side: - **`X`, a per-connect registration keypair** bound to the connect `sid`; `priv(X)` never leaves the backend, and `pub(X)` rides the II link (`registration_key`, base64url DER). -- **A pinned callback page** at `GET /oauth/connect/callback` — the *sole* reader +- **A pinned callback page** at `GET …/oauth/connect/callback` — the *sole* reader of the returned fragment. It reads `location.hash` client-side, POSTs it (with - the connect cookie) to `POST /oauth/connect/redeem`, and reflects nothing into + the connect cookie) to `POST …/oauth/connect/redeem`, and reflects nothing into the DOM; it ships a strict CSP (`default-src 'none'`, a per-response script nonce, `connect-src 'self'`). - **Redemption** builds a `DelegatedIdentity` from `priv(X)` + the delegation and @@ -628,9 +709,10 @@ both point at the same II. The same server exposes a second, fully isolated instance connected to **production** Internet Identity: MCP endpoint `/mcp-prod`, with its own -path-scoped authorization server under `/prod/oauth/*` (issuer -`/prod`, an RFC 8414 path issuer; AS metadata at -`/.well-known/oauth-authorization-server/prod`, resource metadata at +authorization server under `/mcp-prod/oauth/*` (issuer +`/mcp-prod`, an RFC 8414 path issuer; AS metadata at +`/.well-known/oauth-authorization-server/mcp-prod` plus the OIDC-style +`/mcp-prod/.well-known/…` alternate, resource metadata at `/.well-known/oauth-protected-resource/mcp-prod`). Configure with `II_URL_PROD` (default `https://id.ai`) and `II_CANISTER_ID_PROD` (default `rdmx6-jaaaa-aaaaa-aaadq-cai`). @@ -742,7 +824,7 @@ delegation. Omitting `account` uses the default account. - [x] OAuth 2.1 auth (authorization-code + PKCE): II's `/mcp` **registration-delegation** handshake binds the connection's session key (a fragment-delivered, canister-signed delegation redeemed via `mcp_register_v2`), with **Consent-Bound Completion** binding - `/oauth/connect/redeem` to both the initiator (`sid` cookie) and the consenter (the + `…/oauth/connect/redeem` to both the initiator (`sid` cookie) and the consenter (the fragment delegation); expiring tokens. The same-browser phishing variant is closed by a hosted-redirect allow-list (see Auth). (The RFC 8628 device grant was dropped.) - [x] On-demand **domain identities**: the registered session key mints per-app diff --git a/monitoring/mcp-status/README.md b/monitoring/mcp-status/README.md index fc3a042..6d1681e 100644 --- a/monitoring/mcp-status/README.md +++ b/monitoring/mcp-status/README.md @@ -10,7 +10,8 @@ It answers three questions and adds a few suggestions: correct status codes?** — probes the landing page, the build/version endpoint (`/version`), the two OAuth discovery documents, the `/mcp` endpoint's unauthenticated `401` challenge, dynamic client registration, and the - `/oauth/authorize` + `/oauth/token` endpoints, plus TLS certificate freshness. + `/mcp/oauth/authorize` + `/mcp/oauth/token` endpoints, plus TLS certificate + freshness. 2. **Which Internet Identity instance is it linked to?** — resolves the II origin (derived from the `mcp..id.ai` ↔ `.id.ai` convention, or overridden explicitly). Question 3 then checks that resolved origin is diff --git a/monitoring/mcp-status/checks.js b/monitoring/mcp-status/checks.js index 191f883..ba8af88 100644 --- a/monitoring/mcp-status/checks.js +++ b/monitoring/mcp-status/checks.js @@ -320,14 +320,14 @@ export const checkMcpEndpoints = async (mcpOrigin, timeoutMs) => { typeof issuer === "string" && Array.isArray(asList) && asList.includes(issuer) && - issuer === mcpOrigin; + issuer === `${mcpOrigin}/mcp`; checks.push({ id: "metadata-consistency", label: "Discovery documents are self-consistent", description: - "Cross-checks the two discovery documents agree: the authorization server's issuer must match this origin and be listed as an authorization_server.", + "Cross-checks the two discovery documents agree: the authorization server's issuer must be this origin's /mcp path issuer and be listed as an authorization_server.", target: "oauth-protected-resource ↔ oauth-authorization-server", - expected: "issuer === origin and listed as authorization_server", + expected: "issuer === origin/mcp and listed as authorization_server", status: consistent ? "pass" : "warn", httpStatus: null, latencyMs: null, @@ -361,7 +361,10 @@ export const checkMcpEndpoints = async (mcpOrigin, timeoutMs) => { }), }); const wwwAuth = r.headers.get("www-authenticate") ?? ""; - const expectedMetadata = `${mcpOrigin}/.well-known/oauth-protected-resource`; + // The challenge points at the resource's path-aware metadata document + // (RFC 9728 §3.1): the resource is `/mcp`, so its metadata lives at + // `/.well-known/oauth-protected-resource/mcp`. + const expectedMetadata = `${mcpOrigin}/.well-known/oauth-protected-resource/mcp`; const challengeOk = r.status === 401 && /bearer/i.test(wwwAuth) && @@ -387,7 +390,7 @@ export const checkMcpEndpoints = async (mcpOrigin, timeoutMs) => { // allow-list exempts it), so this is the dependency-free way to confirm a // client can self-register without manual approval, which is what DCR is for. { - const url = `${mcpOrigin}/oauth/register`; + const url = `${mcpOrigin}/mcp/oauth/register`; const r = await probe(url, { timeoutMs, method: "POST", @@ -431,7 +434,7 @@ export const checkMcpEndpoints = async (mcpOrigin, timeoutMs) => { // allow-listed by a deployment, so this can't false-alert if the allow-list // is widened via OAUTH_ALLOWED_REDIRECT_PREFIXES. { - const url = `${mcpOrigin}/oauth/register`; + const url = `${mcpOrigin}/mcp/oauth/register`; const r = await probe(url, { timeoutMs, method: "POST", @@ -468,7 +471,7 @@ export const checkMcpEndpoints = async (mcpOrigin, timeoutMs) => { // (rather than 5xx / connection error). It is interactive, so we only // assert it is alive and validating, not a full successful redirect. { - const url = `${mcpOrigin}/oauth/authorize`; + const url = `${mcpOrigin}/mcp/oauth/authorize`; const r = await probe(url, { timeoutMs }); const alive = r.ok && r.status >= 400 && r.status < 500; checks.push({ @@ -489,7 +492,7 @@ export const checkMcpEndpoints = async (mcpOrigin, timeoutMs) => { // 7. Token endpoint liveness: a bogus grant must be rejected with 400. { - const url = `${mcpOrigin}/oauth/token`; + const url = `${mcpOrigin}/mcp/oauth/token`; const r = await probe(url, { timeoutMs, method: "POST", @@ -569,7 +572,7 @@ export const checkMcpEndpoints = async (mcpOrigin, timeoutMs) => { /** * Resolve which II instance the MCP server is paired with. We don't try to - * confirm the link by following `/oauth/authorize` headlessly: that endpoint + * confirm the link by following `/mcp/oauth/authorize` headlessly: that endpoint * issues a *script-initiated* navigation (an HTML page that calls * `location.replace`), not an HTTP 3xx redirect — because the II `/mcp` URL * carries its params in the fragment and form-action CSP is enforced across @@ -884,7 +887,7 @@ export const buildSuggestions = (sections, facts) => { if (checkById["mcp-challenge"]?.status !== "pass") { suggestions.push( "The unauthenticated /mcp response should be a 401 carrying " + - 'WWW-Authenticate: Bearer resource_metadata="…/.well-known/oauth-protected-resource". ' + + 'WWW-Authenticate: Bearer resource_metadata="…/.well-known/oauth-protected-resource/mcp". ' + "MCP clients rely on this header to discover the authorization server.", ); } @@ -896,11 +899,11 @@ export const buildSuggestions = (sections, facts) => { "external uptime monitors have no clean liveness probe.", ); suggestions.push( - "POST /oauth/register accepts anonymous dynamic client registration. " + + "POST /mcp/oauth/register accepts anonymous dynamic client registration. " + "Ensure it is rate-limited and that stale/unused clients are pruned to " + "avoid unbounded growth, and that registrations are shared across all " + "server replicas (a freshly registered client_id was not immediately " + - "usable at /oauth/authorize during probing).", + "usable at /mcp/oauth/authorize during probing).", ); const certWarn = [facts?.mcp, facts?.ii] diff --git a/monitoring/mcp-status/checks.test.js b/monitoring/mcp-status/checks.test.js index 69be626..157a1a1 100644 --- a/monitoring/mcp-status/checks.test.js +++ b/monitoring/mcp-status/checks.test.js @@ -166,28 +166,28 @@ test("checkMcpEndpoints passes for a well-behaved server", async () => { [`GET ${origin}/.well-known/oauth-protected-resource`]: resp(200, { headers: { "content-type": "application/json" }, body: JSON.stringify({ - authorization_servers: [origin], + authorization_servers: [`${origin}/mcp`], resource: `${origin}/mcp`, }), }), [`GET ${origin}/.well-known/oauth-authorization-server`]: resp(200, { body: JSON.stringify({ - issuer: origin, - authorization_endpoint: `${origin}/oauth/authorize`, - token_endpoint: `${origin}/oauth/token`, - registration_endpoint: `${origin}/oauth/register`, + issuer: `${origin}/mcp`, + authorization_endpoint: `${origin}/mcp/oauth/authorize`, + token_endpoint: `${origin}/mcp/oauth/token`, + registration_endpoint: `${origin}/mcp/oauth/register`, code_challenge_methods_supported: ["S256"], }), }), [`POST ${origin}/mcp`]: resp(401, { headers: { - "www-authenticate": `Bearer resource_metadata="${origin}/.well-known/oauth-protected-resource"`, + "www-authenticate": `Bearer resource_metadata="${origin}/.well-known/oauth-protected-resource/mcp"`, }, body: JSON.stringify({ error: "invalid_token" }), }), - [`POST ${origin}/oauth/register`]: registerRoute, - [`GET ${origin}/oauth/authorize`]: resp(400, { body: "missing client_id" }), - [`POST ${origin}/oauth/token`]: resp(400, { + [`POST ${origin}/mcp/oauth/register`]: registerRoute, + [`GET ${origin}/mcp/oauth/authorize`]: resp(400, { body: "missing client_id" }), + [`POST ${origin}/mcp/oauth/token`]: resp(400, { body: JSON.stringify({ error: "invalid_grant" }), }), }); @@ -230,21 +230,21 @@ test("checkMcpEndpoints flags a missing OAuth challenge", async () => { [`GET ${origin}/`]: resp(200, { headers: { "content-type": "text/html" } }), [`GET ${origin}/version`]: resp(404), [`GET ${origin}/.well-known/oauth-protected-resource`]: resp(200, { - body: JSON.stringify({ authorization_servers: [origin], resource: `${origin}/mcp` }), + body: JSON.stringify({ authorization_servers: [`${origin}/mcp`], resource: `${origin}/mcp` }), }), [`GET ${origin}/.well-known/oauth-authorization-server`]: resp(200, { body: JSON.stringify({ - issuer: origin, - authorization_endpoint: `${origin}/oauth/authorize`, - token_endpoint: `${origin}/oauth/token`, - registration_endpoint: `${origin}/oauth/register`, + issuer: `${origin}/mcp`, + authorization_endpoint: `${origin}/mcp/oauth/authorize`, + token_endpoint: `${origin}/mcp/oauth/token`, + registration_endpoint: `${origin}/mcp/oauth/register`, }), }), // 200 instead of a 401 challenge → wrong contract. [`POST ${origin}/mcp`]: resp(200, { body: "{}" }), - [`POST ${origin}/oauth/register`]: registerRoute, - [`GET ${origin}/oauth/authorize`]: resp(400), - [`POST ${origin}/oauth/token`]: resp(400, { + [`POST ${origin}/mcp/oauth/register`]: registerRoute, + [`GET ${origin}/mcp/oauth/authorize`]: resp(400), + [`POST ${origin}/mcp/oauth/token`]: resp(400, { body: JSON.stringify({ error: "invalid_grant" }), }), }); @@ -262,28 +262,28 @@ test("checkMcpEndpoints flags a missing hosted-redirect allow-list", async () => [`GET ${origin}/`]: resp(200, { headers: { "content-type": "text/html" } }), [`GET ${origin}/version`]: resp(404), [`GET ${origin}/.well-known/oauth-protected-resource`]: resp(200, { - body: JSON.stringify({ authorization_servers: [origin], resource: `${origin}/mcp` }), + body: JSON.stringify({ authorization_servers: [`${origin}/mcp`], resource: `${origin}/mcp` }), }), [`GET ${origin}/.well-known/oauth-authorization-server`]: resp(200, { body: JSON.stringify({ - issuer: origin, - authorization_endpoint: `${origin}/oauth/authorize`, - token_endpoint: `${origin}/oauth/token`, - registration_endpoint: `${origin}/oauth/register`, + issuer: `${origin}/mcp`, + authorization_endpoint: `${origin}/mcp/oauth/authorize`, + token_endpoint: `${origin}/mcp/oauth/token`, + registration_endpoint: `${origin}/mcp/oauth/register`, }), }), [`POST ${origin}/mcp`]: resp(401, { headers: { - "www-authenticate": `Bearer resource_metadata="${origin}/.well-known/oauth-protected-resource"`, + "www-authenticate": `Bearer resource_metadata="${origin}/.well-known/oauth-protected-resource/mcp"`, }, body: JSON.stringify({ error: "invalid_token" }), }), // Guard MISSING: the server accepts ANY redirect (even a hosted one) with 201. - [`POST ${origin}/oauth/register`]: resp(201, { + [`POST ${origin}/mcp/oauth/register`]: resp(201, { body: JSON.stringify({ client_id: "leaked" }), }), - [`GET ${origin}/oauth/authorize`]: resp(400), - [`POST ${origin}/oauth/token`]: resp(400, { + [`GET ${origin}/mcp/oauth/authorize`]: resp(400), + [`POST ${origin}/mcp/oauth/token`]: resp(400, { body: JSON.stringify({ error: "invalid_grant" }), }), }); diff --git a/src/auth.rs b/src/auth.rs index 8ce87b8..c1089bc 100644 --- a/src/auth.rs +++ b/src/auth.rs @@ -128,11 +128,6 @@ const GRANT_TTL_SECS: u64 = 3600; /// `/oauth/connect/redeem`: only the browser that started the flow can complete it. const CONNECT_COOKIE: &str = "mcp_connect"; -/// Public base URL clients use to reach this server. Override with PUBLIC_URL. -pub fn base_url() -> String { - std::env::var("PUBLIC_URL").unwrap_or_else(|_| "http://localhost:8000".to_string()) -} - /// A registered OAuth client (RFC 7591): the redirect URIs it declared. The /// auth-code flow only redirects a code to one of these (exact match), so the /// server is not an open redirector and needs no hardcoded host allowlist. @@ -406,6 +401,16 @@ pub struct AuthStore { /// Shared with the MCP tools: the session's backend key / grant expiration /// live here (keyed by `session_id`) for the tools to sign with. identities: Identities, + /// Public base URL (origin, no trailing slash) clients use to reach this + /// server — injected by the embedding application, never read from the + /// environment here. + public_url: String, + /// The path this instance's mcp router is nested at ("" for the root, else + /// `/mcp`-like: leading slash, no trailing slash). Everything an absolute + /// URL is built from — the AS issuer `{public_url}{mcp_path}`, the OAuth + /// endpoints `{issuer}/oauth/*`, the II callback URL, and the + /// path-inserted discovery documents — derives from this one value. + mcp_path: String, } /// An auth-code connect awaiting the user's II handshake. @@ -460,19 +465,29 @@ struct TokenInfo { #[derive(Clone)] pub struct SharedClients(Arc>>); -/// Load the persisted client registrations once, to be shared by all stores. -pub fn load_shared_clients() -> SharedClients { - SharedClients(Arc::new(RwLock::new(load_clients()))) +impl SharedClients { + /// Load the persisted client registrations once, to be shared by all stores + /// (file from `OAUTH_CLIENTS_FILE`, default `oauth-clients.json`). + pub fn load() -> Self { + Self(Arc::new(RwLock::new(load_clients()))) + } } impl AuthStore { - pub fn new(identities: Identities, clients: SharedClients) -> Self { + pub fn new( + identities: Identities, + clients: SharedClients, + public_url: String, + mcp_path: String, + ) -> Self { Self { clients: clients.0, tokens: Arc::default(), authz: Arc::default(), codes: Arc::default(), identities, + public_url, + mcp_path, } } @@ -481,17 +496,21 @@ impl AuthStore { self.identities.instance() } - /// This instance's protected-resource metadata URL (RFC 9728), advertised in - /// the 401 challenge. The default instance keeps the root document; other - /// instances use the path-inserted form for their resource path. + /// This instance's AS issuer: `{public_url}{mcp_path}` (an RFC 8414 *path + /// issuer* whenever the router is nested below the root). Every OAuth + /// endpoint lives under it, at `{issuer}/oauth/*`. + fn issuer(&self) -> String { + format!("{}{}", self.public_url, self.mcp_path) + } + + /// This instance's protected-resource metadata URL (RFC 9728 §3.1), + /// advertised in the 401 challenge: the path-inserted form for the + /// resource `{public_url}{mcp_path}`. fn resource_metadata_url(&self) -> String { - let inst = self.instance(); - let base = base_url(); - if inst.oauth_prefix.is_empty() { - format!("{base}/.well-known/oauth-protected-resource") - } else { - format!("{base}/.well-known/oauth-protected-resource{}", inst.mcp_path) - } + format!( + "{}/.well-known/oauth-protected-resource{}", + self.public_url, self.mcp_path + ) } /// Whether `redirect_uri` is acceptable for `client_id`: the client must be @@ -522,7 +541,6 @@ pub struct AuthorizeQuery { state: Option, #[serde(default)] code_challenge: Option, - #[allow(dead_code)] #[serde(default)] code_challenge_method: Option, #[allow(dead_code)] @@ -613,11 +631,16 @@ pub async fn authorize( "Your MCP client's request was missing a required security check (PKCE). The client may \ be out of date. Try updating it. If that doesn't help, remove the connector and add it again."); }; - if let Some(m) = &q.code_challenge_method { - if m != "S256" { - return signin_error(&headers, StatusCode::BAD_REQUEST, "invalid_request", - "only code_challenge_method=S256 is supported", SIGNIN_HEADLINE, MALFORMED_DIAGNOSTIC); - } + // `token` only ever verifies S256, so require the method to say so + // EXPLICITLY. Per RFC 7636 an *omitted* method defaults to `plain` — + // accepting the omission and then verifying as S256 would hand a + // spec-strict `plain` client a code it can never exchange. + if q.code_challenge_method.as_deref() != Some("S256") { + return signin_error(&headers, StatusCode::BAD_REQUEST, "invalid_request", + "code_challenge_method=S256 is required", SIGNIN_HEADLINE, + "Your MCP client's request used an unsupported PKCE method (only S256 is supported). \ + The client may be out of date. Try updating it. If that doesn't help, remove the \ + connector and add it again."); } let session_id = format!("sess-{}", Uuid::new_v4()); @@ -644,21 +667,27 @@ pub async fn authorize( // Redirect the browser to this instance's II handshake, setting the binding // cookie. II navigates back to our pinned callback page once it certifies the // delegation; SameSite=Lax lets the cookie ride that top-level cross-site GET - // back to us. Scoped to this instance's OAuth prefix. `Secure` only when served - // over HTTPS (production always is): a `Secure` cookie is dropped by browsers - // over plain HTTP, which would break the initiator check for local - // `http://localhost` development. - let secure = if base_url().starts_with("https://") { "; Secure" } else { "" }; + // back to us. Scoped to this instance's OAuth subtree (`{mcp_path}/oauth`). + // `Secure` only when served over HTTPS (production always is): a `Secure` + // cookie is dropped by browsers over plain HTTP, which would break the + // initiator check for local `http://localhost` development. `McpServer::new` + // normalizes `public_url` to a lowercase-scheme origin, but compare + // case-insensitively so this stays correct for a directly-built store too. + let is_https = store + .public_url + .split_once("://") + .is_some_and(|(scheme, _)| scheme.eq_ignore_ascii_case("https")); + let secure = if is_https { "; Secure" } else { "" }; let set_cookie = format!( "{CONNECT_COOKIE}={cookie}; Path={}/oauth; Max-Age={}; HttpOnly{secure}; SameSite=Lax", - store.instance().oauth_prefix, + store.mcp_path, CONNECT_TTL.as_secs(), ); // Mint this connect's registration key `X` and carry `pub(X)` in the II link, // toward which II builds the registration chain (`P_reg -> Y -> X`, the last // hop browser-signed to `X`). let reg_pubkey = store.identities.registration_pubkey_b64(&session_id).await; - let ii_url = ii_mcp_url(store.instance(), &session_id, ®_pubkey); + let ii_url = ii_mcp_url(&store, &session_id, ®_pubkey); // Redirect the consenting browser to the II connect link with a real HTTP // 302 (`Location`). The link's params ride in the URL fragment // (`#callback=…&state=…®istration_key=…`); modern browsers preserve a @@ -718,11 +747,11 @@ fn build_redirect(redirect_uri: &str, code: &str, client_state: &str) -> String /// delegation in the fragment; that callback page is our sole fragment reader /// ([`connect_callback_page`]). No `priv(X)` is ever put in the link — only its /// public half. -fn ii_mcp_url(inst: &crate::identities::IiInstance, session_id: &str, reg_pubkey_b64: &str) -> String { +fn ii_mcp_url(store: &AuthStore, session_id: &str, reg_pubkey_b64: &str) -> String { format!( "{ii}/mcp#callback={cb}&state={st}&ttl={ttl}®istration_key={rk}", - ii = inst.ii_url, - cb = urlencoding::encode(&connect_callback_url(inst)), + ii = store.instance().ii_url, + cb = urlencoding::encode(&connect_callback_url(store)), st = urlencoding::encode(session_id), ttl = GRANT_TTL_SECS, rk = urlencoding::encode(reg_pubkey_b64), @@ -744,8 +773,8 @@ pub const AUTH_CALLBACKS_WELL_KNOWN: &str = "/.well-known/ii-auth-callbacks"; /// the II link fragment and in the [`auth_callbacks`] allow-list, so the two /// can never drift: II matches them by exact string equality (no /// normalization, no case/slash slack). -fn connect_callback_url(inst: &crate::identities::IiInstance) -> String { - format!("{}{}/oauth/connect/callback", base_url(), inst.oauth_prefix) +fn connect_callback_url(store: &AuthStore) -> String { + format!("{}/oauth/connect/callback", store.issuer()) } /// GET /.well-known/ii-auth-callbacks — declare every instance's connect @@ -754,7 +783,7 @@ fn connect_callback_url(inst: &crate::identities::IiInstance) -> String { /// Served with CORS (II's frontend fetches it cross-origin) and well under /// II's 8 KB cap. pub async fn auth_callbacks(State(stores): State>) -> Response { - let callbacks: Vec = stores.iter().map(|s| connect_callback_url(s.instance())).collect(); + let callbacks: Vec = stores.iter().map(connect_callback_url).collect(); let mut resp = Json(json!({ "callbacks": callbacks })).into_response(); // Fail-closed, exact-match infrastructure must never be served stale: II's // fetch is `cache: no-store` on ITS side, but an intermediary (CDN/proxy) @@ -777,7 +806,7 @@ pub async fn auth_callbacks(State(stores): State>) -> Response { /// redirect the backend returns. It never writes any fragment/query value into /// the DOM (no reflection), and ships a strict CSP. pub async fn connect_callback_page(State(store): State) -> Response { - pinned_callback_page(store.instance().oauth_prefix) + pinned_callback_page(&store.mcp_path) } /// A fresh CSP nonce: 128 bits from the OS CSPRNG, **standard** base64. CSP3's @@ -1588,11 +1617,15 @@ pub async fn register(State(store): State, Json(req): Json`, and every endpoint lives under it. +/// GET `/.well-known/oauth-authorization-server{mcp_path}` — RFC 8414 metadata +/// for this instance's AS. The issuer is `{public_url}{mcp_path}` (a *path +/// issuer* when the instance is nested below the root), and every endpoint +/// lives under it. Also served at the OIDC-style alternate location inside the +/// mount (`{issuer}/.well-known/oauth-authorization-server`) and, for the +/// origin's default instance, at the plain root as a courtesy for clients that +/// probe there without doing RFC 8414 path insertion. pub async fn authorization_server_metadata(State(store): State) -> Response { - let issuer = format!("{}{}", base_url(), store.instance().oauth_prefix); + let issuer = store.issuer(); Json(json!({ "issuer": issuer, "authorization_endpoint": format!("{issuer}/oauth/authorize"), @@ -1606,14 +1639,14 @@ pub async fn authorization_server_metadata(State(store): State) -> Re .into_response() } -/// GET /.well-known/oauth-protected-resource (and the path-inserted variants): -/// this instance's MCP resource and the AS that protects it. +/// GET `/.well-known/oauth-protected-resource{mcp_path}` (RFC 9728 §3.1; also +/// the root variant for the default instance): this instance's MCP resource +/// and the AS that protects it — both `{public_url}{mcp_path}`. pub async fn protected_resource_metadata(State(store): State) -> Response { - let base = base_url(); - let inst = store.instance(); + let issuer = store.issuer(); Json(json!({ - "resource": format!("{base}{}", inst.mcp_path), - "authorization_servers": [format!("{base}{}", inst.oauth_prefix)], + "resource": issuer, + "authorization_servers": [issuer], })) .into_response() } @@ -1983,17 +2016,30 @@ mod tests { // Build an AuthStore over a dummy II instance (these tests never hit the // network — the connect paths are pure-local crypto/state). + // Build an AuthStore over a dummy II instance, as if its mcp router were + // nested at `/mcp` on `https://mcp.test`. fn test_store() -> super::AuthStore { use crate::identities::{Identities, IiInstance}; use candid::Principal; - let ids = Identities::new(IiInstance { - name: "test", - ii_url: "https://ii.test".into(), - ii_canister: Principal::anonymous(), - oauth_prefix: "", - mcp_path: "/mcp", - }); - super::AuthStore::new(ids, super::SharedClients(std::sync::Arc::default())) + let agent = crate::Agent::builder() + .with_url("https://ii.test") + .build() + .expect("test agent"); + let ids = Identities::new( + IiInstance { + name: "test", + ii_url: "https://ii.test".into(), + ii_canister: Principal::anonymous(), + }, + "https://mcp.test".into(), + agent, + ); + super::AuthStore::new( + ids, + super::SharedClients(std::sync::Arc::default()), + "https://mcp.test".into(), + "/mcp".into(), + ) } /// A request header map that accepts HTML — i.e. a browser hitting the @@ -2041,20 +2087,14 @@ mod tests { // addition to the callback/state/ttl fragment, all in the URL fragment. #[test] fn v2_link_carries_registration_key() { - use crate::identities::IiInstance; - use candid::Principal; - let inst = IiInstance { - name: "t", - ii_url: "https://ii.test".into(), - ii_canister: Principal::anonymous(), - oauth_prefix: "", - mcp_path: "/mcp", - }; - let url = super::ii_mcp_url(&inst, "sess-1", "PUBX"); + let store = test_store(); + let url = super::ii_mcp_url(&store, "sess-1", "PUBX"); assert!(url.starts_with("https://ii.test/mcp#"), "everything rides the fragment: {url}"); assert!(url.contains("state=sess-1")); assert!(url.contains("registration_key=PUBX")); - assert!(url.contains("callback=")); + // The callback lives under the instance's mount ({public_url}{mcp_path}). + let encoded = urlencoding::encode("https://mcp.test/mcp/oauth/connect/callback").into_owned(); + assert!(url.contains(&format!("callback={encoded}")), "callback under the mount: {url}"); } // The allow-list invariant (II #4091 matches by EXACT string equality): the @@ -2067,21 +2107,29 @@ mod tests { use axum::extract::State; use crate::identities::{Identities, IiInstance}; use candid::Principal; - let make = |prefix: &'static str, mcp_path: &'static str| { + let make = |mcp_path: &'static str| { + let agent = crate::Agent::builder() + .with_url("https://ii.test") + .build() + .expect("test agent"); super::AuthStore::new( - Identities::new(IiInstance { - name: "t", - ii_url: "https://ii.test".into(), - ii_canister: Principal::anonymous(), - oauth_prefix: prefix, - mcp_path, - }), + Identities::new( + IiInstance { + name: "t", + ii_url: "https://ii.test".into(), + ii_canister: Principal::anonymous(), + }, + "https://mcp.test".into(), + agent, + ), super::SharedClients(std::sync::Arc::default()), + "https://mcp.test".into(), + mcp_path.into(), ) }; - // Both instances, so the allow-list covers each prefix. - let beta = make("", "/mcp"); - let prod = make("/prod", "/mcp-prod"); + // Both instances, so the allow-list covers each mount. + let beta = make("/mcp"); + let prod = make("/mcp-prod"); let r = super::auth_callbacks(State(vec![beta.clone(), prod.clone()])).await; assert_eq!(r.status(), axum::http::StatusCode::OK); @@ -2112,10 +2160,10 @@ mod tests { // Each declared entry must equal the callback embedded in that // instance's II link, byte for byte. for (store, link) in [ - (&beta, super::ii_mcp_url(beta.instance(), "s", "K")), - (&prod, super::ii_mcp_url(prod.instance(), "s", "K")), + (&beta, super::ii_mcp_url(&beta, "s", "K")), + (&prod, super::ii_mcp_url(&prod, "s", "K")), ] { - let expected = super::connect_callback_url(store.instance()); + let expected = super::connect_callback_url(store); assert!(declared.contains(&expected), "{expected} must be declared: {declared:?}"); let encoded = format!("callback={}", urlencoding::encode(&expected)); assert!(link.contains(&encoded), "the II link must embed the declared URL: {link}"); @@ -2123,7 +2171,7 @@ mod tests { // Both entries share the origin II fetches the document from, and no // entry carries a fragment (II rejects both). for d in &declared { - assert!(d.starts_with(&super::base_url()), "same-origin entries only: {d}"); + assert!(d.starts_with("https://mcp.test"), "same-origin entries only: {d}"); assert!(!d.contains('#'), "no fragments in declared callbacks: {d}"); } } @@ -2425,7 +2473,7 @@ mod tests { // `location.hash` and never writes it into the HTML). #[tokio::test] async fn pinned_page_has_strict_csp_matching_nonce_and_no_reflection() { - let resp = super::pinned_callback_page("/prod"); + let resp = super::pinned_callback_page("/mcp-prod"); let csp = resp .headers() .get(axum::http::header::CONTENT_SECURITY_POLICY) @@ -2463,7 +2511,7 @@ mod tests { "the inline style nonce must match the CSP nonce" ); assert!(html.contains("location.hash"), "the page reads the fragment client-side"); - assert!(html.contains("/prod/oauth/connect/redeem"), "posts to the instance's redeem path"); + assert!(html.contains("/mcp-prod/oauth/connect/redeem"), "posts to the instance's redeem path"); assert!(!html.contains("__REDEEM_URL__"), "the redeem-URL placeholder must be substituted"); // Every handshake/redeem failure lands on this page, so it carries the // "contact us to report it" line (hidden until the script adds `.error`), diff --git a/src/identities.rs b/src/identities.rs index c00b003..40ba017 100644 --- a/src/identities.rs +++ b/src/identities.rs @@ -54,9 +54,6 @@ use rmcp::schemars; use serde::{Deserialize, Serialize}; use tokio::sync::RwLock; -/// Public IC API boundary node the II canister calls are made against. -const IC_URL: &str = "https://icp-api.io"; - /// Re-derive once the cached delegation is within this margin of expiry, so a /// call never goes out with an about-to-expire delegation. const REDERIVE_MARGIN_NS: u64 = 30 * 1_000_000_000; @@ -99,12 +96,12 @@ const READ_ONLY_MSG: &str = "This Internet Identity session is read-only, so it — are update calls that a read-only session can't make. Reconnect with Internet Identity and turn \ OFF the read-only option on the consent screen, then try again."; -/// One Internet Identity instance this server can connect users against. The -/// default ("beta") instance serves `/mcp` with its OAuth AS at the root of -/// `PUBLIC_URL`; the "prod" instance serves `/mcp-prod` with a path-scoped AS -/// (issuer `/prod`, RFC 8414 path issuer). Each instance gets its +/// One Internet Identity instance this server can connect users against — +/// purely *which II* (origin + canister), nothing about where the instance is +/// mounted on this server: the mount path is deployment composition, chosen in +/// `McpConfig` when the instance's routers are built. Each instance gets its /// own `Identities` + `AuthStore`, so sessions/tokens never cross instances; -/// II trust in the user's settings is by ORIGIN, which both instances share. +/// II trust in the user's settings is by ORIGIN, which all instances share. #[derive(Clone, Debug)] pub struct IiInstance { /// Short name for logging ("beta", "prod"). @@ -113,10 +110,6 @@ pub struct IiInstance { pub ii_url: String, /// Canister id of that II instance — the target of the `mcp_*` calls. pub ii_canister: Principal, - /// This instance's OAuth path prefix on the server: "" (root) or "/prod". - pub oauth_prefix: &'static str, - /// The MCP resource path this instance gates: "/mcp" or "/mcp-prod". - pub mcp_path: &'static str, } impl IiInstance { @@ -126,8 +119,6 @@ impl IiInstance { name: "beta", ii_url: env_origin("II_URL", II_URL_DEFAULT), ii_canister: env_principal("II_CANISTER_ID", II_CANISTER_ID_DEFAULT)?, - oauth_prefix: "", - mcp_path: "/mcp", }) } @@ -137,8 +128,6 @@ impl IiInstance { name: "prod", ii_url: env_origin("II_URL_PROD", II_URL_PROD_DEFAULT), ii_canister: env_principal("II_CANISTER_ID_PROD", II_CANISTER_ID_PROD_DEFAULT)?, - oauth_prefix: "/prod", - mcp_path: "/mcp-prod", }) } } @@ -181,12 +170,12 @@ const ACTIVE_SESSION_WINDOW_NS: u64 = 15 * 60 * 1_000_000_000; /// [`Identities::session_gauges`] so a scrape locks and iterates the session map /// once and reports a consistent `active <= live` pair. #[derive(Debug, Clone, Copy)] -pub(crate) struct SessionGauges { +pub struct SessionGauges { /// Sessions holding a currently-valid II grant (the `live_sessions` gauge). - pub(crate) live: usize, + pub live: usize, /// The subset also active within [`ACTIVE_SESSION_WINDOW_NS`] (the /// `active_sessions` gauge). Always `<= live`. - pub(crate) active: usize, + pub active: usize, } /// Remap a domain to the `target_origin` II expects for account derivation. @@ -439,17 +428,38 @@ pub struct ResolveAppOutput { pub struct Identities { /// The II instance every session in this store is registered against. instance: IiInstance, + /// This MCP server's own public origin — the `target_origin` the + /// management identity is derived at (`target_origin()` reduces it to a + /// bare origin either way). Injected by the embedding application, never + /// read from the environment here. + public_url: String, + /// The injected base agent (see `McpConfig::agent`). Every identity-bearing + /// agent is a clone of it with the identity swapped, so delegated and II + /// calls inherit the host's boundary-node routing and transport. + agent: Agent, sessions: Arc>>, } impl Identities { - pub fn new(instance: IiInstance) -> Self { + pub fn new(instance: IiInstance, public_url: String, agent: Agent) -> Self { Self { instance, + public_url, + agent, sessions: Arc::default(), } } + /// The injected base agent with `identity` swapped in: a clone shares the + /// transport, route provider, and root key, so calls signed as `identity` + /// go through the embedding application's boundary-node routing — never a + /// second, hard-coded endpoint. + pub(crate) fn agent_as(&self, identity: I) -> Agent { + let mut agent = self.agent.clone(); + agent.set_identity(identity); + agent + } + /// The II instance this store connects against. pub fn instance(&self) -> &IiInstance { &self.instance @@ -620,7 +630,7 @@ impl Identities { /// reported pair is consistent (`active <= live`) — two separate reads could /// straddle a grant expiry and momentarily report `active > live`. A cheap /// read-lock snapshot. - pub(crate) async fn session_gauges(&self) -> SessionGauges { + pub async fn session_gauges(&self) -> SessionGauges { let now = now_ns(); let sessions = self.sessions.read().await; let mut g = SessionGauges { live: 0, active: 0 }; @@ -725,11 +735,7 @@ impl Identities { /// key. This is the caller II recovers the anchor from for the `mcp_*` calls. async fn session_agent(&self, session_id: &str) -> Result { let signer = self.session_signer(session_id).await?; - Agent::builder() - .with_url(IC_URL) - .with_identity(signer) - .build() - .map_err(|e| format!("could not build II agent: {e}")) + Ok(self.agent_as(signer)) } /// A stable per-user identity for the canister-management tools — the user's @@ -738,7 +744,7 @@ impl Identities { /// across reconnects (unlike the ephemeral session key), so it works as the /// user's controller/funder identity. pub async fn management_identity(&self, session_id: &str) -> Result { - let origin = crate::auth::base_url(); + let origin = self.public_url.clone(); self.delegated_identity(session_id, &origin, None).await } @@ -837,11 +843,7 @@ impl Identities { // `reg_user_key` is `der(P_reg)`: the chain root II recovers `caller() == // P_reg` from. let identity = registration_identity(reg_user_key, reg_seed, ®_der, chain)?; - let agent = Agent::builder() - .with_url(IC_URL) - .with_identity(identity) - .build() - .map_err(|e| format!("could not build registration agent: {e}"))?; + let agent = self.agent_as(identity); // mcp_register_v2(session_key) -> variant { Ok : McpRegisterV2Ok; Err : text } // The ONLY argument is `session_key` (= `pub(S)`). Consent (permissions, @@ -1274,17 +1276,16 @@ impl IiSignedDelegation { mod tests { use super::*; - /// The built-in instance defaults must parse (canister ids are compile-time - /// strings) and carry the expected paths/prefixes. + /// Both built-in instance constructors must succeed: the fallible part is + /// parsing the canister id (a compile-time default string, or its env + /// override) into a `Principal`, so this guards those defaults against a + /// typo. It deliberately does NOT assert the two instances differ — both + /// read `II_URL*` / `II_CANISTER_ID*` from the environment, and a + /// legitimate setup (e.g. local testing) may point both at the same II. #[test] fn instance_defaults_are_valid() { - let beta = IiInstance::beta().expect("beta defaults"); - assert_eq!(beta.oauth_prefix, ""); - assert_eq!(beta.mcp_path, "/mcp"); - let prod = IiInstance::prod().expect("prod defaults"); - assert_eq!(prod.oauth_prefix, "/prod"); - assert_eq!(prod.mcp_path, "/mcp-prod"); - assert_ne!(beta.ii_canister, prod.ii_canister); + IiInstance::beta().expect("beta defaults"); + IiInstance::prod().expect("prod defaults"); } #[test] @@ -1348,13 +1349,19 @@ mod tests { // An Identities store over a dummy II instance (tests never hit the network). fn test_ids() -> Identities { - Identities::new(IiInstance { - name: "test", - ii_url: "https://ii.test".into(), - ii_canister: Principal::anonymous(), - oauth_prefix: "", - mcp_path: "/mcp", - }) + let agent = Agent::builder() + .with_url("https://ii.test") + .build() + .expect("test agent"); + Identities::new( + IiInstance { + name: "test", + ii_url: "https://ii.test".into(), + ii_canister: Principal::anonymous(), + }, + "https://mcp.test".into(), + agent, + ) } // Seed a session with a live grant, bypassing the network connect flow. diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..65961a0 --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,560 @@ +//! IMCP2 — Internet Computer MCP server as an embeddable axum library. +//! +//! The server exposes MCP tools over streamable HTTP that talk to the Internet +//! Computer via `ic-agent` (discover the canisters behind an app, read/write +//! canisters as the user's Internet Identity account, OQL reads, canister +//! management, …), gated by an OAuth 2.1 authorization server whose login +//! mechanism is Internet Identity's `/mcp` connect handshake. +//! +//! One [`McpServer`] serves one Internet Identity instance ([`IiInstance`]) and +//! packages everything as two [`axum::Router`]s: +//! +//! * [`McpServer::mcp_router`] — the MCP endpoint plus the OAuth +//! authorization server under `/oauth`, nested at [`McpServer::mcp_path`]. +//! * [`McpServer::well_known_router`] — the OAuth discovery documents. +//! Well-known URIs are origin-scoped (RFC 8615), so this router is merged +//! at the application root; its handlers are parametric on the path the +//! mcp router is nested at (the RFC 8414 / RFC 9728 *path-inserted* +//! locations). [`McpServer::root_well_known_router`] adds the plain-root +//! fallback documents for the origin's default instance, and +//! [`auth_callbacks_router`] the origin-global II auth-callback allow-list +//! covering every instance. +//! +//! The IC [`Agent`] is **inherited from the embedding application**, not built +//! here: a host (an API boundary node, a gateway, or the bundled binary) +//! passes in its own agent, so anonymous canister calls go through the host's +//! boundary-node client and the whole process links a single `ic-agent`. +//! +//! ```rust,no_run +//! use imcp2::{auth_callbacks_router, Agent, IiInstance, McpConfig, McpServer, SharedClients, IC_URL}; +//! +//! #[tokio::main] +//! async fn main() -> anyhow::Result<()> { +//! // Or hand in the host's own agent instead of building a default one. +//! let agent = Agent::builder().with_url(IC_URL).build()?; +//! let server = McpServer::new(McpConfig { +//! agent, +//! instance: IiInstance::beta().map_err(anyhow::Error::msg)?, +//! public_url: "https://mcp.example.com".into(), +//! mcp_path: "/mcp".into(), +//! clients: SharedClients::load(), +//! }); +//! server.spawn_session_reaper(); +//! let app = axum::Router::new() +//! // nest_service (not nest): it also forwards the bare +//! // trailing-slash form (`/mcp/`) into the router. +//! .nest_service(server.mcp_path(), server.mcp_router()) +//! .merge(server.well_known_router()) +//! // Exactly one instance per origin also answers the root probes +//! // and serves the origin-global II auth-callback allow-list. +//! .merge(server.root_well_known_router()) +//! .merge(auth_callbacks_router(&[&server])); +//! let listener = tokio::net::TcpListener::bind("0.0.0.0:8000").await?; +//! axum::serve(listener, app).await?; +//! Ok(()) +//! } +//! ``` +//! +//! Several instances can share one origin (e.g. beta II at `/mcp`, production +//! II at `/mcp-prod`): give each its own `McpServer` — sessions and tokens +//! never cross instances — share ONE [`SharedClients`] between them so dynamic +//! client registrations (II-agnostic) persist to a single snapshot, and pass +//! every instance to [`auth_callbacks_router`] so the one allow-list document +//! declares all callbacks. +//! +//! Remaining knobs are environment variables read where they are used: +//! `II_URL` / `II_CANISTER_ID` and `II_URL_PROD` / `II_CANISTER_ID_PROD` (the +//! Internet Identity instances), `OAUTH_CLIENTS_FILE` (where dynamic client +//! registrations persist) and `SKILLS_URL` (the IC skills registry). + +mod auth; +mod calls; +mod discover; +mod identities; +mod management; +mod skills; +mod tools; + +use axum::{ + middleware, + routing::{get, post}, + Router, +}; +use rmcp::transport::{ + streamable_http_server::{session::local::LocalSessionManager, tower::StreamableHttpService}, + StreamableHttpServerConfig, +}; +use tokio_util::sync::CancellationToken; + +pub use auth::SharedClients; +pub use identities::{IiInstance, SessionGauges}; +/// The IC [`Agent`] type the server is built around, re-exported so callers +/// construct the injected agent from the exact `ic-agent` version this crate +/// links. +pub use ic_agent::{self, Agent}; + +/// A sensible default IC API boundary node (the public mainnet endpoint) for +/// callers that just want `Agent::builder().with_url(IC_URL).build()`. A host +/// with its own boundary-node routing supplies an agent built against that +/// instead. +pub const IC_URL: &str = "https://icp-api.io"; + +/// Everything an [`McpServer`] is built from. +pub struct McpConfig { + /// The IC agent anonymous canister calls are signed with — inherited from + /// the embedding application so the host controls boundary-node routing + /// and the process links one `ic-agent`. For a standalone deployment: + /// `Agent::builder().with_url(imcp2::IC_URL).build()`. + pub agent: Agent, + /// Which Internet Identity instance this server connects users against. + pub instance: IiInstance, + /// Public base URL (origin) clients use to reach the deployment, e.g. + /// `https://mcp.example.com` — baked into the OAuth discovery documents, + /// the II handshake URLs, and the transport's DNS-rebinding allowlist. + pub public_url: String, + /// The path [`McpServer::mcp_router`] is nested at (e.g. `"/mcp"`). The + /// single source of truth every absolute URL derives from: the AS issuer + /// is `{public_url}{mcp_path}` (an RFC 8414 path issuer), the OAuth + /// endpoints live at `{issuer}/oauth/*`, and the discovery documents at + /// the matching path-inserted well-known locations. + pub mcp_path: String, + /// The dynamic-client-registration store. Share ONE across every instance + /// on an origin (registrations are II-agnostic and persist to one file). + pub clients: SharedClients, +} + +/// One MCP server instance: the shared state behind [`Self::mcp_router`] and +/// [`Self::well_known_router`]. Cheap to clone (everything inside is shared). +#[derive(Clone)] +pub struct McpServer { + agent: Agent, + identities: identities::Identities, + skills: skills::SkillsCatalog, + store: auth::AuthStore, + public_url: String, + mcp_path: String, + /// Cancels the streamable-HTTP sessions and the session reaper on + /// [`Self::shutdown`]. + ct: CancellationToken, +} + +impl McpServer { + pub fn new(config: McpConfig) -> Self { + let public_url = normalize_public_url(&config.public_url); + let mcp_path = normalize_mount_path(&config.mcp_path); + let identities = + identities::Identities::new(config.instance, public_url.clone(), config.agent.clone()); + let store = auth::AuthStore::new( + identities.clone(), + config.clients, + public_url.clone(), + mcp_path.clone(), + ); + Self { + agent: config.agent, + identities, + skills: skills::SkillsCatalog::new(), + store, + public_url, + mcp_path, + ct: CancellationToken::new(), + } + } + + /// The path this instance's [`Self::mcp_router`] must be nested at — the + /// normalized [`McpConfig::mcp_path`]: + /// `.nest_service(server.mcp_path(), server.mcp_router())`. + pub fn mcp_path(&self) -> &str { + &self.mcp_path + } + + /// The MCP API and its OAuth authorization server, as one router to nest + /// at [`Self::mcp_path`]: + /// + /// * `/oauth/{authorize,connect/callback,connect/redeem,token,register}` + /// — the OAuth AS (login via Internet Identity's connect handshake), + /// CORS-open; + /// * `/.well-known/oauth-authorization-server` — the OIDC-style + /// alternate location of the AS metadata (some clients derive + /// `/.well-known/…` instead of RFC 8414 path insertion); + /// * everything else — the MCP streamable-HTTP endpoint (the router + /// fallback, so the bare mount path, its trailing-slash form, and + /// sub-paths all reach it), bearer-token gated, with the CORS + /// preflight answered before authentication and `WWW-Authenticate` + /// exposed cross-origin. + /// + /// Nest with `nest_service` (which forwards the bare trailing-slash form + /// too, unlike `nest`). Nesting at the application root is the one axum + /// won't allow; for a root-mounted instance `.merge` this router instead + /// and set `mcp_path: "".into()`. + pub fn mcp_router(&self) -> Router { + let mcp_service = { + let (agent, identities, skills) = + (self.agent.clone(), self.identities.clone(), self.skills.clone()); + StreamableHttpService::new( + move || Ok(tools::IcTools::new(agent.clone(), identities.clone(), skills.clone())), + LocalSessionManager::default().into(), + // Stateless + plain-JSON responses: our tools are pure + // request/response with no server-initiated messages, and this + // is the most compatible mode across MCP clients (ChatGPT's + // connector does not complete the stateful SSE/session + // handshake the rmcp defaults require). + StreamableHttpServerConfig::default() + .with_stateful_mode(false) + .with_json_response(true) + .with_cancellation_token(self.ct.child_token()) + .with_allowed_hosts(self.allowed_hosts()), + ) + }; + + // The MCP resource is gated by a bearer token issued after Internet + // Identity login. Browser-based MCP clients (e.g. the Grok/ChatGPT + // connector UIs) call the resource via `fetch()`, so it needs CORS — + // applied OUTSIDE the bearer gate so the OPTIONS preflight is answered + // (2xx) BEFORE authentication, and the 401's `WWW-Authenticate` (the + // auth-discovery hint) is exposed cross-origin. `Authorization` must be + // listed explicitly: the `*` wildcard for `Access-Control-Allow-Headers` + // does NOT cover it per the Fetch spec. + let mcp_cors = tower_http::cors::CorsLayer::new() + .allow_origin(tower_http::cors::Any) + .allow_methods([ + axum::http::Method::GET, + axum::http::Method::POST, + axum::http::Method::OPTIONS, + ]) + .allow_headers([ + axum::http::header::AUTHORIZATION, + axum::http::header::CONTENT_TYPE, + axum::http::header::ACCEPT, + axum::http::HeaderName::from_static("mcp-protocol-version"), + axum::http::HeaderName::from_static("mcp-session-id"), + axum::http::HeaderName::from_static("last-event-id"), + ]) + .expose_headers([ + axum::http::header::WWW_AUTHENTICATE, + axum::http::HeaderName::from_static("mcp-session-id"), + ]); + let gated_mcp = Router::new() + .fallback_service(mcp_service) + .layer(middleware::from_fn_with_state( + self.store.clone(), + auth::require_token, + )) + .layer(mcp_cors); + + let oauth = Router::new() + .route("/authorize", get(auth::authorize)) + // The pinned callback PAGE (GET): II navigates the consenting + // browser here with the delegation in the URL fragment; the page is + // the sole fragment reader. + .route("/connect/callback", get(auth::connect_callback_page)) + // The pinned page POSTs the fragment delegation here to be redeemed. + .route("/connect/redeem", post(auth::connect_redeem)) + .route("/token", post(auth::token)) + .route("/register", post(auth::register)) + .with_state(self.store.clone()) + .layer(permissive_cors()); + + // The OIDC-style AS-metadata alternate `/.well-known/…` lives + // INSIDE the mount (the issuer is `{public_url}{mcp_path}`), so it is a + // route of this router, not of `well_known_router`. + let oidc_alternate = Router::new() + .route( + "/.well-known/oauth-authorization-server", + get(auth::authorization_server_metadata), + ) + .with_state(self.store.clone()) + .layer(permissive_cors()); + + Router::new() + .nest("/oauth", oauth) + .merge(oidc_alternate) + // Everything else is the MCP endpoint: the bare mount path, its + // trailing-slash form, and any sub-path (the streamable service + // dispatches on method, not path) — same breadth `nest_service` + // used to give the endpoint. + .fallback_service(gated_mcp) + } + + /// This instance's OAuth discovery documents at their **path-inserted** + /// well-known locations, to be merged at the application **root** + /// (well-known URIs are origin-scoped). Parametric on [`Self::mcp_path`]: + /// + /// * `/.well-known/oauth-authorization-server{mcp_path}` — RFC 8414 for + /// the path issuer `{public_url}{mcp_path}`; + /// * `/.well-known/oauth-protected-resource{mcp_path}` — RFC 9728 §3.1 + /// for the resource `{public_url}{mcp_path}` (the URL the 401 + /// challenge's `resource_metadata` points at). + /// + /// For an instance mounted at the root (`mcp_path: ""`) these ARE the root + /// documents — don't also add [`Self::root_well_known_router`]. + pub fn well_known_router(&self) -> Router { + Router::new() + .route( + &format!("/.well-known/oauth-authorization-server{}", self.mcp_path), + get(auth::authorization_server_metadata), + ) + .route( + &format!("/.well-known/oauth-protected-resource{}", self.mcp_path), + get(auth::protected_resource_metadata), + ) + .with_state(self.store.clone()) + .layer(permissive_cors()) + } + + /// The plain **root** discovery documents (`/.well-known/oauth-authorization-server` + /// and `/.well-known/oauth-protected-resource`), answering clients and + /// monitors that probe the root without doing path-inserted discovery. + /// Serve this for exactly ONE instance per origin — the default one. (The + /// documents describe this instance: a strict RFC 8414 client would reject + /// the root AS document on issuer mismatch, but such a client performs + /// path insertion and never fetches it.) + pub fn root_well_known_router(&self) -> Router { + Router::new() + .route( + "/.well-known/oauth-authorization-server", + get(auth::authorization_server_metadata), + ) + .route( + "/.well-known/oauth-protected-resource", + get(auth::protected_resource_metadata), + ) + .with_state(self.store.clone()) + .layer(permissive_cors()) + } + + /// Spawn this instance's session reaper: sweeps once shortly after startup + /// and then every 60s (tokio's interval fires its first tick immediately; + /// the startup sweep is harmless, the map is empty then), evicting + /// expired-grant sessions (emitting a "session closed" log each) and giving + /// the journal a close event to reconcile against "session opened". This + /// caps growth from expired grants (the common case: every authenticated + /// session eventually expires); sessions with no recorded expiry + /// (mid-connect) are deliberately kept and so are NOT bounded by this. + /// Tied to [`Self::shutdown`] so the task stops cleanly on drain. Must be + /// called from within a tokio runtime. + pub fn spawn_session_reaper(&self) { + let ids = self.identities.clone(); + let reap_ct = self.ct.child_token(); + tokio::spawn(async move { + let mut tick = tokio::time::interval(std::time::Duration::from_secs(60)); + // Skip missed ticks rather than the default Burst: if the runtime + // stalls, resume with a single sweep at the next slot instead of + // firing several back-to-back catch-up sweeps. + tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); + loop { + tokio::select! { + _ = reap_ct.cancelled() => break, + _ = tick.tick() => { + ids.reap_expired_sessions().await; + } + } + } + }); + } + + /// This instance's live/active session gauges (see + /// [`identities::Identities::session_gauges`]) — for a deployment's + /// `/version`-style observability probe. + pub async fn session_gauges(&self) -> SessionGauges { + self.identities.session_gauges().await + } + + /// Cancel the MCP transport's live sessions and the session reaper; call + /// AFTER draining in-flight requests on graceful shutdown (cancelling + /// first would cut the very requests the drain is waiting on). + pub fn shutdown(&self) { + self.ct.cancel(); + } + + /// Hosts allowed in the `Host` header by rmcp's DNS-rebinding protection: + /// loopback (local dev) plus the `public_url` host. Without the latter, + /// every request via the public URL is rejected before the bearer token is + /// even checked. + fn allowed_hosts(&self) -> Vec { + allowed_hosts_for(&self.public_url) + } +} + +/// The `Host`-header allow-list derived from `public_url`: loopback (local +/// dev) plus the public host, in both its bare and `host:port` forms — clients +/// send whichever their URL implies. Parsed as a URL (assuming an `https` +/// scheme when none is given) rather than string-split, so a bare host, an +/// explicit port, or an IPv6 literal all land correctly — a mis-parse here +/// would reject every public request before the bearer token is even checked. +fn allowed_hosts_for(public_url: &str) -> Vec { + let mut hosts = vec![ + "localhost".to_string(), + "127.0.0.1".to_string(), + // IPv6 loopback in BOTH spellings: a real `Host` header serializes it + // bracketed (`[::1]`, matching the `host_str()` form the public-host + // branch below emits), while the bare `::1` is kept for readers/tools + // that use it. (rmcp normalizes brackets on both sides before matching, + // so either would match today — carrying both keeps the list + // self-consistent and independent of that normalization.) + "::1".to_string(), + "[::1]".to_string(), + ]; + let public_url = public_url.trim(); + let candidate = if public_url.contains("://") { + public_url.to_string() + } else { + format!("https://{public_url}") + }; + if let Ok(url) = url::Url::parse(&candidate) { + // `host_str` keeps IPv6 brackets ("[::1]"), matching the Host header's + // own serialization. + if let Some(host) = url.host_str() { + if !hosts.iter().any(|h| h == host) { + hosts.push(host.to_string()); + } + if let Some(port) = url.port() { + hosts.push(format!("{host}:{port}")); + } + } + } + hosts +} + +/// The origin-global II **auth-callback allow-list** (II #4091): before +/// contacting the connect callback named in the (attacker-craftable) link +/// fragment, II fetches this origin's `/.well-known/ii-auth-callbacks` and +/// requires the callback to be EXACTLY one of the declared entries — +/// fail-closed, so serving it is mandatory once #4091 ships. The path carries +/// no instance prefix, so ONE document must declare every instance's callback: +/// pass all of an origin's `McpServer`s and merge the router at the +/// application root. CORS-open (II's frontend fetches it cross-origin). +pub fn auth_callbacks_router(servers: &[&McpServer]) -> Router { + let stores: Vec = servers.iter().map(|s| s.store.clone()).collect(); + Router::new() + .route(auth::AUTH_CALLBACKS_WELL_KNOWN, get(auth::auth_callbacks)) + .with_state(stores) + .layer(permissive_cors()) +} + +/// The OAuth and discovery endpoints are called cross-origin by browser-based +/// MCP clients, so they are CORS-open (they are public API, gated by their own +/// semantics, not by origin). +fn permissive_cors() -> tower_http::cors::CorsLayer { + tower_http::cors::CorsLayer::new() + .allow_origin(tower_http::cors::Any) + .allow_methods(tower_http::cors::Any) + .allow_headers(tower_http::cors::Any) +} + +/// Normalize a mount path to `""` (root) or `/segment[/…]` — leading slash, +/// no trailing slash — so it can be interpolated into URLs and route paths. +fn normalize_mount_path(path: &str) -> String { + let path = path.trim().trim_end_matches('/'); + if path.is_empty() { + String::new() + } else if path.starts_with('/') { + path.to_string() + } else { + format!("/{path}") + } +} + +/// Canonicalize [`McpConfig::public_url`] to a clean **origin** — `scheme://host[:port]`, +/// with a lowercase scheme, the default port dropped, and any path/query/fragment +/// removed. `public_url` is the single source of truth for every absolute URL the +/// server emits (the AS issuer, the discovery documents, the II callback), for the +/// `Secure`-cookie decision, and for the DNS-rebinding host allow-list, so a +/// scheme-less host (`mcp.example.com`), an uppercase scheme (`HTTPS://…`), or a +/// stray path (`https://mcp.example.com/mcp`) would otherwise produce malformed +/// issuers/callbacks or a wrongly-dropped `Secure` flag. Self-corrects rather than +/// failing: an `https` scheme is assumed when none is given, and if parsing can't +/// yield a host the trimmed input is returned unchanged (best effort). +fn normalize_public_url(raw: &str) -> String { + let raw = raw.trim(); + // Give `Url::parse` a scheme to work with; a bare host isn't a valid URL. + let candidate = if raw.contains("://") { + raw.to_string() + } else { + format!("https://{raw}") + }; + if let Ok(url) = url::Url::parse(&candidate) { + // `scheme()` is lowercased by the parser; `host_str()` keeps IPv6 + // brackets; `port()` is `None` for an absent or default port (so the + // origin omits `:443`/`:80`). + if let Some(host) = url.host_str() { + let scheme = url.scheme(); + return match url.port() { + Some(port) => format!("{scheme}://{host}:{port}"), + None => format!("{scheme}://{host}"), + }; + } + } + raw.trim_end_matches('/').to_string() +} + +#[cfg(test)] +mod lib_tests { + use super::{allowed_hosts_for, normalize_mount_path, normalize_public_url}; + + /// `public_url` canonicalizes to a clean origin regardless of the shape the + /// caller passes: a scheme is added when absent, an uppercase scheme is + /// lowercased (so the `Secure`-cookie check sees `https://`), a trailing + /// slash or a stray path is dropped, and a default port is omitted while a + /// non-default one is kept. + #[test] + fn public_url_normalizes_to_an_origin() { + assert_eq!(normalize_public_url("https://mcp.example.com"), "https://mcp.example.com"); + assert_eq!(normalize_public_url("https://mcp.example.com/"), "https://mcp.example.com"); + // Stray path dropped (would otherwise double up under {public_url}{mcp_path}). + assert_eq!(normalize_public_url("https://mcp.example.com/mcp"), "https://mcp.example.com"); + // Scheme-less host gets https. + assert_eq!(normalize_public_url("mcp.example.com"), "https://mcp.example.com"); + // Uppercase scheme lowercased (fixes the Secure-cookie check). + assert_eq!(normalize_public_url("HTTPS://mcp.example.com"), "https://mcp.example.com"); + // Default port dropped; non-default kept; local http preserved. + assert_eq!(normalize_public_url("https://mcp.example.com:443"), "https://mcp.example.com"); + assert_eq!(normalize_public_url("https://mcp.example.com:8443"), "https://mcp.example.com:8443"); + assert_eq!(normalize_public_url("http://localhost:8000"), "http://localhost:8000"); + // IPv6 literal stays bracketed. + assert_eq!(normalize_public_url("http://[::1]:8080"), "http://[::1]:8080"); + } + + #[test] + fn mount_paths_are_normalized() { + assert_eq!(normalize_mount_path("/mcp"), "/mcp"); + assert_eq!(normalize_mount_path("mcp"), "/mcp"); + assert_eq!(normalize_mount_path("/mcp/"), "/mcp"); + assert_eq!(normalize_mount_path("/api/v1/mcp"), "/api/v1/mcp"); + assert_eq!(normalize_mount_path("/"), ""); + assert_eq!(normalize_mount_path(""), ""); + } + + /// The DNS-rebinding allow-list must survive every plausible `public_url` + /// shape: with/without a scheme, with an explicit port (the Host header + /// then carries `host:port`), and IPv6 literals (bracketed, as in the Host + /// header). A mis-parse would reject all public traffic pre-auth. + #[test] + fn allowed_hosts_cover_public_url_shapes() { + let has = |hosts: &[String], h: &str| hosts.iter().any(|x| x == h); + + let hosts = allowed_hosts_for("https://mcp.example.com"); + assert!(has(&hosts, "mcp.example.com"), "{hosts:?}"); + assert!(has(&hosts, "localhost") && has(&hosts, "127.0.0.1")); + // IPv6 loopback in both the bare and the bracketed (Host-header) form. + assert!(has(&hosts, "::1") && has(&hosts, "[::1]"), "{hosts:?}"); + + // Explicit port: both forms are allowed (the Host header carries the + // port for non-default ports). + let hosts = allowed_hosts_for("https://mcp.example.com:8443"); + assert!(has(&hosts, "mcp.example.com") && has(&hosts, "mcp.example.com:8443"), "{hosts:?}"); + + // Scheme-less input (the old string-split produced nothing here). + let hosts = allowed_hosts_for("mcp.example.com"); + assert!(has(&hosts, "mcp.example.com"), "{hosts:?}"); + + // IPv6 literal, bracketed like the Host header serializes it. + let hosts = allowed_hosts_for("http://[2001:db8::1]:8080"); + assert!(has(&hosts, "[2001:db8::1]") && has(&hosts, "[2001:db8::1]:8080"), "{hosts:?}"); + + // The local-dev default: no duplicate entry for an already-loopback host. + let hosts = allowed_hosts_for("http://localhost:8000"); + assert_eq!(hosts.iter().filter(|h| *h == "localhost").count(), 1); + assert!(has(&hosts, "localhost:8000"), "{hosts:?}"); + } +} diff --git a/src/main.rs b/src/main.rs index 577bc0a..3a57ab5 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,1919 +1,32 @@ -//! Minimal MCP PoC: an MCP server exposing tools over streamable HTTP that talk -//! to the Internet Computer via ic-agent. +//! Deployment binary for the [`imcp2`] library: composes two Internet Identity +//! instances at their canonical paths and adds the deployment niceties (the +//! landing page, a `/version` probe with live-session gauges, request logging, +//! env-driven config, drained graceful shutdown). //! -//! 1. `get_canister_candid` — fetch a canister's Candid interface (`candid:service` metadata). -//! 2. `discover_app_canisters` — find the canisters behind a web domain. -//! 3. `canister_query` — READ a canister: a Candid `query` method OR an OQL query. -//! 4. `canister_update_call` — call an update method with textual Candid in/out, -//! as `anonymous` or as a domain identity derived ON DEMAND. +//! * `/mcp` — MCP endpoint against **beta** Internet Identity, with its +//! OAuth AS at `/mcp/oauth/*` (issuer `/mcp`) +//! * `/mcp-prod` — the same against **production** Internet Identity +//! (`/mcp-prod/oauth/*`, issuer `/mcp-prod`) +//! * `/.well-known/*` — the OAuth discovery documents (path-inserted per +//! instance, plus the plain-root fallbacks for the default beta instance) +//! and the origin-global II auth-callback allow-list //! -//! The LLM only ever deals with textual Candid; encoding/decoding happens here. -//! Anonymous calls use the shared anonymous agent. A domain identity is minted -//! on demand from the connection's standing II delegation (see `identities`). +//! Honours `$PORT` (bind port, default 8000) and `$PUBLIC_URL` (the public +//! base URL baked into the discovery documents and the II handshake). -mod auth; -mod calls; -mod discover; -mod identities; -mod management; -mod skills; - -use candid::Principal; -use ic_agent::{Agent, Identity}; -use identities::Identities; -use rmcp::{ - handler::server::{router::tool::ToolRouter, wrapper::Parameters}, - model::*, - service::RequestContext, - tool, tool_handler, tool_router, - transport::{ - streamable_http_server::{session::local::LocalSessionManager, tower::StreamableHttpService}, - StreamableHttpServerConfig, - }, - schemars, ErrorData as McpError, RoleServer, ServerHandler, -}; +use axum::{response::Html, routing::get, Json, Router}; +use imcp2::{auth_callbacks_router, Agent, IiInstance, McpConfig, McpServer, SharedClients, IC_URL}; use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt}; -/// Public IC API boundary node. Anonymous queries/updates go here. -const IC_URL: &str = "https://icp-api.io"; - -/// Cap on the per-canister Candid probes open_app / discover_app_canisters run to -/// fill in OQL / api-doc capability flags (#3). Discovery output is already bounded, -/// but a large declared manifest could still list many ids — probe only the most -/// authoritative handful so the extra fan-out stays small. -const MAX_CAPABILITY_PROBES: usize = 8; - -/// Candid references exposed as MCP resources so the client writes correct -/// textual Candid. The textual-syntax cheat sheet is emphasised because every -/// tool here speaks textual Candid; the full type reference backs it up. -const CANDID_TEXTUAL_URI: &str = "candid://textual-syntax"; -const CANDID_REFERENCE_URI: &str = "candid://reference"; -/// URI scheme for IC skills exposed as MCP resources (`skill://`). -const SKILL_URI_PREFIX: &str = "skill://"; -const CANDID_TEXTUAL_MD: &str = include_str!("../static/candid-textual-syntax.md"); -const CANDID_REFERENCE_MD: &str = include_str!("../static/candid-reference.md"); - -/// The OQL query-surface usage guide. Served on demand (via the `icp_oql_guide` -/// tool and the `oql://usage` resource) rather than inlined into every -/// `get_canister_candid` reply: `get_canister_candid` only signals `oql: true` plus a one-line -/// pointer here, so the guidance is delivered once, when the model chooses to -/// query, without bloating every interface read. -const OQL_USAGE_URI: &str = "oql://usage"; -const OQL_PRIMER_MD: &str = include_str!("../static/oql-primer.md"); - /// Bind address. Honours `$PORT` (set by most PaaS), defaulting to 8000. fn bind_address() -> String { let port = std::env::var("PORT").unwrap_or_else(|_| "8000".to_string()); format!("0.0.0.0:{port}") } -/// Hosts allowed in the `Host` header by rmcp's DNS-rebinding protection. -/// Defaults to loopback (good for local dev); when served behind a public URL -/// (tunnel/PaaS), the `PUBLIC_URL` host must be allowed or every `/mcp` request -/// is rejected before the bearer token is even checked. -fn allowed_hosts() -> Vec { - let mut hosts = vec![ - "localhost".to_string(), - "127.0.0.1".to_string(), - "::1".to_string(), - ]; - if let Ok(url) = std::env::var("PUBLIC_URL") { - if let Some(host) = url.split("://").nth(1).and_then(|r| r.split('/').next()) { - let host = host.trim(); - if !host.is_empty() { - hosts.push(host.to_string()); - } - } - } - hosts -} - -// Per-tool argument and output types live in the module that implements the -// tool: `calls` (get_canister_candid, canister_query, canister_update_call), -// `discover`, `identities`, `skills`, `management`. main.rs only wires the tools -// together. - -#[derive(Clone)] -struct IcTools { - agent: Agent, - identities: Identities, - skills: skills::SkillsCatalog, - tool_router: ToolRouter, -} - -#[tool_router] -impl IcTools { - fn new(agent: Agent, identities: Identities, skills: skills::SkillsCatalog) -> Self { - Self { - agent, - identities, - skills, - tool_router: Self::tool_router(), - } - } - - #[tool( - description = "Fetch the Candid (.did) interface definition of an Internet Computer canister, read from its public `candid:service` metadata. Also reports two capability flags: `oql` (the canister exposes the OQL query surface — READ it via icp_oql_guide → get_canister_oql_schema → canister_query with the `oql` argument, since a Candid `method` query is then rejected) and `api_doc_available` (a `getApiDoc`/`get_api_doc` method exists — call get_canister_api_doc for a prose behavior guide; skip that call when this is false).", - annotations(title = "Get Candid interface", read_only_hint = true, destructive_hint = false, open_world_hint = true), - output_schema = schema_for_output::(), - )] - async fn get_canister_candid( - &self, - Parameters(calls::GetCandidArgs { canister_id }): Parameters, - ) -> Result { - let principal = match Principal::from_text(&canister_id) { - Ok(p) => p, - Err(e) => return Ok(err(format!("invalid canister id: {e}"))), - }; - match self - .agent - .read_state_canister_metadata(principal, "candid:service") - .await - { - Ok(bytes) => match String::from_utf8(bytes) { - Ok(did) => { - // Signal an OQL query surface structurally (`oql: true`). When - // set, the guidance pointer is emitted as a SEPARATE content - // block, so the first block stays the raw `.did` (still valid, - // copy-pastable Candid) — the full primer is served on demand - // (see icp_oql_guide / OQL_USAGE_URI), never inlined. - let oql = calls::has_oql(&did); - // Same predicate get_canister_api_doc uses, so the flag gates that - // call: true means a `getApiDoc`/`get_api_doc` method exists. - let api_doc_available = calls::api_doc_method(&did).is_some(); - let output = calls::GetCandidOutput { - canister_id, - candid: did.clone(), - oql, - api_doc_available, - }; - // Assemble any capability notes as separate blocks so the first - // block stays the raw, paste-able `.did`. - let mut notes = Vec::new(); - if oql { - notes.push(format!( - "This canister exposes an OQL query surface (a JSON query language \ - over its data), so READ it through the OQL tools — a Candid `method` \ - query via canister_query is rejected here. Order: icp_oql_guide \ - (dialect, once) → get_canister_oql_schema (entities/fields) → \ - canister_query with the `oql` argument (run a JSON query, get a table). \ - Those wrap the `schema`/`execute` methods (no Candid escaping). Per-app \ - data is caller-gated, so the OQL read path REQUIRES the app's \ - derivation_origin — an anonymous OQL read is rejected (for now), not \ - silently empty; pass the derivation_origin from open_app / resolve_app. \ - See icp_oql_guide (or the `{OQL_USAGE_URI}` resource) for the dialect. \ - canister_update_call then handles UPDATE calls only." - )); - } - notes.push(if api_doc_available { - "This canister exposes an API-doc method (api_doc_available=true): call \ - get_canister_api_doc for a prose \"how this app behaves\" guide (units, \ - auth, lifecycle, gotchas)." - .to_string() - } else { - "This canister declares no API-doc method (api_doc_available=false) — don't \ - call get_canister_api_doc; the Candid types above are the interface." - .to_string() - }); - let mut blocks = vec![did]; - blocks.extend(notes); - Ok(ok_structured_blocks(blocks, &output)) - } - Err(e) => Ok(err(format!("metadata is not valid UTF-8: {e}"))), - }, - Err(e) => Ok(err(format!( - "could not read candid:service metadata: {e}" - ))), - } - } - - #[tool( - description = "Load the OQL query-surface guide: the JSON query dialect for canisters that expose OQL (get_canister_candid reports `oql: true`) — entities/fields/edges via `schema`, and the `execute` query object (filters, aggregation, ordering, edge traversal, paging). This is step ONE of the fixed sequence guide→schema→query: read this once, then `get_canister_oql_schema` for the exact entity/field names (they are the schema's own — often PLURAL and unlike the Candid types/methods, e.g. `bookings` not `Booking`/`getBookings`), then `canister_query` with the `oql` argument. Never guess bespoke per-question methods. The schema read and the query REQUIRE the app's `derivation_origin` (from open_app / resolve_app) — anonymous per-app reads are disabled for now and are rejected with guidance. Both wrap the `schema`/`execute` methods, so you write plain JSON — no Candid escaping.", - annotations(title = "Get the OQL query guide", read_only_hint = true, destructive_hint = false, open_world_hint = false), - output_schema = schema_for_output::(), - )] - async fn icp_oql_guide( - &self, - Parameters(_args): Parameters, - ) -> Result { - let output = calls::OqlGuideOutput { content: OQL_PRIMER_MD.to_string() }; - Ok(ok_structured(output.content.clone(), &output)) - } - - #[tool( - description = "Fetch the OQL schema catalogue of a canister that exposes the OQL surface (get_canister_candid reports `oql: true`): its entities, their primary keys, fields, and edges, as JSON. The MIDDLE step of guide→schema→query: read `icp_oql_guide` once, then call THIS before canister_query so you use the exact entity/field names instead of guessing. Entity names are the schema's own — often PLURAL and different from the Candid types/methods (e.g. `bookings`, not `Booking`/`getBookings`). Returns the schema plus a ready-to-run `canister_query` example per entity (each preserving this call's identity). AUTH: `derivation_origin` is REQUIRED — the schema itself is gated by the caller's principal, so a read with no origin is REJECTED (anonymous per-app reads are disabled for now) with guidance to pass it, rather than returning an empty entity list you'd misread as \"the app has no data model\". Pass the app's canonical `derivation_origin` (from open_app / resolve_app) to read the entities visible to the USER; the reply echoes `derived_for_origin` / `acted_as_principal`.", - annotations(title = "Get the OQL schema", read_only_hint = true, destructive_hint = false, open_world_hint = true), - output_schema = schema_for_output::(), - )] - async fn get_canister_oql_schema( - &self, - Parameters(calls::OqlSchemaArgs { canister_id, derivation_origin, account }): Parameters, - ctx: RequestContext, - ) -> Result { - let principal = match Principal::from_text(&canister_id) { - Ok(p) => p, - Err(e) => return Ok(err(format!("invalid canister id: {e}"))), - }; - let arg_bytes = match calls::encode_unit_arg() { - Ok(b) => b, - Err(e) => return Ok(err(e)), - }; - // Anonymous per-app reads are disabled for now (the schema is itself gated by - // the caller's principal): reject with guidance instead of returning an empty - // entity list an agent would misread as "the app has no data model". - let target = match resolve_identity_target(derivation_origin) { - Ok(t) => t, - Err(e) => return Ok(err(e)), - }; - let Some(target) = target else { - return Ok(err(oql_needs_origin_error("Reading the OQL schema"))); - }; - let requested = Some(target.requested.clone()); - let origin = Some(target.origin.clone()); - let (agent, acted_as) = match self - .resolve_agent(&ctx, Some(target.origin.as_str()), account.as_deref(), "reading the schema") - .await - { - Ok(a) => a, - Err(e) => return Ok(err(e)), - }; - let reply = match calls::raw_call(&agent, principal, "schema", arg_bytes, true).await { - Ok(b) => b, - Err(e) => return Ok(err(format!("OQL schema call failed: {e}"))), - }; - let schema = calls::decode_schema_reply(&reply); - // An origin is required (rejected above if absent), so this is never anonymous. - let is_anonymous = false; - // #8: a COMPLETE canister_query per entity, carrying the SAME identity this - // schema was read under (so copying one doesn't drop to anon). - let example_queries = - calls::oql_query_examples(&canister_id, &schema, Some(target.origin.as_str()), account.as_deref()); - // #1: an EMPTY schema (no visible entities) for this authenticated account is - // "nothing visible here", not "the app has no data model". - let empty_note = if calls::oql_schema_is_empty(&schema) { - Some( - "This account sees no OQL entities on this canister — confirm the \ - derivation_origin/account are the ones the user uses in their browser." - .to_string(), - ) - } else { - None - }; - // Keep the primary block as the raw schema JSON (paste-able); surface the - // empty-note, the ready-to-run examples, and the identity note as SEPARATE - // blocks so none of them break the JSON for a copy-paste consumer. - let mut blocks = vec![schema.clone()]; - if let Some(note) = &empty_note { - blocks.push(note.clone()); - } - if !example_queries.is_empty() { - blocks.push(format!( - "Ready-to-run queries (read-only; each preserves this identity):\n{}", - example_queries.join("\n") - )); - } - blocks.push(format!("[{}]", identity_annotation(&target, acted_as.as_deref()))); - let output = calls::OqlSchemaOutput { - canister_id, - schema, - acted_as_principal: acted_as, - derived_for_origin: origin, - requested, - is_anonymous, - note: empty_note, - example_queries, - }; - Ok(ok_structured_blocks(blocks, &output)) - } - - #[tool( - description = "Read a canister's own API documentation — a prose \"how this app behaves\" guide covering units, auth, lifecycle, non-obvious semantics, mutation safety, polling rules, and gotchas — if it exposes a `getApiDoc`/`get_api_doc` method. Call this ONLY when get_canister_candid (or open_app) reports `api_doc_available: true`; most canisters have no such doc, and then the Candid types ARE the interface. Returns a STRUCTURED result in every case (never a bare error): on success `available: true` + the doc markdown; otherwise `available: false` with `expected` (true = interface read fine, no such method — don't retry) and `retry` (true = a transient/unreachable failure — retry) plus a `next` hint, so you can tell \"no doc here\" from \"couldn't reach it\".", - annotations(title = "Get a canister's API documentation", read_only_hint = true, destructive_hint = false, open_world_hint = true), - output_schema = schema_for_output::(), - )] - async fn get_canister_api_doc( - &self, - Parameters(calls::ApiDocArgs { canister_id }): Parameters, - ) -> Result { - let principal = match Principal::from_text(&canister_id) { - Ok(p) => p, - Err(e) => return Ok(err(format!("invalid canister id: {e}"))), - }; - // Read the interface to learn which naming the canister uses - // (getApiDoc vs get_api_doc); the doc is public, so call anonymously. - let did = calls::candid_service(&self.agent, principal).await; - // A structured "not available" (#6) so the agent can tell an EXPECTED - // absence (interface read fine, no api-doc method — don't retry) from an - // UNREACHABLE canister (couldn't read the interface / the call trapped — - // retry) instead of getting one opaque error for both. - let unavailable = |expected: bool, retry: bool, next: &str| { - let output = calls::ApiDocOutput { - canister_id: canister_id.clone(), - available: false, - method: None, - doc: None, - expected, - retry, - next: Some(next.to_string()), - }; - ok_structured(next.to_string(), &output) - }; - let Some(did) = did.as_deref() else { - // Interface unreadable: can't tell whether an api-doc method exists, and - // this is often transient (or access-restricted) — flag it retryable. - return Ok(unavailable( - false, - true, - "Couldn't read this canister's Candid interface, so its API-doc method (if any) is \ - unknown — this may be transient. Retry, or read the interface with \ - get_canister_candid.", - )); - }; - let method = match calls::api_doc_method(did) { - Some(m) => m, - None => { - // Interface read fine; the canister simply declares no api-doc method - // — expected for most canisters, and retrying won't change it. - return Ok(unavailable( - true, - false, - "This canister declares no `getApiDoc`/`get_api_doc` method — most canisters \ - don't. Use get_canister_candid for the interface; its api_doc_available flag \ - mirrors this.", - )); - } - }; - let arg_bytes = match calls::encode_unit_arg() { - Ok(b) => b, - Err(e) => return Ok(err(e)), - }; - let reply = match calls::raw_call(&self.agent, principal, method, arg_bytes, true).await { - Ok(b) => b, - Err(e) => { - // The method exists but the call failed — a transient/network issue, - // so retryable (distinct from the "no such method" case above). - return Ok(unavailable( - false, - true, - &format!("The {method} call failed ({e}); this is likely transient — retry."), - )); - } - }; - let doc = calls::decode_text_reply(&reply); - let output = calls::ApiDocOutput { - canister_id, - available: true, - method: Some(method.to_string()), - doc: Some(doc.clone()), - expected: false, - retry: false, - next: None, - }; - Ok(ok_structured(doc, &output)) - } - - /// The agent to sign calls with for a request, and the principal it signs as: - /// the shared anonymous agent (principal `None`) when `origin` is `None`, else - /// one backed by a short-lived account delegation for that Internet Identity - /// derivation `origin`, derived on demand from this connection's standing - /// credential. `origin` must be a VALIDATED derivation origin: the canonical one - /// from [`resolve_identity_target`], which every caller here now passes - /// (canister_update_call, both of canister_query's paths, and - /// get_canister_oql_schema). (get_app_principal and - /// list_app_accounts don't use this helper — they call - /// `Identities::delegated_identity_for` / `list_accounts` directly with a - /// `resolve_identity_target` origin.) `delegated_identity_for` re-canonicalizes - /// internally (idempotent), so an already-canonical origin is fine. `what` - /// names the action for the no-session error. - async fn resolve_agent( - &self, - ctx: &RequestContext, - origin: Option<&str>, - account: Option<&str>, - what: &str, - ) -> Result<(Agent, Option), String> { - match origin { - None => Ok((self.agent.clone(), None)), - Some(origin) => { - let session_id = authed_session(ctx) - .ok_or_else(|| format!("{what} as an app needs an authenticated session"))? - .session_id; - let delegated = self - .identities - .delegated_identity_for(&session_id, origin, account) - .await?; - let principal = delegated.sender().ok().map(|p| p.to_text()); - let agent = Agent::builder() - .with_url(IC_URL) - .with_identity(delegated) - .build() - .map_err(|e| format!("could not build agent: {e}"))?; - Ok((agent, principal)) - } - } - } - - /// Probe the app's OWN data canisters once (#3): fetch each candidate's Candid - /// anonymously (the interface is public) and record its OQL / api-doc capability - /// flags on the `DiscoveredCanister`. Only app-owned data candidates are probed - /// — never the frontend or a shared system canister (ledger/II/NNS) — so the - /// caller-gated data-access handle the flags feed stays correctly scoped. Probes - /// run CONCURRENTLY and fail soft: an unreadable interface just leaves the flags - /// null. Bounded by `MAX_CAPABILITY_PROBES` so a large manifest can't fan out - /// unboundedly. - async fn enrich_capabilities(&self, canisters: &mut [discover::DiscoveredCanister]) { - let targets: Vec<(usize, Principal)> = canisters - .iter() - .enumerate() - .filter(|(_, c)| discover::is_app_data_candidate(c)) - .filter_map(|(i, c)| Principal::from_text(&c.canister_id).ok().map(|p| (i, p))) - .take(MAX_CAPABILITY_PROBES) - .collect(); - if targets.is_empty() { - return; - } - let mut set = tokio::task::JoinSet::new(); - for (i, principal) in targets { - let agent = self.agent.clone(); - set.spawn(async move { - let did = calls::candid_service(&agent, principal).await; - // Only conclude flags when the interface was actually read; a failed - // read leaves them null (unknown), not a misleading `false`. - let flags = did - .as_deref() - .map(|d| (calls::has_oql(d), calls::api_doc_method(d).is_some())); - (i, flags) - }); - } - while let Some(res) = set.join_next().await { - if let Ok((i, Some((oql, api_doc)))) = res { - canisters[i].oql = Some(oql); - canisters[i].api_doc_available = Some(api_doc); - } - } - } - - #[tool( - description = "Make an UPDATE call (a state-changing call) on an Internet Computer canister method, with textual Candid in and out. 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.", - annotations(title = "Make a canister update call", read_only_hint = false, destructive_hint = true, idempotent_hint = false, open_world_hint = true), - output_schema = schema_for_output::(), - )] - async fn canister_update_call( - &self, - Parameters(calls::CanisterUpdateCallArgs { - canister_id, - method, - args, - derivation_origin, - account, - candid, - }): Parameters, - ctx: RequestContext, - ) -> Result { - let principal = match Principal::from_text(&canister_id) { - Ok(p) => p, - Err(e) => return Ok(err(format!("invalid canister id: {e}"))), - }; - // The interface to encode/decode against: the canister's own - // candid:service if exposed, else the caller-supplied `candid`. Update calls - // are never redirected (OQL is read-only), so no oql_query_redirect here. - let did = calls::resolve_did(&self.agent, principal, candid.as_deref()).await; - let arg_bytes = match calls::encode_args(did.as_deref(), &method, &args) { - Ok(b) => b, - Err(e) => return Ok(err(e)), - }; - // Resolve which principal to act as: none = anonymous; else the app's - // effective (canonical) II derivation origin, from the caller's explicit - // `derivation_origin` (obtained once via open_app / resolve_app). - let target = match resolve_identity_target(derivation_origin) { - Ok(t) => t, - Err(e) => return Ok(err(e)), - }; - let origin = target.as_ref().map(|t| t.origin.as_str()); - let (agent, acted_as_principal) = match self - .resolve_agent(&ctx, origin, account.as_deref(), "calling") - .await - { - Ok(a) => a, - Err(e) => return Ok(err(e)), - }; - let reply_bytes = match calls::raw_call(&agent, principal, &method, arg_bytes, false).await { - Ok(b) => b, - Err(e) => return Ok(err(format!("call failed: {e}"))), - }; - // Decode against the Candid interface so field names are recovered. - let reply = calls::decode_reply(did.as_deref(), &method, &reply_bytes); - let (derived_for_origin, requested, derivation_origin_source) = match &target { - Some(t) => (Some(t.origin.clone()), Some(t.requested.clone()), Some(t.source.clone())), - None => (None, None, None), - }; - let is_anonymous = target.is_none(); - // Keep the primary text block pure textual Candid (paste-able); surface the - // identity note (so a wrong-principal is visible to text-only clients) as a - // SEPARATE block rather than contaminating the reply. - let mut blocks = vec![reply.clone()]; - if let Some(t) = &target { - let acted = acted_as_principal.as_deref().unwrap_or(""); - blocks.push(format!("[{}]", identity_annotation(t, Some(acted)))); - } - let output = calls::CanisterUpdateCallOutput { - canister_id, method, reply, - acted_as_principal, derived_for_origin, requested, derivation_origin_source, - is_anonymous, - }; - Ok(ok_structured_blocks(blocks, &output)) - } - - #[tool( - description = "If the request is about a specific app or the user's own data in it (a balance, holdings, a profile, \"what can it do\"), start with open_app first to resolve the app and discover its canisters, then compose the answer from what it returns, since there is rarely a dedicated per-feature tool. READ from an Internet Computer canister — provide EITHER a Candid `query` method OR an OQL query (exactly one). `method` is a query function from the canister's Candid interface, invoked with textual-Candid `args`; `oql` is an OQL query as a JSON object string, run against the canister's `execute` method (no Candid escaping — write plain JSON). Use `oql` when get_canister_candid reports `oql: true` (a Candid `method` query is then REJECTED — read via OQL); use `method` for a plain query canister such as a ledger. The `oql` path REQUIRES `derivation_origin` (per-app data is caller-gated; an anonymous OQL read is rejected for now) and returns `columns` + `rows` (a markdown table) with `has_more` for paging; on an empty result it validates the query's `start` against the schema and returns valid_entities + a did_you_mean repair. The `method` path may be anonymous, or pass `derivation_origin` + `account` to read AS your account; it returns the decoded reply in textual Candid. Get `derivation_origin` from open_app / resolve_app (not a raw URL), and the OQL entity/field names from get_canister_oql_schema. For state changes use canister_update_call.", - annotations(title = "Query a canister (Candid method or OQL)", read_only_hint = true, destructive_hint = false, open_world_hint = true), - output_schema = schema_for_output::(), - )] - async fn canister_query( - &self, - Parameters(calls::CanisterQueryArgs { - canister_id, - method, - args, - oql, - derivation_origin, - account, - candid, - }): Parameters, - ctx: RequestContext, - ) -> Result { - let principal = match Principal::from_text(&canister_id) { - Ok(p) => p, - Err(e) => return Ok(err(format!("invalid canister id: {e}"))), - }; - // Normalize: an empty/whitespace-only `method`/`oql` counts as absent, so a - // blank string doesn't masquerade as "both provided". - let method = method.map(|m| m.trim().to_string()).filter(|m| !m.is_empty()); - let oql = oql.filter(|o| !o.trim().is_empty()); - match (method, oql) { - (Some(_), Some(_)) => Ok(err( - "provide EITHER `method` (a Candid query) OR `oql` (an OQL query) — not both." - .into(), - )), - (None, None) => Ok(err( - "canister_query needs a read to run: pass `method` (a Candid query function) or \ - `oql` (an OQL query object). For a canister that exposes an OQL surface \ - (get_canister_candid reports oql: true) use `oql`; otherwise use `method`." - .into(), - )), - (Some(method), None) => { - self.canister_candid_query( - &ctx, principal, canister_id, method, args, derivation_origin, account, candid, - ) - .await - } - (None, Some(oql)) => { - self.canister_oql_query(&ctx, principal, canister_id, oql, derivation_origin, account) - .await - } - } - } - - /// `canister_query`'s Candid-`method` path: a read-only query call, encoded and - /// decoded against the canister's Candid interface. Rejected on an OQL canister - /// (data reads go through the `oql` path); may be anonymous or as an app account. - #[allow(clippy::too_many_arguments)] - async fn canister_candid_query( - &self, - ctx: &RequestContext, - principal: Principal, - canister_id: String, - method: String, - args: String, - derivation_origin: Option, - account: Option, - candid: Option, - ) -> Result { - // The interface to encode/decode against: the canister's own candid:service - // if exposed, else the caller-supplied `candid`. - let did = calls::resolve_did(&self.agent, principal, candid.as_deref()).await; - // Prefer OQL: a canister that exposes an OQL query surface must be READ with - // an OQL query, so reject a raw Candid `method` query here. Fail fast, before - // encoding args. - if let Some(msg) = calls::oql_query_redirect(did.as_deref()) { - return Ok(err(msg)); - } - // Read/write split: a query call to an UPDATE method is rejected by the - // replica at runtime, so when the interface is readable and `method` is - // declared as an update (not a query), fail fast with a clear pointer to - // canister_update_call instead of an opaque failure. Fail OPEN when the - // interface can't be read or the method isn't declared (is_query_method → - // None): the IC then decides, matching the old permissive behavior. (The - // reverse — a query method called as an update — is valid on the IC, so - // canister_update_call stays permissive.) - if let Some(did_text) = did.as_deref() { - if calls::is_query_method(did_text, &method) == Some(false) { - return Ok(err(format!( - "`{method}` is not a `query` method on this canister — its Candid signature is \ - an update method, which the replica refuses to run as a query. Call it with \ - canister_update_call instead (a state-changing update call)." - ))); - } - } - let arg_bytes = match calls::encode_args(did.as_deref(), &method, &args) { - Ok(b) => b, - Err(e) => return Ok(err(e)), - }; - let target = match resolve_identity_target(derivation_origin) { - Ok(t) => t, - Err(e) => return Ok(err(e)), - }; - let origin = target.as_ref().map(|t| t.origin.as_str()); - let (agent, acted_as_principal) = match self - .resolve_agent(ctx, origin, account.as_deref(), "querying") - .await - { - Ok(a) => a, - Err(e) => return Ok(err(e)), - }; - let reply_bytes = match calls::raw_call(&agent, principal, &method, arg_bytes, true).await { - Ok(b) => b, - Err(e) => return Ok(err(format!("query failed: {e}"))), - }; - let reply = calls::decode_reply(did.as_deref(), &method, &reply_bytes); - let (derived_for_origin, requested, derivation_origin_source) = match &target { - Some(t) => (Some(t.origin.clone()), Some(t.requested.clone()), Some(t.source.clone())), - None => (None, None, None), - }; - let is_anonymous = target.is_none(); - // #1: an anonymous query whose reply LOOKS empty is most likely an auth - // artifact (per-app data is caller-gated), not "no data". Computed only from - // local facts (anonymous + empty-looking reply); conservative, so a reply - // with real content never trips it. - let note = (is_anonymous && calls::candid_reply_is_empty(&reply)).then(|| { - calls::anonymous_empty_note( - "this query call", - "the app's canonical `derivation_origin` (from open_app / resolve_app)", - ) - }); - let mut blocks = vec![reply.clone()]; - if let Some(n) = ¬e { - blocks.push(n.clone()); - } - if let Some(t) = &target { - let acted = acted_as_principal.as_deref().unwrap_or(""); - blocks.push(format!("[{}]", identity_annotation(t, Some(acted)))); - } - let output = calls::CanisterQueryOutput { - canister_id, - mode: "candid".to_string(), - method: Some(method), - reply: Some(reply), - columns: Vec::new(), - rows: Vec::new(), - has_more: false, - acted_as_principal, - derived_for_origin, - requested, - derivation_origin_source, - is_anonymous, - note, - valid_entities: None, - did_you_mean: None, - }; - Ok(ok_structured_blocks(blocks, &output)) - } - - /// `canister_query`'s `oql` path (the former run_canister_oql_query tool): wrap - /// the JSON query as `execute`'s single text arg, run it, and decode the tabular - /// reply. Anonymous per-app OQL reads are disabled, so a `derivation_origin` is - /// REQUIRED; an empty/failed result is diagnosed against the schema for the SAME - /// principal (#1/#7). - async fn canister_oql_query( - &self, - ctx: &RequestContext, - principal: Principal, - canister_id: String, - oql: String, - derivation_origin: Option, - account: Option, - ) -> Result { - // Validate the query is a JSON object and wrap it as `execute`'s single text - // arg — the model writes plain JSON, we do the Candid encoding. - let query_json = match calls::normalize_oql_query(&oql) { - Ok(s) => s, - Err(e) => return Ok(err(e)), - }; - let arg_bytes = match calls::encode_text_arg(&query_json) { - Ok(b) => b, - Err(e) => return Ok(err(e)), - }; - // Anonymous per-app reads are disabled for now (this canister gates data by - // the caller's principal), so an OQL query REQUIRES a derivation origin. - let target = match resolve_identity_target(derivation_origin) { - Ok(t) => t, - Err(e) => return Ok(err(e)), - }; - let Some(target) = target else { - return Ok(err(oql_needs_origin_error("Running an OQL query"))); - }; - let requested = Some(target.requested.clone()); - let origin = Some(target.origin.clone()); - let derivation_origin_source = Some(target.source.clone()); - let (agent, acted_as) = match self - .resolve_agent(ctx, Some(target.origin.as_str()), account.as_deref(), "querying") - .await - { - Ok(a) => a, - Err(e) => return Ok(err(e)), - }; - let identity_note = format!("[{}]", identity_annotation(&target, acted_as.as_deref())); - // An origin is required (rejected above if absent), so this is never anonymous. - let is_anonymous = false; - let reply = match calls::raw_call(&agent, principal, "execute", arg_bytes, true).await { - Ok(b) => b, - Err(e) => { - // A trap is the OTHER way an invalid `start` shows up (#7): validate - // it against the schema for THIS principal and fold the repair into - // the error. Failed context: only the unknown-`start` repair is - // appended, never a "came back empty" note. - let d = diagnose_empty_oql(&agent, principal, &query_json, is_anonymous, EmptyContext::Failed).await; - let mut msg = format!("OQL execute failed: {e}"); - if let Some(note) = d.note { - msg.push_str(&format!("\n\n{note}")); - } - return Ok(err(msg)); - } - }; - // Decode the reply against the canister's interface so cell/field names are - // recovered (the wire format hashes them). - let did = calls::resolve_did(&agent, principal, None).await; - match calls::parse_execute_reply(did.as_deref(), &reply) { - calls::OqlResult::Table { columns, rows, has_more } => { - // Validate-on-empty (#1/#7): a 0-row result is diagnosed with facts - // from the SAME principal — unknown-`start` repair, or a benign - // "0 rows for this account" — never by probing others. - let mut diag = if rows.is_empty() { - diagnose_empty_oql(&agent, principal, &query_json, is_anonymous, EmptyContext::EmptyResult).await - } else { - OqlEmptyDiagnosis::none() - }; - if rows.is_empty() && diag.note.is_none() { - diag.note = Some(format!( - "Query matched 0 rows for this account{}.", - calls::oql_query_start(&query_json) - .map(|s| format!(" (start: \"{s}\")")) - .unwrap_or_default() - )); - } - let mut blocks = vec![calls::render_table(&columns, &rows, has_more)]; - if let Some(note) = &diag.note { - blocks.push(note.clone()); - } - blocks.push(identity_note); - let output = calls::CanisterQueryOutput { - canister_id, - mode: "oql".to_string(), - method: None, - reply: None, - columns, - rows, - has_more, - acted_as_principal: acted_as, - derived_for_origin: origin, - requested, - derivation_origin_source, - is_anonymous, - note: diag.note, - valid_entities: diag.valid_entities, - did_you_mean: diag.did_you_mean, - }; - Ok(ok_structured_blocks(blocks, &output)) - } - calls::OqlResult::QueryError(msg) => { - // The canister returned its error arm. An invalid `start` can land - // here too, so enrich with the schema-based repair (#7). - let d = diagnose_empty_oql(&agent, principal, &query_json, is_anonymous, EmptyContext::Failed).await; - let mut text = format!("the canister returned an OQL error: {msg}"); - if let Some(note) = d.note { - text.push_str(&format!("\n\n{note}")); - } - Ok(err(text)) - } - calls::OqlResult::Unrecognized(raw) => { - // Not a recognizable OQL result — hand back the raw decoded reply so - // the model still has the data. Carry a `note` (rather than null) so a - // structured-only consumer doesn't misread empty columns/rows as - // "no data" when the reply actually carried content (in the text block). - let unparsed_note = - "This reply couldn't be parsed as an OQL table; the raw decoded reply is in the \ - text block. This is NOT an empty result — read the raw reply for the data." - .to_string(); - let blocks = vec![ - format!("(Could not parse this as an OQL table; raw reply below.)\n\n{raw}"), - identity_note, - ]; - let output = calls::CanisterQueryOutput { - canister_id, - mode: "oql".to_string(), - method: None, - reply: None, - columns: Vec::new(), - rows: Vec::new(), - has_more: false, - acted_as_principal: acted_as, - derived_for_origin: origin, - requested, - derivation_origin_source, - is_anonymous, - note: Some(unparsed_note), - valid_entities: None, - did_you_mean: None, - }; - Ok(ok_structured_blocks(blocks, &output)) - } - } - } - - #[tool( - description = "Get the Internet Computer principal you act as at an app, without making a canister call. Identify the app by `derivation_origin` — its EXACT canonical Internet Identity derivation origin (NOT necessarily the visible website URL, and never inferred from an alternativeOrigins list). Get it from open_app / resolve_app (which turn an app name or URL into the derivation origin under the guessed-domain gate); this tool does NOT accept a raw website URL. The account delegation is derived on demand from this connection's standing Internet Identity credential. By default this resolves the app's default account; pass `account` (a name from list_app_accounts) for a specific one. The result returns the `principal` plus `derived_for_origin` and `requested` — compare them to catch a canonicalization surprise. If the principal looks wrong, the derivation origin is wrong: re-resolve the app with open_app / resolve_app rather than guessing an origin.", - annotations(title = "Get your principal at an app", read_only_hint = true, destructive_hint = false, open_world_hint = true), - output_schema = schema_for_output::(), - )] - async fn get_app_principal( - &self, - Parameters(identities::GetPrincipalArgs { derivation_origin, account }): Parameters, - ctx: RequestContext, - ) -> Result { - let session_id = match authed_session(&ctx) { - Some(s) => s.session_id, - None => return Ok(err("getting an app principal needs an authenticated session".into())), - }; - // `derivation_origin` is a required String here, so this always yields a - // target (never the anonymous None) unless it fails validation. - let target = match resolve_identity_target(Some(derivation_origin)) { - Ok(Some(t)) => t, - Ok(None) => return Ok(err("`derivation_origin` must not be empty".into())), - Err(e) => return Ok(err(e)), - }; - let delegated = match self - .identities - .delegated_identity_for(&session_id, &target.origin, account.as_deref()) - .await - { - Ok(d) => d, - Err(e) => return Ok(err(e)), - }; - let principal = match delegated.sender() { - Ok(p) => p.to_text(), - Err(e) => return Ok(err(format!("could not derive principal for {}: {e}", target.origin))), - }; - // Surface a read-only session (H2) so the LLM won't attempt (and have the - // IC reject at ingress) canister-management updates. - let read_only = self.identities.is_read_only(&session_id).await == Some(true); - let mut text = format!("{principal}\n\n[{}]", identity_annotation(&target, None)); - if read_only { - text.push_str( - "\n\n(This Internet Identity session is READ-ONLY: reads work, but canister \ - management — create/install/start/stop/delete, and icp_canister_status — needs \ - update access. Ask the user to reconnect with the read-only option turned OFF.)", - ); - } - let output = identities::PrincipalOutput { - derived_for_origin: target.origin, - requested: target.requested, - derivation_origin_source: target.source, - account, - principal, - read_only, - }; - Ok(ok_structured(text, &output)) - } - - #[tool( - description = "List the user's Internet Identity accounts at an app. Identify the app by `derivation_origin` — its EXACT canonical II derivation origin (not necessarily the visible URL). Get it from open_app / resolve_app (which turn an app name or URL into the derivation origin under the guessed-domain gate); this tool does NOT accept a raw website URL. Internet Identity gives the user a distinct principal per derivation origin, and within it they may hold several accounts: a default account everyone gets automatically (the anchor's current, user-controllable default there), plus any named accounts they created. Use this before acting on the user's behalf: if there's only the default account, just proceed (canister_query / canister_update_call / get_app_principal with no `account`); if there are several, pick one with the user by passing its name as `account`. Returns each account's name (the default has none), number, and last-used time, plus `derived_for_origin`/`requested` — if these accounts don't match what the user sees in their browser, the derivation origin is wrong (re-resolve the app rather than guessing). Requires an authenticated session.", - annotations(title = "List your accounts at an app", read_only_hint = true, destructive_hint = false, open_world_hint = true), - output_schema = schema_for_output::(), - )] - async fn list_app_accounts( - &self, - Parameters(identities::ListAccountsArgs { derivation_origin }): Parameters, - ctx: RequestContext, - ) -> Result { - let session_id = match authed_session(&ctx) { - Some(s) => s.session_id, - None => return Ok(err("listing your accounts needs an authenticated session".into())), - }; - // `derivation_origin` is a required String here, so this always yields a - // target (never the anonymous None) unless it fails validation. - let target = match resolve_identity_target(Some(derivation_origin)) { - Ok(Some(t)) => t, - Ok(None) => return Ok(err("`derivation_origin` must not be empty".into())), - Err(e) => return Ok(err(e)), - }; - match self.identities.list_accounts(&session_id, &target.origin).await { - Ok(accounts) => { - let text = format_accounts(&target, &accounts); - let output = identities::AccountsOutput { - derived_for_origin: target.origin, - requested: target.requested, - derivation_origin_source: target.source, - accounts: accounts.iter().map(identities::AccountEntry::from).collect(), - }; - Ok(ok_structured(text, &output)) - } - Err(e) => Ok(err(e)), - } - } - - #[tool( - description = "ROUTING. Run this chain before answering anything that names, or implies, a specific app or the user's own data in one app (a balance, holdings, bookings, positions, profile, or \"what can it do\"): (1) Is the request about a specific APP's functionality or the user's data in it, rather than the IC protocol itself? (2) Is that app on the Internet Computer? If unsure, THIS tool (or icp_find_app_by_name) is how you find out: a known app or an IC-verifiable URL resolves, while an unrecognized app name, or a URL with no Internet Computer evidence, is refused rather than guessed. (3) If so, START HERE: one open_app call resolves the app AND discovers its service. An app feature is almost never a single named tool (there is no \"get_balance\"); you COMPOSE it from what open_app returns, reading the discovered canisters and their `oql`/`api_doc_available` flags to see how to READ (canister_query, including OQL) or ACT (canister_update_call). So never conclude \"there's no tool for that\" from the flat tool list without opening the app first. Open an Internet Computer app in ONE call, given its NAME or its URL — the recommended entry point when a user names an app. It resolves the app's Internet Identity derivation origin (like resolve_app) AND discovers the canisters behind it (like discover_app_canisters) together, so you don't chain those yourself. Pass a NAME (e.g. \"MULTI/DEX\", \"Oisy\", \"NNS\") or a URL (e.g. \"https://oisy.com\"): a name or bare host is matched to the built-in known-app registry FIRST (so even a wrong-TLD guess like \"multidex.com\" repairs to the canonical URL), and an explicit https:// URL is resolved as given. NEVER fabricate a domain from a name — an unknown bare name is refused with instructions to find the real URL (web search / ask the user), and a URL with no Internet Computer evidence is refused, both instead of guessing a wrong identity. Returns `app_url` (the one used), `derivation_origin` (+ its source) to act with, `alternative_origins`, and the discovered `canisters` (with provenance/labels AND per-canister `oql`/`api_doc_available` capability flags, from a one-shot Candid probe of the app's own canisters). A canister flagged `oql` holds the app's data, GATED BY THE CALLER's principal: to read the USER's own data (\"my …\", \"our …\") pass the returned `derivation_origin` to get_canister_oql_schema (for the entity/field names) and to canister_query (with the `oql` argument, to run the query). Those OQL reads REQUIRE `derivation_origin` — not `app_url` — and reject an anonymous read for now. No authenticated session required for open_app itself (no principal is derived here). Narrower tools remain for single steps: icp_find_app_by_name (name→URL only), resolve_app (origin only), discover_app_canisters (canisters only).", - annotations(title = "Open an app (resolve origin + discover canisters)", read_only_hint = true, destructive_hint = false, open_world_hint = true), - output_schema = schema_for_output::(), - )] - async fn open_app( - &self, - Parameters(discover::OpenAppArgs { app }): Parameters, - ) -> Result { - // Clean the query like every other identity input: trim, reject empty, and - // reject control chars — otherwise they'd be echoed into the UnknownName - // error via find_app_by_name's note (the rest of the surface avoids this). - let query = match clean_identity_arg("app", &app) { - Ok(q) => q, - Err(e) => return Ok(err(e)), - }; - // Disambiguate name vs URL, repairing a known app's name/wrong-TLD to its - // canonical URL and refusing a bare unknown name rather than guessing. - let (app_url, app_url_source) = match discover::classify_app_query(&query) { - discover::AppQuery::Known(m) => (m.app_url, "known_app_registry"), - discover::AppQuery::Url(u) => (u, "as_provided"), - discover::AppQuery::UnknownName => { - // Echo find_app_by_name's web-search/ask guidance (it builds the - // "not a known app; don't guess a domain" note from the registry). - return Ok(err(discover::find_app_by_name(&query).note)); - } - }; - let app_url = match clean_app_url(&app_url) { - Ok(u) => u, - Err(e) => return Ok(err(e)), - }; - // Resolve (the gating step) and discover (canisters) run CONCURRENTLY so the - // happy path doesn't pay for discovery sequentially. Discovery is spawned so it - // can be ABORTED the moment resolution fails or the IC-evidence gate refuses: - // that makes a refused/guessed origin return PROMPTLY (no waiting out - // discovery's timeout) and cancels the REST of the crawl. It does not prevent - // discovery from starting — because it runs concurrently with the gate, a few - // requests may already be in flight before abort() — but those are bounded by - // discover's own size/count caps, and the common (accepted) case keeps the - // full concurrency win. Gating before starting discovery would eliminate that - // residual I/O only by serializing the two, which the happy path pays for. - let discovery = tokio::spawn({ - let url = app_url.clone(); - async move { discover::discover(&url).await } - }); - let resolved = match discover::resolve_app_identity(&app_url, true).await { - Ok(r) => r, - Err(e) => { - discovery.abort(); - return Ok(err(app_url_error_with_guidance(&app_url, e))); - } - }; - // The same guessed-domain gate as resolve_app / the identity routes: an - // assumed origin with no IC evidence is refused, not resolved. - if resolved.derivation_origin_source == discover::DerivationSource::AppUrlDefault - && resolved.application_is_ic == Some(false) - { - discovery.abort(); - return Ok(err(unverified_app_url_error(&resolved.application_origin))); - } - let effective = identities::target_origin(&resolved.derivation_origin); - let note = resolution_note(&resolved, &effective); - // Origin resolution succeeded and passed the IC gate, so the derivation - // context stands regardless of discovery. Collect the concurrent discovery - // now, keeping a FAILURE distinct from "found nothing" (a JoinError — i.e. a - // panic — is treated as a discovery failure, never a hard error). - let (canisters, omitted, discovery_error) = match discovery.await { - Ok(Ok(d)) => (d.canisters, d.omitted, None), - Ok(Err(e)) => (Vec::new(), 0, Some(e)), - Err(join_err) => (Vec::new(), 0, Some(format!("discovery task error: {join_err}"))), - }; - // Enrich the app's OWN data canisters with OQL / api-doc capability flags - // (#3), so open_app hands back a ready-to-use handle: which canister holds - // the (caller-gated) data, and the origin to read it as the user. - let mut discovered: Vec = - canisters.iter().map(discover::DiscoveredCanister::from).collect(); - self.enrich_capabilities(&mut discovered).await; - // The ready-to-use data-access handle: the resolved origin, scoped (by - // is_app_data_candidate) to the app's own OQL canisters — never a guessed - // origin, never II/NNS/ledger/frontend. `derivation_origin` only: the - // data-access note names the OQL read tools (get_canister_oql_schema, - // canister_query), which take `derivation_origin`, not a website URL, so - // offering app_url here would send the agent to a param those tools don't accept. - let handle = format!("derivation_origin=\"{effective}\""); - - let mut text = format!( - "app_url: {app_url} ({app_url_source})\nderivation_origin: {effective} ({})\n", - resolved.derivation_origin_source.as_str() - ); - if !resolved.alternative_origins.is_empty() { - text.push_str(&format!("alternative_origins: {}\n", resolved.alternative_origins.join(", "))); - } - if let Some(e) = &discovery_error { - text.push_str(&format!( - "\nCanister discovery FAILED ({e}) — this is NOT \"the app has no canisters\"; the \ - derivation origin above is still valid. Retry discover_app_canisters, or proceed \ - with a canister id you already have.\n" - )); - } else if discovered.is_empty() { - text.push_str("\nNo canisters discovered — the app appears to declare none.\n"); - } else { - text.push_str("\nCanisters:\n"); - for c in &discovered { - text.push_str(&render_canister_line(c)); - } - if omitted > 0 { - text.push_str(&format!( - "(+{omitted} more findings dropped by the output cap; the list is \ - authority-ordered, so the least authoritative entries were cut first)\n" - )); - } - } - if let Some(n) = ¬e { - text.push_str(&format!("\nNOTE: {n}")); - } - if let Some(access) = data_access_note(&discovered, Some(&handle)) { - text.push_str(&format!("\n\n{access}")); - } - text.push_str( - "\n\nNext: inspect a canister with get_canister_candid — its oql / api_doc_available \ - flags say whether to read via OQL and whether get_canister_api_doc has a doc \ - (only call it when api_doc_available). To act as the user, pass the derivation_origin \ - above to canister_query (read) and canister_update_call (write); for an OQL canister, \ - call get_canister_oql_schema for the entity/field names, then canister_query with \ - the `oql` argument — plus an optional account from list_app_accounts. A \"my/our…\" \ - question is an AUTHENTICATED read: pass the origin.", - ); - let output = discover::OpenAppOutput { - app_url, - app_url_source: app_url_source.to_string(), - application_origin: resolved.application_origin, - derivation_origin: effective, - derivation_origin_source: resolved.derivation_origin_source.as_str().to_string(), - alternative_origins: resolved.alternative_origins, - application_is_ic: resolved.application_is_ic, - canisters: discovered, - omitted, - discovery_error, - note, - }; - Ok(ok_structured(text, &output)) - } - - #[tool( - description = "Resolve an application URL to its Internet Identity derivation context, so you don't have to figure out the derivation origin yourself. `app_url` must be a URL you actually HAVE — from the user, from icp_find_app_by_name, or from a web search of the app's official site. NEVER guess or fabricate a domain from an app's name (when you only know a NAME, call icp_find_app_by_name first): a lookalike domain is an unrelated or squatted site, and this tool REFUSES to resolve an origin that shows no evidence of being an Internet Computer app rather than hand back a wrong identity. Returns the `application_origin`, the `derivation_origin` to pass to the identity tools, how it was determined (`derivation_origin_source`: \"declared\" — the app published it in /.well-known/ic-app.json, authoritative; \"known\" — from the connector's built-in registry of well-known custom-derivation-origin apps (e.g. NNS, Oisy, MULTI/DEX), used only when the app declares none; or \"app_url_default\" — the origin IS IC-served but declares nothing, so it was assumed to be its own derivation origin, correct only if the app has no custom one), and the app's `alternative_origins` (informational — the INVERSE relation, never use it to infer the derivation origin). This does NOT return a principal — it resolves the origin only, since you haven't picked an account; to get the principal you act as, pass the returned `derivation_origin` to get_app_principal (choosing an `account`) or list_app_accounts. Use this first when you only know an app's URL; no authenticated session is required.", - annotations(title = "Resolve an app's derivation origin", read_only_hint = true, destructive_hint = false, open_world_hint = true), - output_schema = schema_for_output::(), - )] - async fn resolve_app( - &self, - Parameters(identities::ResolveAppArgs { app_url }): Parameters, - ) -> Result { - let app_url = match clean_app_url(&app_url) { - Ok(u) => u, - Err(e) => return Ok(err(e)), - }; - // This tool surfaces `alternative_origins`, so fetch them (unlike the - // identity hot path in resolve_identity_target). It resolves the derivation - // origin ONLY — no principal is derived here, because the caller hasn't - // chosen an account; that's get_app_principal / list_app_accounts' job. So - // this needs no authenticated session. - let resolved = match discover::resolve_app_identity(&app_url, true).await { - Ok(r) => r, - Err(e) => return Ok(err(app_url_error_with_guidance(&app_url, e))), - }; - // A guessed/lookalike domain fails HARD instead of resolving to a - // plausible-but-wrong identity: no declared origin, not a known app, and - // no sign the origin is even served from the IC. The error carries the - // "did you mean" repair and the legitimate ways to obtain the URL. - if resolved.derivation_origin_source == discover::DerivationSource::AppUrlDefault - && resolved.application_is_ic == Some(false) - { - return Ok(err(unverified_app_url_error(&resolved.application_origin))); - } - let effective = identities::target_origin(&resolved.derivation_origin); - let note = resolution_note(&resolved, &effective); - let mut text = format!( - "application_origin: {}\nderivation_origin: {} ({})\n", - resolved.application_origin, effective, resolved.derivation_origin_source.as_str() - ); - if !resolved.alternative_origins.is_empty() { - text.push_str(&format!("alternative_origins: {}\n", resolved.alternative_origins.join(", "))); - } - if let Some(n) = ¬e { - text.push_str(&format!("\nNOTE: {n}")); - } - let output = identities::ResolveAppOutput { - application_origin: resolved.application_origin, - derivation_origin: effective, - derivation_origin_source: resolved.derivation_origin_source.as_str().to_string(), - alternative_origins: resolved.alternative_origins, - application_is_ic: resolved.application_is_ic, - note, - }; - Ok(ok_structured(text, &output)) - } - - #[tool( - description = "Discover the Internet Computer canisters behind a web domain (e.g. \"oisy.com\"). The domain must be one you actually have (from the user, icp_find_app_by_name, or a web search) — NEVER a domain guessed from an app's name; when you only know a NAME, call icp_find_app_by_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.", - annotations(title = "Discover canisters behind a domain", read_only_hint = true, destructive_hint = false, open_world_hint = true), - output_schema = schema_for_output::(), - )] - async fn discover_app_canisters( - &self, - Parameters(discover::DiscoverCanistersArgs { domain }): Parameters, - ) -> Result { - match discover::discover(&domain).await { - Ok(d) if !d.canisters.is_empty() => { - // Probe the app's own data canisters for OQL / api-doc capabilities - // (#3), mirroring open_app. discover_app_canisters resolves no origin, - // so the data-access note points at resolve_app / open_app for it. - let mut discovered: Vec = - d.canisters.iter().map(discover::DiscoveredCanister::from).collect(); - self.enrich_capabilities(&mut discovered).await; - let mut out = format!("Canisters discovered for {domain}:\n"); - for c in &discovered { - out.push_str(&render_canister_line(c)); - } - if d.omitted > 0 { - out.push_str(&format!( - "(+{} more findings dropped by the output cap; the list is \ - authority-ordered, so the least authoritative entries were cut first)\n", - d.omitted - )); - } - out.push_str( - "\n`ai-connect.html` and `ic-app.json` entries are DECLARED by the app itself \ - (its main backend, and its own canister manifest with roles) — treat them as \ - the app's claim about its composition. The `header` (x-ic-canister-id) entry \ - is the frontend/asset canister. Others come from env.json or the JS bundle \ - and may include multiple environments (prefer the production/IC ids). A \ - «name» (type) is the IC dashboard's label for that id. `[oql]`/`[api-doc]` \ - flags are from a Candid probe of the app's own canisters. Confirm an interface \ - with get_canister_candid before calling.", - ); - if let Some(access) = data_access_note(&discovered, None) { - out.push_str(&format!("\n\n{access}")); - } - let output = discover::DiscoverOutput { - domain, - canisters: discovered, - omitted: d.omitted, - }; - Ok(ok_structured(out, &output)) - } - Ok(d) => { - let mut text = - format!("No IC canisters found for {domain} — is it served from the Internet Computer?"); - if let Some(m) = discover::similar_known_app(&domain) { - text.push_str(&format!( - " DID YOU MEAN {}? Its real URL is {} (derivation origin {}).", - m.name, m.app_url, m.derivation_origin - )); - } - text.push_str( - " If you GUESSED this domain from an app name, don't guess again: call \ - icp_find_app_by_name with the name, or WEB SEARCH the app's official URL, \ - or ask the user for it.", - ); - let output = discover::DiscoverOutput::from((domain, d)); - Ok(ok_structured(text, &output)) - } - // A fetch failure on a guessed domain (DNS-dead lookalikes like - // "multi.dex") carries the same repair as the identity routes, so it - // reads as "wrong URL — here's the real one", not a transient error. - Err(e) => Ok(err(app_url_error_with_guidance(&domain, e))), - } - } - - #[tool( - description = "Find Internet Computer canisters by NAME. Searches the IC dashboard's service registries — the ICRC token ledgers (e.g. ckBTC, ckETH, ckUSDC, SNS tokens) by symbol/name, and the SNS project catalog by name — and returns matching canister ids. Use this when the user names a token, project, or service (e.g. \"ckUSDC\") rather than a canister id; then confirm with get_canister_candid, read with canister_query, and write with canister_update_call. (No public name-search exists over arbitrary canisters; this covers the IC's labelled services.)", - annotations(title = "Find canisters by name", read_only_hint = true, destructive_hint = false, open_world_hint = true), - output_schema = schema_for_output::(), - )] - async fn icp_find_canister_by_name( - &self, - Parameters(discover::FindCanisterArgs { query }): Parameters, - ) -> Result { - match discover::search_by_name(&query).await { - Ok(matches) if !matches.is_empty() => { - let mut out = format!("Canisters matching \"{query}\":\n"); - for m in &matches { - out.push_str(&format!( - "- {} — {} [{}]{}\n", - m.canister_id, - m.name, - m.kind, - m.note.as_deref().map(|n| format!(" — {n}")).unwrap_or_default(), - )); - } - out.push_str( - "\nConfirm an interface with get_canister_candid, then read with canister_query and \ - write with canister_update_call. \ - For an SNS match the id is the project root — icp_lookup_canister_info_by_id it to learn more.", - ); - let output = discover::FindCanisterOutput::from((query, matches)); - Ok(ok_structured(out, &output)) - } - Ok(_) => { - let text = format!( - "No named canisters found matching \"{query}\". This searches known tokens (ICRC \ - ledgers) and SNS projects, so an arbitrary canister won't appear unless it's a \ - labelled service. If you have a website, try discover_app_canisters; if you already \ - have a canister id, try icp_lookup_canister_info_by_id or get_canister_candid." - ); - let output = discover::FindCanisterOutput::from((query, Vec::new())); - Ok(ok_structured(text, &output)) - } - Err(e) => Ok(err(e)), - } - } - - #[tool( - description = "Find a well-known Internet Computer APP by NAME and get its front-end URL and Internet Identity derivation origin — the missing name→app step (discovery otherwise needs a URL). ALWAYS call this FIRST when the user names an app and you don't have its URL — NEVER guess a domain from the name (lookalike domains like .com/.app are unrelated or squatted sites, and the URL-taking tools refuse origins with no IC evidence). Offline and instant. Covers only a small built-in set (NNS, Oisy, MULTI/DEX, ICPSwap). For ANY other app there is NO on-chain name→URL directory, so the result's `note` directs you to WEB SEARCH the app's official URL (or ask the user) and then use resolve_app / discover_app_canisters. Returns `matches` (each with `app_url` + `derivation_origin`) — usually one, or none. This is name→app; for a token/service→canister-id use icp_find_canister_by_name, and for a URL→canisters use discover_app_canisters.", - annotations(title = "Find a well-known app by name", read_only_hint = true, destructive_hint = false, open_world_hint = false), - output_schema = schema_for_output::(), - )] - async fn icp_find_app_by_name( - &self, - Parameters(discover::FindAppArgs { name }): Parameters, - ) -> Result { - let output = discover::find_app_by_name(&name); - let mut text = String::new(); - for m in &output.matches { - text.push_str(&format!( - "{} — {} (derivation_origin: {})\n", - m.name, m.app_url, m.derivation_origin - )); - } - text.push_str(&output.note); - Ok(ok_structured(text, &output)) - } - - #[tool( - description = "Identify what an Internet Computer canister IS, from the IC dashboard: its label/name (e.g. \"ICP Ledger\"), type (e.g. \"ledger\"), controllers, hosting subnet, module hash, language, and latest upgrade proposal. Use this to make sense of a bare canister id — e.g. one returned by discover_app_canisters.", - annotations(title = "Identify a canister", read_only_hint = true, destructive_hint = false, open_world_hint = true), - output_schema = schema_for_output::(), - )] - async fn icp_lookup_canister_info_by_id( - &self, - Parameters(discover::LookupCanisterArgs { canister_id }): Parameters, - ) -> Result { - let client = match discover::http_client() { - Ok(c) => c, - Err(e) => return Ok(err(e)), - }; - match discover::lookup_canister(&client, &canister_id).await { - Ok(info) => { - let text = format_canister_info(&info); - let output = discover::CanisterIdentityOutput::from(info); - Ok(ok_structured(text, &output)) - } - Err(e) => Ok(err(e)), - } - } - - // ---- ICP skills awareness ---------------------------------------------- - - #[tool( - description = "List the official Internet Computer skills — authoritative how-to guides for authoring and shipping IC apps (Motoko language, mops/icp CLIs, cycles management, stable memory & upgrades, security, DeFi, auth, …). Returns each skill's name and a one-line description. Load a skill's full instructions with icp_get_skill(name). Consult these BEFORE writing Motoko/Rust canister code, building, or deploying.", - annotations(title = "List Internet Computer skills", read_only_hint = true, destructive_hint = false, open_world_hint = false), - output_schema = schema_for_output::(), - )] - async fn icp_list_skills( - &self, - Parameters(_args): Parameters, - ) -> Result { - match self.skills.list().await { - Ok(s) => { - let text = skills::SkillsCatalog::format_list(&s); - let output = skills::SkillsOutput::from(s); - Ok(ok_structured(text, &output)) - } - Err(e) => Ok(err(e)), - } - } - - #[tool( - description = "Fetch the full instructions (SKILL.md) of one Internet Computer skill by name (e.g. \"motoko\", \"icp-cli\", \"mops-cli\", \"cycles-management\", \"stable-memory\", \"canister-security\"). Call icp_list_skills first to see the available names. Use this to learn the exact, current way to do an IC task before doing it.", - annotations(title = "Get an Internet Computer skill", read_only_hint = true, destructive_hint = false, open_world_hint = false), - output_schema = schema_for_output::(), - )] - async fn icp_get_skill( - &self, - Parameters(skills::GetSkillArgs { name }): Parameters, - ) -> Result { - match self.skills.get(&name).await { - Ok(md) => { - let output = skills::SkillOutput { name, content: md }; - Ok(ok_structured(output.content.clone(), &output)) - } - Err(e) => Ok(err(e)), - } - } - - // ---- Canister creation & management (as your standing II principal) ----- - - #[tool( - description = "Your cycles-ledger balance — the cycles that icp_create_canister and icp_top_up_canister spend. Acts as your Internet Identity principal (also printed). If it's empty, fund it first (e.g. via the icp CLI / cycles-management skill). Requires an authenticated session.", - annotations(title = "Check your cycles balance", read_only_hint = true, destructive_hint = false, open_world_hint = true), - output_schema = schema_for_output::(), - )] - async fn icp_cycles_balance( - &self, - Parameters(_args): Parameters, - ctx: RequestContext, - ) -> Result { - let sid = match authed_session(&ctx) { - Some(s) => s.session_id, - None => return Ok(err("checking your cycles balance needs an authenticated session".into())), - }; - match management::cycles_balance(&self.identities, &sid).await { - Ok(b) => Ok(ok_structured(b.human(), &b)), - Err(e) => Ok(err(e)), - } - } - - #[tool( - description = "Create and fund a NEW Internet Computer canister (as your Internet Identity). Fund it EITHER with `cycles` (exact, drawn from your cycles-ledger balance) OR with `icp` (a decimal-ICP string like \"0.5\", transferred from your ICP-ledger account and converted to cycles via the CMC). BOTH accounts belong to your management principal — the same principal icp_cycles_balance reports (its default subaccount); check/fund it before calling (cycles-ledger balance via icp_cycles_balance, or hold ICP in that principal's ICP-ledger account). The ICP path is best-effort with no retries: if the ICP transfer lands but the mint fails, the error carries the block index to recover with — do not blindly re-run. `cycles` wins if both are given. Controllers default to your own principal. Returns the new canister id — then build your Wasm (see the motoko/icp-cli skills) and install it with icp_install_code. Requires an authenticated session.", - annotations(title = "Create a canister", read_only_hint = false, destructive_hint = false, idempotent_hint = false, open_world_hint = true), - output_schema = schema_for_output::(), - )] - async fn icp_create_canister( - &self, - Parameters(args): Parameters, - ctx: RequestContext, - ) -> Result { - let sid = match authed_session(&ctx) { - Some(s) => s.session_id, - None => return Ok(err("creating a canister needs an authenticated session".into())), - }; - match management::create_canister(&self.identities, &sid, args).await { - Ok(c) => Ok(ok_structured(c.human(), &c)), - Err(e) => Ok(err(e)), - } - } - - #[tool( - description = "Add cycles to an existing canister (as your Internet Identity). Fund EITHER with `cycles` (exact, drawn from your cycles-ledger balance) OR with `icp` (a decimal-ICP string, transferred from your ICP-ledger account and converted via the CMC straight into the target canister). Both accounts belong to your management principal — the one icp_cycles_balance reports (default subaccount). The ICP path is best-effort with no retries: if the transfer lands but the mint fails, the error carries the block index to recover with — do not blindly re-run. `cycles` wins if both are given. Requires an authenticated session.", - annotations(title = "Top up a canister", read_only_hint = false, destructive_hint = false, idempotent_hint = false, open_world_hint = true), - output_schema = schema_for_output::(), - )] - async fn icp_top_up_canister( - &self, - Parameters(args): Parameters, - ctx: RequestContext, - ) -> Result { - let sid = match authed_session(&ctx) { - Some(s) => s.session_id, - None => return Ok(err("topping up a canister needs an authenticated session".into())), - }; - let canister_id = args.canister_id.clone(); - Ok(ok_canister_action( - canister_id, - management::top_up_canister(&self.identities, &sid, args).await, - )) - } - - #[tool( - description = "Install a compiled Wasm module on a canister you control (as your Internet Identity). Provide the module as `wasm_base64` (or `wasm_hex`); large modules are uploaded via the chunk store automatically. `mode` is \"install\" (default, empty canister), \"reinstall\" (wipe state), or \"upgrade\" (preserve stable memory). `arg` is the init/upgrade argument in textual Candid, e.g. \"()\". Build the Wasm in your own environment first (see the motoko / icp-cli / mops-cli skills). Requires an authenticated session.", - annotations(title = "Install code on a canister", read_only_hint = false, destructive_hint = true, idempotent_hint = false, open_world_hint = true), - output_schema = schema_for_output::(), - )] - async fn icp_install_code( - &self, - Parameters(args): Parameters, - ctx: RequestContext, - ) -> Result { - let sid = match authed_session(&ctx) { - Some(s) => s.session_id, - None => return Ok(err("installing code needs an authenticated session".into())), - }; - let canister_id = args.canister_id.clone(); - Ok(ok_canister_action( - canister_id, - management::install_code(&self.identities, &sid, args).await, - )) - } - - #[tool( - description = "Report a canister's status: run state, cycle balance, module hash, memory size, controllers, and allocations. Controller-only (acts as your Internet Identity). This only READS status (it changes nothing), but on the IC it is an update call, so it needs a full (non-read-only) Internet Identity session. Requires an authenticated session.", - annotations(title = "Get canister status", read_only_hint = true, destructive_hint = false, open_world_hint = true), - output_schema = schema_for_output::(), - )] - async fn icp_canister_status( - &self, - Parameters(args): Parameters, - ctx: RequestContext, - ) -> Result { - let sid = match authed_session(&ctx) { - Some(s) => s.session_id, - None => return Ok(err("reading canister status needs an authenticated session".into())), - }; - let canister_id = args.canister_id.clone(); - Ok(ok_canister_action( - canister_id, - management::canister_status(&self.identities, &sid, args).await, - )) - } - - #[tool( - description = "Update a canister's settings: controllers, compute/memory allocation, freezing threshold, reserved-cycles limit, wasm memory limit, or log visibility (\"controllers\"|\"public\"). Only the fields you pass are changed. WARNING: passing `controllers` REPLACES the whole set — include your own principal to remain a controller. Requires an authenticated session.", - annotations(title = "Update canister settings", read_only_hint = false, destructive_hint = true, idempotent_hint = true, open_world_hint = true), - output_schema = schema_for_output::(), - )] - async fn icp_update_canister_settings( - &self, - Parameters(args): Parameters, - ctx: RequestContext, - ) -> Result { - let sid = match authed_session(&ctx) { - Some(s) => s.session_id, - None => return Ok(err("updating settings needs an authenticated session".into())), - }; - let canister_id = args.canister_id.clone(); - Ok(ok_canister_action( - canister_id, - management::update_canister_settings(&self.identities, &sid, args).await, - )) - } - - #[tool( - description = "Start a stopped canister you control. Requires an authenticated session.", - annotations(title = "Start a canister", read_only_hint = false, destructive_hint = false, idempotent_hint = true, open_world_hint = true), - output_schema = schema_for_output::(), - )] - async fn icp_start_canister( - &self, - Parameters(management::CanisterRefArgs { canister_id }): Parameters, - ctx: RequestContext, - ) -> Result { - let sid = match authed_session(&ctx) { - Some(s) => s.session_id, - None => return Ok(err("starting a canister needs an authenticated session".into())), - }; - let result = management::start_canister(&self.identities, &sid, &canister_id).await; - Ok(ok_canister_action(canister_id, result)) - } - - #[tool( - description = "Stop a running canister you control (required before deleting it). Requires an authenticated session.", - annotations(title = "Stop a canister", read_only_hint = false, destructive_hint = false, idempotent_hint = true, open_world_hint = true), - output_schema = schema_for_output::(), - )] - async fn icp_stop_canister( - &self, - Parameters(management::CanisterRefArgs { canister_id }): Parameters, - ctx: RequestContext, - ) -> Result { - let sid = match authed_session(&ctx) { - Some(s) => s.session_id, - None => return Ok(err("stopping a canister needs an authenticated session".into())), - }; - let result = management::stop_canister(&self.identities, &sid, &canister_id).await; - Ok(ok_canister_action(canister_id, result)) - } - - #[tool( - description = "Remove a canister's code and state, leaving it empty (it keeps its id and cycles). Acts as your Internet Identity. Requires an authenticated session.", - annotations(title = "Uninstall code from a canister", read_only_hint = false, destructive_hint = true, idempotent_hint = true, open_world_hint = true), - output_schema = schema_for_output::(), - )] - async fn icp_uninstall_code( - &self, - Parameters(management::CanisterRefArgs { canister_id }): Parameters, - ctx: RequestContext, - ) -> Result { - let sid = match authed_session(&ctx) { - Some(s) => s.session_id, - None => return Ok(err("uninstalling code needs an authenticated session".into())), - }; - let result = management::uninstall_code(&self.identities, &sid, &canister_id).await; - Ok(ok_canister_action(canister_id, result)) - } - - #[tool( - description = "Delete a canister permanently (irreversible — stop it first; remaining cycles are burned). Acts as your Internet Identity. Requires an authenticated session.", - annotations(title = "Delete a canister", read_only_hint = false, destructive_hint = true, idempotent_hint = false, open_world_hint = true), - output_schema = schema_for_output::(), - )] - async fn icp_delete_canister( - &self, - Parameters(management::CanisterRefArgs { canister_id }): Parameters, - ctx: RequestContext, - ) -> Result { - let sid = match authed_session(&ctx) { - Some(s) => s.session_id, - None => return Ok(err("deleting a canister needs an authenticated session".into())), - }; - let result = management::delete_canister(&self.identities, &sid, &canister_id).await; - Ok(ok_canister_action(canister_id, result)) - } -} - -/// The authenticated MCP session of the calling request, if it carried a valid -/// bearer token (injected by [`auth::require_token`]). -fn authed_session(ctx: &RequestContext) -> Option { - ctx.extensions - .get::() - .and_then(|parts| parts.extensions.get::()) - .cloned() -} - -/// The rejection for an OQL read (`get_canister_oql_schema`, or `canister_query`'s -/// `oql` path) attempted with NO derivation origin. OQL reads per-app data gated by -/// the caller's principal, so an anonymous read is signed as 2vxsx-fae and returns -/// nothing useful — so, for the time being, we reject it outright rather than let it -/// silently come back empty. `what` names the action. Points at open_app / -/// resolve_app so the agent recovers the origin in one step. -fn oql_needs_origin_error(what: &str) -> String { - format!( - "{what} reads per-app data that this canister gates by the CALLER's principal, so it \ - cannot be done anonymously — pass `derivation_origin`, the app's canonical Internet \ - Identity origin, to read as your account. If you don't have it, resolve it from the \ - app's URL or name with open_app / resolve_app, then retry. (Anonymous per-app reads are \ - disabled for now; public metadata such as get_canister_candid still works without an origin.)" - ) -} - -/// The diagnosis attached to an EMPTY / trapped OQL query result (#1 + #7). -struct OqlEmptyDiagnosis { - /// The human-readable explanation of the empty result, if one applies. - note: Option, - /// The entities visible to this caller, when an unknown-`start` diagnosis fired. - valid_entities: Option>, - /// The nearest valid entity to an unknown `start` (`booking` → `bookings`). - did_you_mean: Option, -} - -impl OqlEmptyDiagnosis { - fn none() -> Self { - Self { note: None, valid_entities: None, did_you_mean: None } - } -} - -/// Why `diagnose_empty_oql` was called — a genuinely EMPTY result (0-row table) -/// vs. a FAILED one (trap / OQL error arm). The `start`-entity repair applies to -/// both, but the "came back empty ⇒ likely not authenticated" / "no entities here" -/// notes describe an EMPTY result and would misdescribe a failure that already -/// carries its own error message, so they are emitted for `EmptyResult` only. -#[derive(Clone, Copy, PartialEq)] -enum EmptyContext { - EmptyResult, - Failed, -} - -/// Diagnose why an OQL read came back EMPTY (0 rows) or FAILED (trap / error arm), -/// using ONLY facts gathered from the SAME agent/principal that ran the query — -/// never probing whether some OTHER principal would see data (no leak). This is -/// validate-on-empty, not an unconditional preflight: it runs a single extra -/// `schema` read for this principal and hard-validates the query's `start` entity -/// (only `start` — field and dotted-edge paths are treated advisorily by the caller). -/// -/// - `start` isn't one of the visible entities → the #7 unknown-entity repair -/// (valid entities + a "did you mean?" near match). Emitted in BOTH contexts — -/// an unknown `start` is the common reason a query traps. -/// - The notes below describe an EMPTY RESULT and are emitted for `EmptyResult` -/// only (a `Failed` result keeps its own error message; "came back empty" would -/// misdescribe it): -/// - Anonymous + empty → the #1 auth remediation (empty is almost certainly -/// "not authenticated as your account", not "no data"). -/// - Authenticated + the schema read SUCCEEDED and shows no entities → this -/// account sees no entities here. (A schema read that FAILED is left -/// undiagnosed — the caller falls back to a benign "0 rows" note — rather -/// than mislabeled "no entities".) -/// - `start` valid but 0 rows → authenticated gets no note here (the caller -/// adds the benign "0 rows"); anonymous still gets the auth hint. -async fn diagnose_empty_oql( - agent: &Agent, - principal: Principal, - query_json: &str, - is_anonymous: bool, - ctx: EmptyContext, -) -> OqlEmptyDiagnosis { - // The derivation_origin the caller would ADD to authenticate — a placeholder we - // never fill with a guessed origin (#1). - const ADD_HINT: &str = "the app's `derivation_origin` (its canonical Internet Identity origin)"; - // Re-read the schema for THIS principal (same agent). `None` = the read FAILED - // (unknown, not "empty"); `Some(vec)` = it succeeded (possibly with no entities). - // Keeping the two apart stops a transient schema failure from being mislabeled - // "this account sees no entities". - let entities: Option> = match calls::encode_unit_arg() { - Ok(arg) => match calls::raw_call(agent, principal, "schema", arg, true).await { - Ok(reply) => Some(calls::oql_entity_names(&calls::decode_schema_reply(&reply))), - Err(_) => None, - }, - Err(_) => None, - }; - let start = calls::oql_query_start(query_json); - let mut d = OqlEmptyDiagnosis::none(); - - // #7 unknown-`start` repair — needs a successful, non-empty schema. Applies in - // BOTH contexts (an unknown entity is exactly what makes a query trap). - if let Some(ents) = entities.as_ref().filter(|e| !e.is_empty()) { - if let Some(s) = start.as_deref().filter(|s| !ents.iter().any(|e| e == s)) { - d.did_you_mean = calls::closest_entity(s, ents); - let dym = d - .did_you_mean - .as_deref() - .map(|m| format!(" Did you mean \"{m}\"?")) - .unwrap_or_default(); - d.note = Some(format!( - "`start`: \"{s}\" is not a queryable entity on this canister. Valid entities \ - (visible to this caller): {}.{dym}", - ents.join(", ") - )); - d.valid_entities = Some(ents.clone()); - return d; - } - } - - // The remaining notes describe an EMPTY result — skip them on a failure, whose - // own error message stands, and appending "came back empty" would contradict it. - if ctx == EmptyContext::Failed { - return d; - } - - if is_anonymous { - // Anonymous + empty (schema empty, unreadable, or `start` valid) → auth hint. - d.note = Some(calls::anonymous_empty_note("this query", ADD_HINT)); - } else if matches!(entities.as_deref(), Some([])) { - // Authenticated AND the schema read SUCCEEDED with no entities — a genuine - // "nothing visible here" (not a failed re-read mislabeled as empty). - d.note = Some( - "This account sees no OQL entities on this canister, so there is nothing to query as \ - it — confirm the derivation_origin/account are the ones the user uses in their browser." - .to_string(), - ); - } - // Otherwise (authenticated: `start` valid, OR the schema read failed): no - // actionable diagnosis — leave `note` None so the caller adds the benign - // "0 rows for this account" on an empty table. - d -} - -/// Which app principal an identity-bearing tool should act as, resolved from the -/// caller's `derivation_origin`. -#[derive(Debug)] -struct IdentityTarget { - /// The EFFECTIVE (canonical) Internet Identity derivation origin to feed the - /// delegation layer — echoed to the caller as `derived_for_origin`. - origin: String, - /// Exactly what the caller supplied as `derivation_origin`. - requested: String, - /// How `origin` was determined. Always "explicit" here: the identity-bearing - /// tools take the canonical derivation origin DIRECTLY (they no longer accept an - /// `app_url` to resolve). The "declared"/"known"/"app_url_default" sources come - /// from the resolver tools (`open_app` / `resolve_app`), where a URL is turned - /// into a derivation origin under the guessed-domain gate; feed their result - /// here. Kept for parity with those tools' echoed `derivation_origin_source`. - source: String, -} - -/// Resolve the caller's `derivation_origin` into an [`IdentityTarget`] (or `None` -/// when it's absent — an anonymous call). The origin is canonicalized and used -/// VERBATIM (source `explicit`): these tools trust the caller to supply the exact -/// canonical origin Internet Identity derives against. There is deliberately no -/// `app_url` here — a derivation origin is a stable per-app value, so it's resolved -/// ONCE via `open_app` / `resolve_app` (which run the guessed-domain gate and return -/// it) and then reused across calls, rather than re-resolved on every invocation -/// against this stateless server. -fn resolve_identity_target( - derivation_origin: Option, -) -> Result, String> { - match derivation_origin { - None => Ok(None), - Some(d) => { - let d = clean_identity_arg("derivation_origin", &d)?; - let origin = canonicalize_derivation_origin(&d)?; - Ok(Some(IdentityTarget { - origin, - requested: d, - source: "explicit".to_string(), - })) - } - } -} - -/// Append guess-repair guidance to an `app_url` resolution failure (DNS, TLS, -/// timeout, SSRF refusal): a fetch error on a domain FABRICATED from an app name -/// (e.g. "multi.dex") must redirect the caller to the name→URL tools — the same -/// way the no-IC-evidence refusal does — rather than read as a transient error -/// inviting the next guess. -fn app_url_error_with_guidance(app_url: &str, e: String) -> String { - let mut msg = e; - if let Some(m) = discover::similar_known_app(app_url) { - msg.push_str(&format!( - " DID YOU MEAN {}? Its real URL is {} (derivation origin {}).", - m.name, m.app_url, m.derivation_origin - )); - } - msg.push_str( - " If you arrived at this URL by guessing from an app name, don't guess again — call \ - icp_find_app_by_name with the name, WEB SEARCH the app's official URL, or ask the user.", - ); - msg -} - -/// The refusal text for an `app_url` that resolved to `app_url_default` with NO -/// evidence of being served from the Internet Computer — almost always a domain -/// GUESSED from an app name (e.g. "multidex.com" for MULTI/DEX, whose real URL is -/// multidex.ai). Rather than resolving the guess to a plausible-but-wrong identity, -/// tools return this error, which (a) names the failure, (b) suggests the -/// well-known app the host resembles when there is one ("did you mean …"), and -/// (c) spells out the legitimate ways to obtain the URL — so the caller converges -/// on the real app instead of guessing again. -fn unverified_app_url_error(application_origin: &str) -> String { - let mut msg = format!( - "{application_origin} is reachable but shows NO evidence of being an Internet Computer \ - app — no valid `x-ic-canister-id` gateway header (the IC HTTP gateway sets one on every \ - response) — and its /.well-known/ic-app.json couldn't be fetched or declares no Internet \ - Identity derivation origin. Refusing to treat it as an app. " - ); - if let Some(m) = discover::similar_known_app(application_origin) { - msg.push_str(&format!( - "DID YOU MEAN {}? Its real URL is {} (derivation origin {}) — use that instead. ", - m.name, m.app_url, m.derivation_origin - )); - } - msg.push_str( - "If you arrived at this URL by GUESSING from an app name, stop guessing — a lookalike \ - domain is an unrelated or squatted site, and an identity derived there is wrong: \ - (1) call icp_find_app_by_name with the app's name (well-known apps resolve offline); \ - (2) if it's not known, WEB SEARCH the app's official URL; (3) or ask the user for it. \ - If this origin genuinely IS the app's canonical Internet Identity origin (e.g. a \ - non-IC-hosted app the user pointed you at), pass it explicitly as `derivation_origin` \ - to proceed deliberately.", - ); - msg -} - -/// The human `note` explaining how a resolved derivation origin was determined, -/// shared by `resolve_app` and `open_app` so the two stay in lock-step. `None` for -/// a Declared origin (self-evident); a Known origin explains the built-in registry; -/// an AppUrlDefault origin flags the assumption and, when the host resembles a -/// well-known app, appends a lookalike CAUTION (an IC-hosted squat can pass the -/// evidence gate yet still not be the app it mimics). `effective` is the -/// canonicalized derivation origin (`target_origin`). -fn resolution_note(resolved: &discover::AppIdentity, effective: &str) -> Option { - let lookalike = discover::similar_known_app(&resolved.application_origin).map(|m| { - format!( - " CAUTION: this host RESEMBLES the well-known app {} but is not one of its real \ - origins — {}'s URL is {} (derivation origin {}). If you meant {}, use that URL; \ - if you guessed this URL from the app's name, use icp_find_app_by_name instead of \ - guessing.", - m.name, m.name, m.app_url, m.derivation_origin, m.name - ) - }); - match resolved.derivation_origin_source { - discover::DerivationSource::Declared => None, - discover::DerivationSource::Known => Some(format!( - "This app didn't declare a derivation origin in /.well-known/ic-app.json, but it's \ - a known app that pins a custom one, so this used the built-in value {effective}. \ - The app's own declaration, if it ships one, would override this." - )), - discover::DerivationSource::AppUrlDefault => Some(format!( - "This origin showed evidence of being served from the Internet Computer (its \ - responses carry the gateway's `x-ic-canister-id` header), but its \ - /.well-known/ic-app.json couldn't be fetched or declares no `derivation_origin`, \ - and it isn't in the built-in known-app registry — so this ASSUMED the application \ - origin, canonicalized to {effective} (what II derives against). That is correct \ - for apps without a custom derivation origin; if this app pins a custom one, the \ - assumption yields a WRONG principal — supply the canonical origin explicitly.{}", - lookalike.as_deref().unwrap_or_default() - )), - } -} - -/// Validate a user-supplied identity argument (`derivation_origin` / `app_url`): -/// trim surrounding whitespace, reject an empty/whitespace-only value, and reject -/// ASCII/Unicode control characters. Both fields are echoed back verbatim (as -/// `requested` / `derived_for_origin`) and `derivation_origin` feeds the delegation -/// origin, so a stray control char could corrupt a log line or the origin string — -/// fail closed rather than pass it through. -fn clean_identity_arg(field: &str, raw: &str) -> Result { - let v = raw.trim(); - if v.is_empty() { - return Err(format!("`{field}` must not be empty")); - } - if v.chars().any(char::is_control) { - return Err(format!("`{field}` must not contain control characters")); - } - Ok(v.to_string()) -} - -/// Canonicalize a cleaned `derivation_origin` to the effective origin the delegation -/// path derives against (`target_origin`), rejecting one that carries no host (e.g. -/// "https://" → `https://`) — which would otherwise derive against, and echo back, -/// an empty origin. -fn canonicalize_derivation_origin(cleaned: &str) -> Result { - let invalid = || { - "`derivation_origin` must be an https origin or a bare host, with no user-info \ - (e.g. https://app.example.com or app.example.com)" - .to_string() - }; - // Reject any explicit scheme other than https (a bare host with no scheme is - // fine — target_origin prepends https). Without this, target_origin would - // rewrite a non-https scheme into a different origin than requested — `ftp://x` - // mangled into a bogus `https://…`, or `http://x` silently upgraded to - // `https://x` — contradicting the https-only contract and confusing debugging. - // A bare host with no scheme is fine — target_origin prepends https. An explicit - // scheme must be https, and we lowercase it before handing off: target_origin strips - // only a lowercase `https://`, so an uppercase `HTTPS://` would otherwise survive - // into the host and mangle the origin (e.g. `https://HTTPS:`), turning a valid input - // into a rejection or a bogus origin. Rejecting a non-https scheme here also stops - // target_origin from silently rewriting it (`ftp://x` → bogus `https://…`, `http://x` - // → `https://x`), contradicting the https-only contract. - let normalized = match cleaned.split_once("://") { - Some((scheme, rest)) => { - if !scheme.eq_ignore_ascii_case("https") { - return Err(invalid()); - } - format!("https://{rest}") - } - None => cleaned.to_string(), - }; - let origin = identities::target_origin(&normalized); - // Require the canonical origin to parse as an https URL with a real host and NO - // user-info. `target_origin` keeps any `user@` prefix, but a browser origin never - // has one, so `https://user@host` would derive a different principal than - // `https://host` while `requested == derived_for_origin` hides the mismatch. This - // also rejects a host-less input ("https://") and embedded spaces/invalid chars. - let parsed = url::Url::parse(&origin).ok().filter(|u| { - u.scheme() == "https" - && u.host_str().map_or(false, |h| !h.is_empty()) - && u.username().is_empty() - && u.password().is_none() - }); - match parsed { - // Reserialize to a canonical ASCII origin (lowercased host, no path/user-info), - // exactly like the `app_url` path (`Url::origin().ascii_serialization()`), so a - // mixed-case host echoes identically and hits the same delegation-cache key - // instead of forking `https://Example.com` vs `https://example.com`. - Some(u) if u.origin().is_tuple() => Ok(u.origin().ascii_serialization()), - _ => Err(invalid()), - } -} - -/// Clean a user-supplied `app_url`: the shared identity-arg checks, plus reject an -/// explicit non-https scheme up front. The connector only ever fetches https -/// discovery targets (the SSRF guard refuses anything else), so an `http://` URL -/// would otherwise fail with a late, indirect error — reject it here with a clear -/// message. A bare host (no scheme) is fine; `resolve_app_identity` prepends https. -fn clean_app_url(raw: &str) -> Result { - let u = clean_identity_arg("app_url", raw)?; - if let Some((scheme, _)) = u.split_once("://") { - if !scheme.eq_ignore_ascii_case("https") { - return Err( - "`app_url` must be an https URL or a bare host (the connector only fetches https origins)" - .to_string(), - ); - } - } - // Require a real host and NO user-info, matching `canonicalize_derivation_origin`. - // `resolve_app_identity` prepends https to a bare host and derives the application - // origin via `Url::origin()`, which silently DROPS any `user:pass@` — so - // `https://user@host` would resolve to `https://host` (differing from what the - // caller supplied) and `https://` would yield no host. Reject both up front with a - // clear message instead of a late, indirect downstream failure. - let candidate = if u.contains("://") { u.clone() } else { format!("https://{u}") }; - let valid = url::Url::parse(&candidate).ok().map_or(false, |parsed| { - parsed.scheme() == "https" - && parsed.host_str().map_or(false, |h| !h.is_empty()) - && parsed.username().is_empty() - && parsed.password().is_none() - }); - if !valid { - return Err( - "`app_url` must be an https URL or bare host with a real host and no user-info \ - (e.g. https://app.example.com or app.example.com)" - .to_string(), - ); - } - Ok(u) -} - -/// A one-line identity annotation for the human-readable `text` (which text-only -/// clients see instead of the structured output): the origin II derived against, -/// how it was determined (`source`, always "explicit" for the identity-bearing -/// tools now that they take the canonical origin directly), and — whenever it -/// differs from the derived origin (canonicalization, http→https, a stripped path) -/// — the caller's `requested` value, so a requested≠derived mismatch stays visible -/// in every client. `acted_as` prefixes the signed-as principal when known. -fn identity_annotation(target: &IdentityTarget, acted_as: Option<&str>) -> String { - let mut s = String::new(); - if let Some(p) = acted_as { - s.push_str(&format!("signed as {p} — ")); - } - s.push_str(&format!("derived for {} (source: {})", target.origin, target.source)); - if target.requested != target.origin { - s.push_str(&format!("; requested {}", target.requested)); - } - s +/// Public base URL clients use to reach this server. Override with PUBLIC_URL. +fn public_url() -> String { + std::env::var("PUBLIC_URL").unwrap_or_else(|_| "http://localhost:8000".to_string()) } /// Log each inbound request: method, path, response status, and latency — gives @@ -1935,403 +48,6 @@ async fn log_request( resp } -#[tool_handler] -impl ServerHandler for IcTools { - fn get_info(&self) -> ServerInfo { - ServerInfo::new( - ServerCapabilities::builder().enable_tools().enable_resources().build(), - ) - .with_server_info(Implementation::from_build_env()) - .with_instructions( - "Internet Computer tools. Every tool speaks TEXTUAL Candid — the `(...)` value \ - syntax, e.g. `(record { owner = principal \"aaaaa-aa\"; amount = 5 : nat })`, never \ - the binary form. Tool names signal SCOPE: an `icp_` prefix marks IC protocol / \ - meta-level tools (dashboard name/id lookups, the official IC skills, the OQL dialect \ - guide, and canister creation/management); `…_app…` names \ - (`open_app`, `discover_app_canisters`, `get_app_principal`, `list_app_accounts`, `resolve_app`) \ - act on a whole APP, keyed by its Internet Identity derivation origin or app URL; and \ - `…canister…` names (`get_canister_candid`, `get_canister_api_doc`, \ - `get_canister_oql_schema`, `canister_query`, `canister_update_call`) act on ONE \ - specific canister. Before writing Candid args, consult the `candid://textual-syntax` \ - resource (the value syntax these tools use); `candid://reference` has the full type \ - reference.\n\n\ - START WITH open_app WHEN THE USER NAMES OR LINKS AN APP. `open_app(name-or-URL)` is the \ - one-call entry point: it takes an app NAME (e.g. \"MULTI/DEX\", \"Oisy\", \"NNS\") or a \ - URL, resolves the Internet Identity derivation origin AND discovers the canisters \ - together, probes the app's own canisters for their `oql`/`api_doc_available` \ - capabilities, and repairs a wrong-TLD guess to the canonical known-app URL — so you do \ - NOT chain resolve_app + discover_app_canisters yourself, and you do NOT start from \ - discover_app_canisters. (open_app bundles `resolve_app` + `discover_app_canisters`; use \ - those directly only for a single step.) RULE — names are not URLs: NEVER guess or \ - fabricate a domain from an app's name (e.g. .com/.app); pass the NAME to open_app \ - (or `icp_find_app_by_name`) and let the connector resolve it, WEB SEARCH the official \ - URL, or ask the user. Lookalike domains are unrelated or squatted sites, and open_app / \ - every URL-taking tool REFUSES an origin that shows no evidence of being an Internet \ - Computer app instead of resolving it to a wrong identity. When the user names a TOKEN, \ - PROJECT or SERVICE (e.g. \"ckUSDC\"), use `icp_find_canister_by_name` for its canister \ - id; `icp_lookup_canister_info_by_id(id)` tells you what a bare canister id IS (dashboard \ - label, type, controllers, subnet).\n\n\ - \"MY / OUR …\" IS AN AUTHENTICATED READ. A question about the USER's OWN data in an app \ - (\"who am I meeting with…\", \"my bookings\", \"our balance\") reads data the app gates \ - by the CALLER's principal. An OQL read (get_canister_oql_schema, and canister_query \ - with the `oql` argument) REQUIRES the app's \ - `derivation_origin` (from open_app / resolve_app) — anonymous per-app reads are \ - disabled for now, so a call with no origin is REJECTED with guidance to pass it, rather \ - than silently returning empty. Authenticating never hurts a public read either — the \ - canister serves the request regardless of principal — so always pass the origin for app \ - data. (canister_query can still run a Candid `method` query anonymously for genuinely \ - public canisters like ledgers.)\n\n\ - INSPECTING A CANISTER. `get_canister_candid` fetches the interface and reports two \ - capability flags: `oql` and `api_doc_available` (open_app reports the same per \ - canister). If `oql: true`, READ the canister via OQL, in order: `icp_oql_guide` (the \ - JSON dialect, once) → `get_canister_oql_schema` (the entities and fields) → \ - `canister_query` with the `oql` argument \ - (run a JSON query, get a table). These wrap the canister's `schema`/`execute` methods, \ - so you never hand-encode Candid for OQL — and on an OQL canister a Candid `method` query \ - through canister_query is REJECTED (use `oql`; canister_update_call handles UPDATES). \ - Call `get_canister_api_doc` ONLY when `api_doc_available` is true: then it returns a \ - prose \"how this app behaves\" guide (units, auth, lifecycle, mutation safety, polling, \ - gotchas) the Candid types don't convey; when the flag is false the canister has no such \ - doc and the Candid types ARE the interface — don't call it.\n\n\ - `canister_query` (reads) and `canister_update_call` (writes) call a method with \ - textual Candid in/out: omit the identity args to call anonymously, or act AS your \ - account at an app. To act as an app account, identify the app by its \ - `derivation_origin` — the EXACT canonical origin Internet Identity derives its \ - principal from, which is NOT necessarily the visible website URL and must NEVER be \ - inferred from an ii-alternative-origins list. The identity-bearing tools \ - (canister_query, canister_update_call, get_app_principal, list_app_accounts, \ - get_canister_oql_schema) \ - take ONLY `derivation_origin`, NOT a website URL: a derivation origin is a stable \ - per-app value, so RESOLVE IT ONCE with `open_app` (or `resolve_app`) — which turn an \ - app name/URL into it under the guessed-domain gate — and reuse it across calls, rather \ - than re-resolving a URL every time on this stateless server. A short-lived (<=5 min) account \ - delegation is minted ON DEMAND from this connection's standing credential, no extra \ - sign-in. `get_app_principal` returns the principal without a call; `list_app_accounts` lists \ - the user's accounts (a default one plus any named ones), and canister_query / \ - canister_update_call / get_app_principal take an optional `account` (a name from that \ - list) — omit it for the default. Every identity result echoes `derived_for_origin` (the origin actually used) and \ - `requested` (what you passed), so a canonicalization mismatch is visible. If a principal, \ - account, or balance doesn't match what the user sees in their browser, the derivation \ - origin is wrong: re-resolve the app with `open_app`/`resolve_app` (don't guess an origin). The standing \ - credential is obtained when you connect \ - (authenticate via Internet Identity) and lasts for the session duration you choose when \ - connecting (up to 30 days); reconnect when it expires. \ - The session may be READ-ONLY (Internet Identity's consent screen defaults to read-only): \ - reads work, but the canister-management tools below make update calls the network \ - rejects for a read-only session — if one fails that way, ask the user to reconnect with \ - the read-only option turned OFF.\n\n\ - Typical flow (acting FOR THE USER at an app): (0-2) `open_app(name-or-URL)` in ONE \ - call gives the `derivation_origin` AND the app's canisters (with `oql`/`api_doc_available` \ - flags) — pass the NAME the user said (well-known apps NNS/Oisy/MULTI/DEX/ICPSwap resolve \ - offline) or a URL you have, NEVER a domain guessed from the name (there is no on-chain \ - name→URL directory; `icp_find_canister_by_name` finds token/SNS canister ids, not \ - front-ends). If you want just one part, `icp_find_app_by_name` does name→URL, \ - `resolve_app(url)` the origin, `discover_app_canisters(url)` the canisters; (3) \ - `list_app_accounts` — if there is more than one account, ask which to use and remember \ - it; (4) `get_app_principal` ONLY when you need the principal value itself (`canister_query` / \ - `canister_update_call` act as the account without pre-fetching it); (5) inspect the \ - canister with `get_canister_candid` — its `oql` flag says whether to read via OQL, \ - its `api_doc_available` flag whether `get_canister_api_doc` has a doc; (6) READ as the \ - user with `canister_query`, passing the `derivation_origin` (REQUIRED for OQL): use the \ - `oql` argument when `oql: true` (get the entity/field names from get_canister_oql_schema; \ - an anonymous OQL read is rejected for now, and a Candid `method` \ - query is REJECTED on an OQL canister), else a Candid `method` query; (7) ACT with \ - `canister_update_call`, passing `derivation_origin` + `account` to act as the \ - user. Public metadata (get_canister_candid, discover_app_canisters) and public \ - canister_query Candid `method` queries need no origin; OQL reads always require one. The \ - per-canister inspection (5) is independent of the identity steps (1/3/4), so they can \ - run in parallel. Managing your OWN canisters \ - (the `icp_` create/install/status/… tools) acts as your standing MANAGEMENT principal at \ - this server's origin — a DIFFERENT identity than the per-app principals above.\n\n\ - To AUTHOR, BUILD and DEPLOY IC code, first consult the official IC skills: \ - `icp_list_skills` lists them and `icp_get_skill(name)` loads one. Especially `motoko` \ - (language), `mops-cli` (deps/build), `icp-cli` (build & deploy), `cycles-management` \ - (ICP↔cycles & funding), `stable-memory` (upgrades) and `canister-security`. Compiling \ - Motoko/Rust to Wasm happens in YOUR environment (guided by these skills); these tools \ - then put it on chain. To CREATE and MANAGE canisters as your Internet Identity, use: \ - `icp_cycles_balance` (your cycles-ledger balance), `icp_create_canister` (create + fund from \ - that balance — amount in `cycles` or `icp`), `icp_install_code` (install your compiled \ - Wasm — base64 — single-shot or chunked), `icp_canister_status`, `icp_update_canister_settings`, \ - `icp_start_canister`/`icp_stop_canister`/`icp_uninstall_code`/`icp_delete_canister`, and \ - `icp_top_up_canister`. These act as your standing II principal, which must hold cycles in \ - the cycles ledger first (fund it via the icp CLI / cycles-management skill). So to \ - \"build X and deploy a canister with Y ICP worth of cycles\": read the relevant skills, \ - write & build the Wasm locally, `icp_create_canister(icp=Y)`, then `icp_install_code`." - .to_string(), - ) - } - - async fn list_resources( - &self, - _request: Option, - _ctx: RequestContext, - ) -> Result { - let mut resources = vec![ - RawResource::new(CANDID_TEXTUAL_URI, "Candid textual syntax (used by these tools)") - .no_annotation(), - RawResource::new(CANDID_REFERENCE_URI, "Candid type reference (full spec)") - .no_annotation(), - RawResource::new(OQL_USAGE_URI, "OQL query surface usage guide") - .no_annotation(), - ]; - // Surface the IC skills as resources too (best-effort: if the registry is - // unreachable, the candid resources above still list). Each `skill://` - // is read on demand in read_resource. - if let Ok(skills) = self.skills.list().await { - for s in skills { - let title = if s.title.is_empty() { - format!("IC skill: {}", s.name) - } else { - format!("IC skill: {}", s.title) - }; - resources.push( - RawResource::new(format!("{SKILL_URI_PREFIX}{}", s.name), title).no_annotation(), - ); - } - } - Ok(ListResourcesResult { - resources, - next_cursor: None, - meta: None, - }) - } - - async fn read_resource( - &self, - request: ReadResourceRequestParams, - _ctx: RequestContext, - ) -> Result { - // Skills are fetched live by name; the candid references are static. - if let Some(name) = request.uri.strip_prefix(SKILL_URI_PREFIX) { - return match self.skills.get(name).await { - Ok(md) => Ok(ReadResourceResult::new(vec![ResourceContents::text( - md, - request.uri, - )])), - Err(e) => Err(McpError::resource_not_found( - "resource_not_found", - Some(serde_json::json!({ "uri": request.uri, "error": e })), - )), - }; - } - let body = match request.uri.as_str() { - CANDID_TEXTUAL_URI => CANDID_TEXTUAL_MD, - CANDID_REFERENCE_URI => CANDID_REFERENCE_MD, - OQL_USAGE_URI => OQL_PRIMER_MD, - other => { - return Err(McpError::resource_not_found( - "resource_not_found", - Some(serde_json::json!({ "uri": other })), - )) - } - }; - Ok(ReadResourceResult::new(vec![ResourceContents::text( - body, - request.uri, - )])) - } -} - -/// One canister line for the open_app / discover_app_canisters text output: id, -/// discovery label, dashboard «name» (type), sources, and any capability flags -/// (`[oql]` / `[api-doc]`) filled in by [`IcTools::enrich_capabilities`]. Shared by -/// both tools so their listings stay identical. -fn render_canister_line(c: &discover::DiscoveredCanister) -> String { - let identity = match (&c.name, &c.kind) { - (Some(n), Some(k)) => format!(" «{n}» ({k})"), - (Some(n), None) => format!(" «{n}»"), - _ => String::new(), - }; - let mut caps = String::new(); - if c.oql == Some(true) { - caps.push_str(" [oql]"); - } - if c.api_doc_available == Some(true) { - caps.push_str(" [api-doc]"); - } - format!( - "- {}{}{} [{}]{}\n", - c.canister_id, - c.label.as_deref().map(|l| format!(" — {l}")).unwrap_or_default(), - identity, - c.sources.join(", "), - caps, - ) -} - -/// The caller-gated data-access note (#3): when discovery surfaced OQL data -/// canister(s), spell out that their data is gated by the CALLER's principal (an OQL -/// read requires the origin — an anonymous read is rejected for now) and how to read -/// as the user. `handle` is the ready-to-use origin clause when the origin is -/// already resolved (open_app), or `None` when it isn't (discover_app_canisters), in -/// which case the note points at resolve_app / open_app to obtain it. -fn data_access_note(canisters: &[discover::DiscoveredCanister], handle: Option<&str>) -> Option { - if !canisters.iter().any(|c| c.oql == Some(true)) { - return None; - } - let how = match handle { - Some(clause) => format!( - "To read it as the user, pass {clause} to get_canister_oql_schema (for the entity/field \ - names) and canister_query (with the `oql` argument), plus an optional account from list_app_accounts." - ), - None => "Resolve the app's derivation_origin (resolve_app / open_app) and pass it to \ - get_canister_oql_schema (for the entity/field names) and canister_query (with the \ - `oql` argument) to read as the user." - .to_string(), - }; - Some(format!( - "Data access: the canister(s) flagged [oql] hold this app's data, gated by the CALLER's \ - principal — an OQL read REQUIRES the origin (an anonymous read is rejected for now). {how}" - )) -} - -/// Render an IC dashboard canister identity as readable text for icp_lookup_canister_info_by_id. -fn format_canister_info(info: &discover::CanisterInfo) -> String { - let mut s = format!("Canister {}\n", info.canister_id); - s.push_str(&format!( - "- name: {}\n", - info.name.as_deref().unwrap_or("(unlabelled — not a known/named canister)") - )); - if let Some(t) = &info.canister_type { - s.push_str(&format!("- type: {t}\n")); - } - if let Some(sub) = &info.subnet_id { - s.push_str(&format!("- subnet: {sub}\n")); - } - if !info.controllers.is_empty() { - s.push_str(&format!("- controllers: {}\n", info.controllers.join(", "))); - } - if let Some(lang) = &info.language { - s.push_str(&format!("- language: {lang}\n")); - } - if let Some(mh) = &info.module_hash { - s.push_str(&format!("- module hash: {mh}\n")); - } - if let Some(p) = info.latest_upgrade_proposal { - s.push_str(&format!("- latest upgrade: NNS proposal {p}\n")); - } - s.push_str("\nFetch its interface with get_canister_candid, then read with canister_query and write with canister_update_call."); - s -} - -/// Render the user's accounts at an app (from `Identities::list_accounts`) as -/// readable text for the `list_app_accounts` tool. -fn format_accounts(target: &IdentityTarget, accounts: &[identities::AccountInfo]) -> String { - // A one-line derivation-origin header so a wrong origin (or requested≠derived - // mismatch) is visible even to text-only clients. - let header = format!("Accounts {}", identity_annotation(target, None)); - if accounts.is_empty() { - return format!("{header}\n\nNo Internet Identity accounts found there."); - } - let mut out = format!("{header}\n\nYour accounts:\n"); - for a in accounts { - // The default account (anchor's current default) has no name/number. - let label = match &a.name { - Some(name) => format!("\"{name}\""), - None => "(default account — no name)".to_string(), - }; - let number = match a.account_number { - Some(n) => format!("account #{n}"), - None => "default".to_string(), - }; - let last_used = a - .last_used - .map(|ns| format!(", last used {ns} ns since epoch")) - .unwrap_or_default(); - out.push_str(&format!("- {label} [{number}{last_used}]\n")); - } - if accounts.len() == 1 { - out.push_str( - "\nOnly the default account exists here — act on the user's behalf directly: \ - canister_query / canister_update_call / get_app_principal with no `account`.", - ); - } else { - out.push_str( - "\nThere are multiple accounts here. Confirm which one the user means (or act on each), \ - then pass its name as `account` to canister_query / canister_update_call / \ - get_app_principal. Omit `account` for the default one.", - ); - } - out -} - -/// The MCP `outputSchema` for `T`, for use in a `#[tool(output_schema = …)]` -/// attribute. Thin wrapper over rmcp's generator that unwraps the object-root -/// validation: a non-object schema is a programming error (every tool output -/// type is a struct), so it panics at router-construction time rather than -/// forcing an `.expect(…)` at each of the ~19 call sites. -fn schema_for_output() -> std::sync::Arc { - rmcp::handler::server::tool::schema_for_output::().unwrap_or_else(|e| { - panic!( - "output schema for `{}` must be object-rooted: {e}", - std::any::type_name::() - ) - }) -} - -fn ok(text: String) -> CallToolResult { - CallToolResult::success(vec![Content::text(text)]) -} - -/// A success result carrying both the human-readable `text` and a machine- -/// readable `value` that conforms to the tool's declared `outputSchema`. -/// Clients that consume structured tool output get the typed form; the rest -/// still get the text. -/// -/// MCP requires `outputSchema` (and therefore `structuredContent`) to be -/// object-rooted, so we only attach `value` when it serializes to a JSON -/// object; anything else (a bare array/string/number, or a serialization -/// failure) falls back to text-only rather than emitting structured content -/// that couldn't match the declared schema. -fn ok_structured(text: String, value: &T) -> CallToolResult { - ok_structured_blocks(vec![text], value) -} - -/// Like [`ok_structured`], but emits several text content blocks instead of one. -/// The first block is the primary payload (e.g. a canister's raw `.did`); any -/// further blocks are separate notes. Keeping them distinct means a consumer -/// that copies the first block gets clean data (the `.did` stays valid, -/// paste-able Candid), while the model still sees the trailing note(s) — used by -/// `get_canister_candid` to attach the OQL pointer without contaminating the interface -/// text. The structured `value` is attached under the same object-rooted rule as -/// [`ok_structured`]. -fn ok_structured_blocks(texts: Vec, value: &T) -> CallToolResult { - let mut result = CallToolResult::success(texts.into_iter().map(Content::text).collect()); - result.structured_content = match serde_json::to_value(value) { - Ok(v @ serde_json::Value::Object(_)) => Some(v), - _ => None, - }; - result -} - -/// Map a management/lifecycle tool's `Result` (the human -/// confirmation message) to a `CallToolResult`, attaching a -/// `management::CanisterActionOutput { canister_id, message }` as structured content on -/// success so the reply conforms to the tool's declared `outputSchema`. -/// -/// The `canister_id` is normalized to its canonical principal text: the -/// management layer accepts ids with surrounding whitespace (it parses via -/// `Principal::from_text(s.trim())`) and renders the canonical form in its -/// messages, so we canonicalize here too to keep the structured field -/// consistent with the text. On the success path the id always parses (the -/// operation just used it); the trimmed input is only a defensive fallback. -fn ok_canister_action(canister_id: String, r: Result) -> CallToolResult { - match r { - Ok(message) => { - let canister_id = Principal::from_text(canister_id.trim()) - .map(|p| p.to_text()) - .unwrap_or_else(|_| canister_id.trim().to_string()); - let output = management::CanisterActionOutput { canister_id, message }; - ok_structured(output.message.clone(), &output) - } - Err(text) => err(text), - } -} - -fn err(text: String) -> CallToolResult { - CallToolResult::error(vec![Content::text(text)]) -} - /// The landing page served at `/`. Markup, stylesheet, and the DFINITY mark /// live in `assets/` (compiled in via `include_str!`, no runtime file I/O) so /// the page is fully self-contained: no external fonts, scripts, or images. @@ -2364,209 +80,36 @@ async fn main() -> anyhow::Result<()> { .with(tracing_subscriber::fmt::layer().with_writer(std::io::stderr)) .init(); + // The boundary-node agent is built HERE (the deployment owns this choice) + // and injected; a host embedding the library would pass its own instead. let agent = Agent::builder().with_url(IC_URL).build()?; tracing::info!("built ic-agent against {IC_URL}"); - - // Two Internet Identity instances: beta serves `/mcp` (OAuth AS at the root - // of PUBLIC_URL, unchanged), prod serves `/mcp-prod` (path-scoped AS under - // `/prod`, issuer `/prod` — RFC 8414 path issuer). Each has its - // own Identities + AuthStore, so sessions/tokens never cross instances. - let inst_beta = identities::IiInstance::beta().map_err(anyhow::Error::msg)?; - let inst_prod = identities::IiInstance::prod().map_err(anyhow::Error::msg)?; - for inst in [&inst_beta, &inst_prod] { - tracing::info!( - "II instance {}: {} ({}) at {}", - inst.name, inst.ii_url, inst.ii_canister, inst.mcp_path, - ); - } - let ids_beta = Identities::new(inst_beta); - let ids_prod = Identities::new(inst_prod); - let skills = skills::SkillsCatalog::new(); - - let ct = tokio_util::sync::CancellationToken::new(); - // One rmcp streamable-HTTP service per instance, differing only in which - // Identities store the tools sign with. Stateless + plain-JSON responses: - // our tools are pure request/response with no server-initiated messages, and - // this is the most compatible mode across MCP clients (ChatGPT's connector - // does not complete the stateful SSE/session handshake the rmcp defaults - // require). - let make_mcp = |ids: Identities| { - let agent = agent.clone(); - let skills = skills.clone(); - StreamableHttpService::new( - move || Ok(IcTools::new(agent.clone(), ids.clone(), skills.clone())), - LocalSessionManager::default().into(), - StreamableHttpServerConfig::default() - .with_stateful_mode(false) - .with_json_response(true) - .with_cancellation_token(ct.child_token()) - .with_allowed_hosts(allowed_hosts()), - ) - }; - let mcp_beta = make_mcp(ids_beta.clone()); - let mcp_prod = make_mcp(ids_prod.clone()); - - // Session reaper, one task per instance. It sweeps once shortly after - // startup and then every 60s (tokio's interval fires its first tick - // immediately; the startup sweep is harmless, the map is empty then), - // evicting expired-grant sessions (emitting a "session closed" log each) and - // giving the journal a close event to reconcile against "session opened". - // This caps growth from expired grants (the common case: every - // authenticated session eventually expires); sessions with no recorded - // expiry (mid-connect) are deliberately kept and so are NOT bounded by this. - // Tied to the shutdown token so the tasks stop cleanly on drain. - for ids in [ids_beta.clone(), ids_prod.clone()] { - let reap_ct = ct.child_token(); - tokio::spawn(async move { - let mut tick = tokio::time::interval(std::time::Duration::from_secs(60)); - // Skip missed ticks rather than the default Burst: if the runtime - // stalls, resume with a single sweep at the next slot instead of - // firing several back-to-back catch-up sweeps. - tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); - loop { - tokio::select! { - _ = reap_ct.cancelled() => break, - _ = tick.tick() => { - ids.reap_expired_sessions().await; - } - } - } - }); - } + let public_url = public_url(); // Dynamic client registrations are II-agnostic (redirect allow-list only), // so both instances share one store — and one persisted snapshot. - let clients = auth::load_shared_clients(); - let store_beta = auth::AuthStore::new(ids_beta.clone(), clients.clone()); - let store_prod = auth::AuthStore::new(ids_prod.clone(), clients.clone()); - - // The MCP resources are gated by a bearer token issued after Internet - // Identity login (each by its own instance's store — a beta token is unknown - // to /mcp-prod and vice versa). `route_layer` (not `layer`) applies the gate - // ONLY to matched routes, so unmatched paths fall through to a 404 instead of - // the token gate's 401 — and the `/oauth/*` and `/.well-known/*` routes stay - // exempt from the bearer check. - // - // Browser-based MCP clients (e.g. the Grok/ChatGPT connector UIs) call the - // resource via `fetch()`, so it needs CORS — applied OUTSIDE the bearer gate - // so the OPTIONS preflight is answered (2xx) BEFORE authentication, and the - // 401's `WWW-Authenticate` (the auth-discovery hint) is exposed cross-origin. - // `Authorization` must be listed explicitly: the `*` wildcard for - // `Access-Control-Allow-Headers` does NOT cover it per the Fetch spec. - let mcp_cors = tower_http::cors::CorsLayer::new() - .allow_origin(tower_http::cors::Any) - .allow_methods([ - axum::http::Method::GET, - axum::http::Method::POST, - axum::http::Method::OPTIONS, - ]) - .allow_headers([ - axum::http::header::AUTHORIZATION, - axum::http::header::CONTENT_TYPE, - axum::http::header::ACCEPT, - axum::http::HeaderName::from_static("mcp-protocol-version"), - axum::http::HeaderName::from_static("mcp-session-id"), - axum::http::HeaderName::from_static("last-event-id"), - ]) - .expose_headers([ - axum::http::header::WWW_AUTHENTICATE, - axum::http::HeaderName::from_static("mcp-session-id"), - ]); - let protected_mcp = axum::Router::new() - .nest_service("/mcp", mcp_beta) - .route_layer(axum::middleware::from_fn_with_state( - store_beta.clone(), - auth::require_token, - )) - .layer(mcp_cors.clone()); - let protected_mcp_prod = axum::Router::new() - .nest_service("/mcp-prod", mcp_prod) - .route_layer(axum::middleware::from_fn_with_state( - store_prod.clone(), - auth::require_token, - )) - .layer(mcp_cors); - - // The per-instance OAuth endpoints, relative to the instance's prefix. - fn oauth_endpoints(store: auth::AuthStore) -> axum::Router { - axum::Router::new() - .route("/oauth/authorize", axum::routing::get(auth::authorize)) - // The pinned callback PAGE (GET): II navigates the consenting browser - // here with the delegation in the URL fragment; the page is the sole - // fragment reader. - .route("/oauth/connect/callback", axum::routing::get(auth::connect_callback_page)) - // The pinned page POSTs the fragment delegation here to be redeemed. - .route("/oauth/connect/redeem", axum::routing::post(auth::connect_redeem)) - .route("/oauth/token", axum::routing::post(auth::token)) - .route("/oauth/register", axum::routing::post(auth::register)) - .with_state(store) - } - - // Discovery documents. - // - // Beta (the default instance) keeps the root docs. Path-aware - // protected-resource metadata (RFC 9728 §3.1): the resource `…/mcp` has a - // path, so its metadata canonically lives at - // `/.well-known/oauth-protected-resource/mcp`; clients that follow the - // `resource_metadata` hint use the root doc. We deliberately do NOT add a - // `/mcp`-suffixed *authorization-server* doc for beta: its issuer is - // `base_url()` (no path), so per RFC 8414 a strict client requesting the - // suffixed AS doc would reject it on issuer mismatch. - // - // Prod is a path issuer (`/prod`), so its AS metadata lives at the - // RFC 8414 path-inserted URL `/.well-known/oauth-authorization-server/prod` - // (plus the OIDC-style `/.well-known/…` alternate some clients - // derive), and its resource doc at - // `/.well-known/oauth-protected-resource/mcp-prod`. - let discovery_beta = axum::Router::new() - .route( - "/.well-known/oauth-authorization-server", - axum::routing::get(auth::authorization_server_metadata), - ) - .route( - "/.well-known/oauth-protected-resource", - axum::routing::get(auth::protected_resource_metadata), - ) - .route( - "/.well-known/oauth-protected-resource/mcp", - axum::routing::get(auth::protected_resource_metadata), - ) - .with_state(store_beta.clone()); - let discovery_prod = axum::Router::new() - .route( - "/.well-known/oauth-authorization-server/prod", - axum::routing::get(auth::authorization_server_metadata), - ) - .route( - "/prod/.well-known/oauth-authorization-server", - axum::routing::get(auth::authorization_server_metadata), - ) - .route( - "/.well-known/oauth-protected-resource/mcp-prod", - axum::routing::get(auth::protected_resource_metadata), - ) - .with_state(store_prod.clone()); - - // OAuth authorization-server + discovery endpoints (CORS-open for clients). - let cors = tower_http::cors::CorsLayer::new() - .allow_origin(tower_http::cors::Any) - .allow_methods(tower_http::cors::Any) - .allow_headers(tower_http::cors::Any); - // The auth-callback allow-list (II #4091): before contacting the connect - // callback named in the link fragment, II fetches this origin-global - // document and requires the callback to be EXACTLY one of the declared - // entries — fail-closed, so serving it is mandatory once #4091 ships. One - // document declares both instances' callbacks (the path carries no - // instance prefix). CORS-open: II's frontend fetches it cross-origin. - let auth_callbacks = axum::Router::new() - .route(auth::AUTH_CALLBACKS_WELL_KNOWN, axum::routing::get(auth::auth_callbacks)) - .with_state(vec![store_beta.clone(), store_prod.clone()]); - let oauth = discovery_beta - .merge(discovery_prod) - .merge(oauth_endpoints(store_beta.clone())) - .nest("/prod", oauth_endpoints(store_prod.clone())) - .merge(auth_callbacks) - .layer(cors); + let clients = SharedClients::load(); + + // Two Internet Identity instances, each a self-contained McpServer: + // sessions/tokens never cross instances. Beta is the origin's default + // instance (it also answers the plain-root discovery probes). + let beta = McpServer::new(McpConfig { + agent: agent.clone(), + instance: IiInstance::beta().map_err(anyhow::Error::msg)?, + public_url: public_url.clone(), + mcp_path: "/mcp".into(), + clients: clients.clone(), + }); + let prod = McpServer::new(McpConfig { + agent, + instance: IiInstance::prod().map_err(anyhow::Error::msg)?, + public_url: public_url.clone(), + mcp_path: "/mcp-prod".into(), + clients, + }); + // Per-instance session reapers (expired-grant eviction + close-event logs). + beta.spawn_session_reaper(); + prod.spawn_session_reaper(); // When this process started — i.e. when the deployment last (re)started. // Every deploy restarts the service, so this is the "last redeployment" time. @@ -2577,11 +120,10 @@ async fn main() -> anyhow::Result<()> { // Per-instance handles for the /version live-session gauge (Arc-backed, so // cloning shares the same session maps the tools mutate). - let ver_ids_beta = ids_beta.clone(); - let ver_ids_prod = ids_prod.clone(); + let (ver_beta, ver_prod) = (beta.clone(), prod.clone()); - let app = axum::Router::new() - .route("/", axum::routing::get(|| async { axum::response::Html(INDEX_HTML.as_str()) })) + let app = Router::new() + .route("/", get(|| async { Html(INDEX_HTML.as_str()) })) // Unauthenticated build/version probe so operators and the status // dashboard can confirm exactly which deployment is live: the running // commit (baked in at build time via GIT_SHA), the build time @@ -2589,23 +131,23 @@ async fn main() -> anyhow::Result<()> { // Timestamps are Unix epoch seconds (or null when unknown). .route( "/version", - axum::routing::get(move || { + get(move || { // Clone the Arc-backed handles per request so the handler stays // `Fn` (reusable across requests) while the async body owns them. - let ver_ids_beta = ver_ids_beta.clone(); - let ver_ids_prod = ver_ids_prod.clone(); + let ver_beta = ver_beta.clone(); + let ver_prod = ver_prod.clone(); async move { // Two per-instance session gauges, each from one lock + - // iteration of the session map (see `Identities::session_gauges`): + // iteration of the session map: // - live_sessions: authenticated sessions whose II grant has // not yet expired. Tracks the grant lifecycle — an idle // session still counts; only expiry removes it. // - active_sessions: the subset also seen requesting within the // activity window (~15 min) — a ballpark of who is working // right now, for timing a low-disruption redeploy. - let beta = ver_ids_beta.session_gauges().await; - let prod = ver_ids_prod.session_gauges().await; - axum::Json(serde_json::json!({ + let beta = ver_beta.session_gauges().await; + let prod = ver_prod.session_gauges().await; + Json(serde_json::json!({ "version": env!("CARGO_PKG_VERSION"), "commit": option_env!("GIT_SHA").unwrap_or("unknown"), "built_at": option_env!("BUILD_TIME").and_then(|s| s.parse::().ok()), @@ -2623,38 +165,51 @@ async fn main() -> anyhow::Result<()> { } }), ) - .merge(oauth) - .merge(protected_mcp) - .merge(protected_mcp_prod) + // `nest_service`, not `nest`: it also forwards the bare trailing-slash + // form (`/mcp/`), which axum's `nest` never routes into the nested router. + .nest_service(beta.mcp_path(), beta.mcp_router()) + .nest_service(prod.mcp_path(), prod.mcp_router()) + .merge(beta.well_known_router()) + .merge(prod.well_known_router()) + // Beta is the default instance: it also owns the plain-root documents. + .merge(beta.root_well_known_router()) + // The II auth-callback allow-list is origin-global: one document + // declares BOTH instances' callbacks. + .merge(auth_callbacks_router(&[&beta, &prod])) // Log every inbound request (method, path, status, latency) so we can see // what external clients actually hit — discovery probes, unknown paths, // etc. Only the path is logged, never the query string, so single-use - // secrets (`?code=`, `?c=`) don't land in logs. + // secrets (`?code=`) don't land in logs. .layer(axum::middleware::from_fn(log_request)); let bind = bind_address(); let listener = tokio::net::TcpListener::bind(&bind).await?; - tracing::info!("listening on http://{bind} (MCP at /mcp, OAuth at /oauth/*)"); + tracing::info!( + "listening on http://{bind} (MCP at {} and {}, OAuth under each mount)", + beta.mcp_path(), + prod.mcp_path(), + ); // Drain-then-cancel, on ALL exit paths. `with_graceful_shutdown` stops // accepting new connections and drains the in-flight ones first; only then - // do we cancel the rmcp services' token. Ordering matters: the token is - // `with_cancellation_token(ct.child_token())`, and cancelling it asks rmcp - // to terminate active sessions, so cancelling before the drain would cut the - // very in-flight MCP requests we want to finish. Capturing the result rather - // than `?`-ing it means an unexpected serve error (accept failure, etc.) - // still cancels the token before the error propagates. (Stateless, no - // long-lived SSE, so there's nothing for the token to cut post-drain.) + // do we cancel the rmcp services' tokens (via McpServer::shutdown). Ordering + // matters: cancelling asks rmcp to terminate active sessions, so cancelling + // before the drain would cut the very in-flight MCP requests we want to + // finish. Capturing the result rather than `?`-ing it means an unexpected + // serve error (accept failure, etc.) still cancels the tokens before the + // error propagates. (Stateless, no long-lived SSE, so there's nothing for + // the tokens to cut post-drain.) let serve_result = axum::serve(listener, app) .with_graceful_shutdown(shutdown_signal()) .await; - ct.cancel(); + beta.shutdown(); + prod.shutdown(); serve_result?; Ok(()) } /// Resolves when the process is asked to stop, so `axum` drains in-flight -/// requests before exit rather than being cut mid-response. The rmcp -/// cancellation token is cancelled by the caller *after* the drain completes, +/// requests before exit rather than being cut mid-response. The per-instance +/// cancellation tokens are cancelled by the caller *after* the drain completes, /// not here (see the call site). /// /// Handles BOTH signals: an interactive run is stopped with `SIGINT` (Ctrl-C), @@ -2686,392 +241,3 @@ async fn shutdown_signal() { } tracing::info!("shutdown signal received; draining in-flight requests"); } - -#[cfg(test)] -mod tests { - use super::calls::decode_bytes_with_did; - use candid::types::value::IDLArgs; - use candid_parser::parse_idl_args; - - // Anonymous per-app reads are disabled for now: the OQL read tools reject a - // call with no origin, and the rejection must name what to pass and how to get - // it (so the agent recovers in one step) — not just say "no". - #[test] - fn oql_needs_origin_error_is_actionable() { - let msg = super::oql_needs_origin_error("Running an OQL query"); - assert!(msg.contains("Running an OQL query"), "echoes the action: {msg}"); - assert!(msg.contains("derivation_origin"), "names the arg to pass: {msg}"); - assert!( - msg.contains("open_app") && msg.contains("resolve_app"), - "names how to get the origin: {msg}" - ); - assert!( - msg.to_lowercase().contains("anonymous"), - "explains anonymous is disabled: {msg}" - ); - } - - // Field names are hashed on the Candid wire; decoding against the method's - // declared return type must recover them (type-less decoding shows hashes). - #[test] - fn typed_decode_recovers_field_names() { - let did = "service : { stats : () -> (record { name : text; url : text }) query }"; - // Encode a record reply (names get hashed in the wire format). - let bytes = parse_idl_args("(record { name = \"ICP\"; url = \"https://internetcomputer.org\" })") - .unwrap() - .to_bytes() - .unwrap(); - - // Type-less decode -> hashed field ids. - let typeless = IDLArgs::from_bytes(&bytes).unwrap().to_string(); - assert!(!typeless.contains("name ="), "type-less should NOT have names: {typeless}"); - - // Typed decode against the .did -> real field names. - let typed = decode_bytes_with_did(did, "stats", &bytes).expect("typed decode"); - assert!(typed.contains("name ="), "typed should have `name`: {typed}"); - assert!(typed.contains("url ="), "typed should have `url`: {typed}"); - } - - // Every tool must carry MCP annotations, else clients fall back to the unsafe - // defaults (readOnly=false, destructive=true) and mislabel reads like - // get_canister_candid as destructive. Assert the read/write classification serializes. - #[test] - fn every_tool_has_correct_read_write_annotations() { - let tools = super::IcTools::tool_router().list_all(); - assert_eq!(tools.len(), 26, "expected 26 tools, got {}", tools.len()); - assert!( - tools.iter().all(|t| t.annotations.is_some()), - "every tool must carry annotations (else clients assume write/destructive)" - ); - let ann = |name: &str| { - tools - .iter() - .find(|t| &*t.name == name) - .unwrap_or_else(|| panic!("tool {name} not found")) - .annotations - .clone() - .unwrap_or_else(|| panic!("tool {name} has no annotations")) - }; - // Pure reads (and status, which reads but is an update call) are read-only, - // AND set destructive_hint=false explicitly so a naive client that doesn't - // gate destructive on read_only can't mislabel them. - for name in [ - "get_canister_candid", "canister_query", "get_canister_oql_schema", "discover_app_canisters", "icp_find_canister_by_name", "icp_find_app_by_name", "icp_lookup_canister_info_by_id", - "icp_list_skills", "icp_get_skill", "icp_oql_guide", - "get_canister_api_doc", "open_app", "resolve_app", "list_app_accounts", "icp_cycles_balance", "get_app_principal", "icp_canister_status", - ] { - let a = ann(name); - assert_eq!(a.read_only_hint, Some(true), "{name} should be read-only"); - assert_eq!(a.destructive_hint, Some(false), "{name} should set destructive=false explicitly"); - } - // Destructive writes: not read-only, destructive. - for name in ["icp_delete_canister", "icp_uninstall_code", "icp_install_code", "icp_update_canister_settings"] { - let a = ann(name); - assert_eq!(a.read_only_hint, Some(false), "{name} should not be read-only"); - assert_eq!(a.destructive_hint, Some(true), "{name} should be destructive"); - } - // Additive/reversible writes: not read-only, not destructive. - for name in ["icp_create_canister", "icp_top_up_canister", "icp_start_canister", "icp_stop_canister"] { - let a = ann(name); - assert_eq!(a.read_only_hint, Some(false), "{name} should not be read-only"); - assert_eq!(a.destructive_hint, Some(false), "{name} should not be destructive"); - } - // The update-call tool is conservatively write + destructive. - let cc = ann("canister_update_call"); - assert_eq!(cc.read_only_hint, Some(false)); - assert_eq!(cc.destructive_hint, Some(true)); - } - - // EVERY tool must declare an outputSchema so a model knows the shape of its - // reply — and MCP requires that schema to be object-rooted. This guards the - // whole surface: a new tool added without an output schema fails here. - #[test] - fn every_tool_declares_an_object_output_schema() { - let tools = super::IcTools::tool_router().list_all(); - for t in &tools { - let schema = t - .output_schema - .as_ref() - .unwrap_or_else(|| panic!("tool {} must declare an output schema", t.name)); - assert_eq!( - schema.get("type"), - Some(&serde_json::json!("object")), - "tool {}'s outputSchema must be object-rooted per the MCP spec", - t.name - ); - } - } - - // Spot-check icp_find_canister_by_name's schema lists the expected properties. - #[test] - fn find_canister_declares_output_schema() { - let tools = super::IcTools::tool_router().list_all(); - let tool = tools - .iter() - .find(|t| &*t.name == "icp_find_canister_by_name") - .expect("icp_find_canister_by_name tool not found"); - let schema = tool - .output_schema - .as_ref() - .expect("icp_find_canister_by_name must declare an output schema"); - let props = schema - .get("properties") - .and_then(|v| v.as_object()) - .expect("outputSchema must have properties"); - assert!(props.contains_key("query"), "outputSchema should describe `query`"); - assert!(props.contains_key("matches"), "outputSchema should describe `matches`"); - } - - // The object-root guard in ok_structured must drop non-object payloads - // (a bare array/string can't match an MCP object outputSchema) while - // still attaching genuine objects. - #[test] - fn ok_structured_only_attaches_objects() { - let obj = super::ok_structured("t".to_string(), &serde_json::json!({"a": 1})); - assert!(obj.structured_content.is_some(), "an object payload must attach"); - - let arr = super::ok_structured("t".to_string(), &serde_json::json!([1, 2, 3])); - assert!(arr.structured_content.is_none(), "a bare array must be dropped"); - - let s = super::ok_structured("t".to_string(), &"hello"); - assert!(s.structured_content.is_none(), "a bare string must be dropped"); - } - - // ok_canister_action normalizes the canister id to its canonical principal - // text, so the structured field matches the id the management layer acted - // on (it trims/parses the input) rather than echoing raw whitespace. - #[test] - fn ok_canister_action_canonicalizes_canister_id() { - let result = super::ok_canister_action( - " ryjl3-tyaaa-aaaaa-aaaba-cai ".to_string(), - Ok("Started ryjl3-tyaaa-aaaaa-aaaba-cai.".to_string()), - ); - let value = result.structured_content.expect("structured content must be attached"); - assert_eq!( - value.get("canister_id"), - Some(&serde_json::json!("ryjl3-tyaaa-aaaaa-aaaba-cai")), - "structured canister_id must be the canonical, trimmed principal text" - ); - } - - // A structured icp_find_canister_by_name reply must round-trip through the declared - // schema shape: text for humans, plus a machine-readable object with a - // `matches` array carrying each match's fields. - #[test] - fn find_canister_output_serializes_to_declared_shape() { - let output = super::discover::FindCanisterOutput { - query: "ckUSDC".to_string(), - matches: vec![super::discover::FoundCanister { - canister_id: "xevnm-gaaaa-aaaar-qafnq-cai".to_string(), - name: "ckUSDC".to_string(), - kind: "token".to_string(), - note: None, - }], - }; - let result = super::ok_structured("human text".to_string(), &output); - let value = result - .structured_content - .expect("structured content must be attached"); - assert_eq!(value.get("query"), Some(&serde_json::json!("ckUSDC"))); - let matches = value.get("matches").and_then(|v| v.as_array()).expect("matches array"); - assert_eq!(matches.len(), 1); - assert_eq!( - matches[0].get("canister_id"), - Some(&serde_json::json!("xevnm-gaaaa-aaaar-qafnq-cai")) - ); - assert_eq!(matches[0].get("kind"), Some(&serde_json::json!("token"))); - } - - // resolve_identity_target's validation rules are part of the tool contract. It - // takes ONLY `derivation_origin` now (the identity-bearing tools no longer - // accept an app_url to resolve — see clean_app_url tests for URL validation). - - // A whitespace-only `derivation_origin` is empty after trimming and must be - // rejected rather than canonicalized into a bogus origin. - #[test] - fn resolve_identity_target_rejects_blank_derivation_origin() { - let err = super::resolve_identity_target(Some(" ".to_string())) - .expect_err("blank derivation_origin must be an error"); - assert!(err.contains("must not be empty"), "unexpected message: {err}"); - } - - // `derivation_origin` is trimmed before canonicalization, so surrounding - // whitespace never leaks into either the echoed `requested` or the effective - // `origin` fed to the delegation path. - #[test] - fn resolve_identity_target_trims_derivation_origin() { - let target = super::resolve_identity_target(Some(" https://example.com ".to_string())) - .expect("valid derivation_origin resolves") - .expect("an explicit derivation_origin yields a target"); - assert_eq!(target.requested, "https://example.com", "requested must be trimmed"); - assert_eq!(target.origin, "https://example.com", "origin must be the canonical trimmed form"); - assert_eq!(target.source, "explicit"); - } - - // No `derivation_origin` is the anonymous path: no target, no error. - #[test] - fn resolve_identity_target_none_is_anonymous() { - let target = super::resolve_identity_target(None).expect("None is anonymous"); - assert!(target.is_none(), "None must yield no target"); - } - - // A control character is rejected up front (it would otherwise be echoed back - // verbatim / corrupt the delegation origin). - #[test] - fn resolve_identity_target_rejects_control_chars() { - let err = super::resolve_identity_target(Some("https://ex\u{7}ample.com".to_string())) - .expect_err("control chars must be rejected"); - assert!(err.contains("control characters"), "unexpected message: {err}"); - } - - // An uppercase scheme must canonicalize the same as lowercase — `target_origin` - // strips only a lowercase `https://`, so without normalization `HTTPS://` would - // survive into the host and mangle the origin instead of yielding `https://host`. - #[test] - fn resolve_identity_target_normalizes_uppercase_scheme() { - let t = super::resolve_identity_target(Some("HTTPS://example.com".to_string())) - .expect("uppercase https scheme must be accepted") - .expect("a derivation_origin yields a target"); - assert_eq!(t.origin, "https://example.com", "unexpected origin: {}", t.origin); - } - - // A mixed-case HOST is canonicalized to lowercase, so the echoed origin and the - // delegation-cache key match the resolver path (which serializes via Url::origin()) - // instead of forking `https://Example.COM` from `https://example.com`. - #[test] - fn resolve_identity_target_lowercases_host() { - let t = super::resolve_identity_target(Some("https://Example.COM".to_string())) - .expect("mixed-case host must be accepted") - .expect("a derivation_origin yields a target"); - assert_eq!(t.origin, "https://example.com", "host must be lowercased: {}", t.origin); - } - - // A `derivation_origin` with no host (e.g. "https://") reduces to an empty - // origin and must be rejected rather than derived against. - #[test] - fn resolve_identity_target_rejects_hostless_derivation_origin() { - let err = super::resolve_identity_target(Some("https://".to_string())) - .expect_err("host-less derivation_origin must be rejected"); - assert!(err.contains("host"), "unexpected message: {err}"); - } - - // A non-http(s) scheme must be rejected, not mangled into a bogus https origin. - #[test] - fn resolve_identity_target_rejects_non_http_scheme() { - let err = super::resolve_identity_target(Some("ftp://example.com".to_string())) - .expect_err("ftp:// must be rejected"); - assert!(err.contains("https origin"), "unexpected message: {err}"); - } - - // `clean_app_url` (used by the resolver tools open_app / resolve_app) fails - // closed on bad URLs up front, so a caller gets a clear error instead of a late - // SSRF-guard/URL-parse failure. (The identity-bearing tools no longer take an - // app_url; the resolvers do.) - #[test] - fn clean_app_url_rejects_bad_urls() { - assert!(super::clean_app_url(" ").expect_err("blank").contains("must not be empty")); - assert!(super::clean_app_url("http://example.com").expect_err("http").contains("https")); - assert!( - super::clean_app_url("https://user:pass@example.com") - .expect_err("user-info") - .contains("user-info") - ); - assert!(super::clean_app_url("https://").expect_err("host-less").contains("real host")); - // A good bare host / https URL passes through. - assert_eq!(super::clean_app_url("oisy.com").unwrap(), "oisy.com"); - assert_eq!(super::clean_app_url("https://oisy.com").unwrap(), "https://oisy.com"); - } - - // The guessed-domain refusal (offline): the message must name the failure, - // redirect to the legitimate name→URL steps, carry the "did you mean" repair - // when the host resembles a well-known app, and keep the deliberate - // `derivation_origin` escape hatch — that combination is what turns a guessing - // loop into a one-step correction. - #[test] - fn unverified_app_url_error_redirects_and_repairs() { - // The exact wild-observed guess: multidex.com for MULTI/DEX (multidex.ai). - let msg = super::unverified_app_url_error("https://multidex.com"); - assert!(msg.contains("NO evidence"), "{msg}"); - assert!(msg.contains("icp_find_app_by_name"), "{msg}"); - assert!(msg.contains("WEB SEARCH"), "{msg}"); - assert!(msg.contains("DID YOU MEAN MULTI/DEX"), "{msg}"); - assert!(msg.contains("https://multidex.ai"), "{msg}"); - assert!(msg.contains("`derivation_origin`"), "escape hatch missing: {msg}"); - // A host resembling no known app still gets the redirect, just no repair. - let plain = super::unverified_app_url_error("https://example.com"); - assert!(plain.contains("icp_find_app_by_name"), "{plain}"); - assert!(!plain.contains("DID YOU MEAN"), "{plain}"); - } - - // A fetch failure on a guessed domain (offline: the DNS-dead "multi.dex" guess) - // carries the same repair, so it reads as "wrong URL — here's the real one" - // rather than a transient error inviting the next guess. - #[test] - fn app_url_fetch_errors_carry_guess_guidance() { - let msg = super::app_url_error_with_guidance( - "multi.dex", - "could not resolve multi.dex: no such host".to_string(), - ); - assert!(msg.contains("could not resolve"), "{msg}"); - assert!(msg.contains("DID YOU MEAN MULTI/DEX"), "{msg}"); - assert!(msg.contains("https://multidex.ai"), "{msg}"); - assert!(msg.contains("icp_find_app_by_name"), "{msg}"); - // Unrelated hosts: guidance without a bogus suggestion. - let plain = super::app_url_error_with_guidance("example.com", "timeout".to_string()); - assert!(!plain.contains("DID YOU MEAN"), "{plain}"); - assert!(plain.contains("icp_find_app_by_name"), "{plain}"); - } - - // The human-readable identity annotation must surface a requested≠derived - // mismatch (and the source) in ALL clients. - #[test] - fn identity_annotation_surfaces_mismatch_and_source() { - // requested == origin: origin + source, but no redundant `requested` echo. - let t = super::IdentityTarget { - origin: "https://nns.ic0.app".to_string(), - requested: "https://nns.ic0.app".to_string(), - source: "explicit".to_string(), - }; - let a = super::identity_annotation(&t, None); - assert!(a.contains("derived for https://nns.ic0.app"), "{a}"); - assert!(a.contains("source: explicit"), "{a}"); - assert!(!a.contains("requested"), "no mismatch must not echo requested: {a}"); - - // requested != origin (e.g. a stripped path): the mismatch is surfaced. - let t2 = super::IdentityTarget { - origin: "https://app.example.com".to_string(), - requested: "https://app.example.com/some/path".to_string(), - source: "explicit".to_string(), - }; - let a2 = super::identity_annotation(&t2, Some("aaaaa-aa")); - assert!(a2.contains("signed as aaaaa-aa"), "{a2}"); - assert!(a2.contains("requested https://app.example.com/some/path"), "{a2}"); - } - - // resolve_identity_target backs every identity-bearing path now — including - // canister_query's OQL mode and get_canister_candid's schema read — so it must - // fail closed on bad derivation origins: control chars, host-less, a non-https - // scheme, an embedded space, and user-info are all rejected; a valid one trims + - // canonicalizes. (The None/blank/uppercase/lowercase-host cases have their own - // tests above.) - #[test] - fn resolve_identity_target_canonicalizes_and_rejects_bad_origins() { - use super::resolve_identity_target as r; - assert!(r(Some("https://a\u{7}b.com".to_string())).is_err(), "control chars rejected"); - assert!(r(Some("https://".to_string())).is_err(), "host-less rejected"); - assert!(r(Some("ftp://example.com".to_string())).is_err(), "non-http(s) scheme rejected"); - assert!(r(Some("https://ex ample.com".to_string())).is_err(), "embedded space rejected"); - assert!( - r(Some("https://user@example.com".to_string())).is_err(), - "user-info rejected (would derive a different principal than the bare origin)" - ); - assert!( - r(Some("http://example.com".to_string())).is_err(), - "explicit http:// rejected (https-only contract; target_origin would silently upgrade it)" - ); - let t = r(Some(" https://example.com ".to_string())) - .expect("valid origin resolves") - .expect("an explicit derivation_origin yields a target"); - assert_eq!(t.origin, "https://example.com", "valid input trims + canonicalizes"); - } -} diff --git a/src/management.rs b/src/management.rs index 26f534c..3d48b8c 100644 --- a/src/management.rs +++ b/src/management.rs @@ -36,8 +36,6 @@ use serde::{Deserialize, Serialize}; use crate::identities::Identities; -/// Public IC API boundary node (same as elsewhere in the crate). -const IC_URL: &str = "https://icp-api.io"; /// Cycles ledger — creates/funds canisters from a principal's cycles balance. const CYCLES_LEDGER: &str = "um5iw-rqaaa-aaaaq-qaaba-cai"; /// Cycles Minting Canister — mints cycles from ICP for the `icp` funding path @@ -539,11 +537,9 @@ async fn management_agent(ids: &Identities, session_id: &str) -> Result<(Agent, let principal = identity .sender() .map_err(|e| format!("could not derive your principal: {e}"))?; - let agent = Agent::builder() - .with_url(IC_URL) - .with_identity(identity) - .build() - .map_err(|e| format!("could not build agent: {e}"))?; + // A clone of the injected base agent with the management identity swapped + // in — same boundary-node routing as every other call. + let agent = ids.agent_as(identity); Ok((agent, principal)) } diff --git a/src/tools.rs b/src/tools.rs new file mode 100644 index 0000000..387a7a7 --- /dev/null +++ b/src/tools.rs @@ -0,0 +1,2659 @@ +//! The MCP tools: an [`rmcp`] server handler exposing Internet Computer tools +//! over streamable HTTP. +//! +//! The LLM only ever deals with textual Candid; encoding/decoding happens in +//! `calls`. Anonymous canister calls use the shared agent injected by the +//! embedding application (see [`crate::McpServer`]); per-app identities are +//! minted on demand from the connection's registered session key (see +//! `identities`). + +use candid::Principal; +use ic_agent::{Agent, Identity}; +use rmcp::{ + handler::server::{router::tool::ToolRouter, wrapper::Parameters}, + model::*, + service::RequestContext, + tool, tool_handler, tool_router, + schemars, ErrorData as McpError, RoleServer, ServerHandler, +}; + +use crate::{auth, calls, discover, identities, identities::Identities, management, skills}; + +/// Cap on the per-canister Candid probes open_app / discover_app_canisters run to +/// fill in OQL / api-doc capability flags (#3). Discovery output is already bounded, +/// but a large declared manifest could still list many ids — probe only the most +/// authoritative handful so the extra fan-out stays small. +const MAX_CAPABILITY_PROBES: usize = 8; + +/// Candid references exposed as MCP resources so the client writes correct +/// textual Candid. The textual-syntax cheat sheet is emphasised because every +/// tool here speaks textual Candid; the full type reference backs it up. +const CANDID_TEXTUAL_URI: &str = "candid://textual-syntax"; +const CANDID_REFERENCE_URI: &str = "candid://reference"; +/// URI scheme for IC skills exposed as MCP resources (`skill://`). +const SKILL_URI_PREFIX: &str = "skill://"; +const CANDID_TEXTUAL_MD: &str = include_str!("../static/candid-textual-syntax.md"); +const CANDID_REFERENCE_MD: &str = include_str!("../static/candid-reference.md"); + +/// The OQL query-surface usage guide. Served on demand (via the `icp_oql_guide` +/// tool and the `oql://usage` resource) rather than inlined into every +/// `get_canister_candid` reply: `get_canister_candid` only signals `oql: true` plus a one-line +/// pointer here, so the guidance is delivered once, when the model chooses to +/// query, without bloating every interface read. +const OQL_USAGE_URI: &str = "oql://usage"; +const OQL_PRIMER_MD: &str = include_str!("../static/oql-primer.md"); + +// Per-tool argument and output types live in the module that implements the +// tool: `calls` (get_canister_candid, canister_query, canister_update_call), +// `discover`, `identities`, `skills`, `management`. main.rs only wires the tools +// together. + +#[derive(Clone)] +pub(crate) struct IcTools { + agent: Agent, + identities: Identities, + skills: skills::SkillsCatalog, + tool_router: ToolRouter, +} + +#[tool_router] +impl IcTools { + pub(crate) fn new(agent: Agent, identities: Identities, skills: skills::SkillsCatalog) -> Self { + Self { + agent, + identities, + skills, + tool_router: Self::tool_router(), + } + } + + #[tool( + description = "Fetch the Candid (.did) interface definition of an Internet Computer canister, read from its public `candid:service` metadata. Also reports two capability flags: `oql` (the canister exposes the OQL query surface — READ it via icp_oql_guide → get_canister_oql_schema → canister_query with the `oql` argument, since a Candid `method` query is then rejected) and `api_doc_available` (a `getApiDoc`/`get_api_doc` method exists — call get_canister_api_doc for a prose behavior guide; skip that call when this is false).", + annotations(title = "Get Candid interface", read_only_hint = true, destructive_hint = false, open_world_hint = true), + output_schema = schema_for_output::(), + )] + async fn get_canister_candid( + &self, + Parameters(calls::GetCandidArgs { canister_id }): Parameters, + ) -> Result { + let principal = match Principal::from_text(&canister_id) { + Ok(p) => p, + Err(e) => return Ok(err(format!("invalid canister id: {e}"))), + }; + match self + .agent + .read_state_canister_metadata(principal, "candid:service") + .await + { + Ok(bytes) => match String::from_utf8(bytes) { + Ok(did) => { + // Signal an OQL query surface structurally (`oql: true`). When + // set, the guidance pointer is emitted as a SEPARATE content + // block, so the first block stays the raw `.did` (still valid, + // copy-pastable Candid) — the full primer is served on demand + // (see icp_oql_guide / OQL_USAGE_URI), never inlined. + let oql = calls::has_oql(&did); + // Same predicate get_canister_api_doc uses, so the flag gates that + // call: true means a `getApiDoc`/`get_api_doc` method exists. + let api_doc_available = calls::api_doc_method(&did).is_some(); + let output = calls::GetCandidOutput { + canister_id, + candid: did.clone(), + oql, + api_doc_available, + }; + // Assemble any capability notes as separate blocks so the first + // block stays the raw, paste-able `.did`. + let mut notes = Vec::new(); + if oql { + notes.push(format!( + "This canister exposes an OQL query surface (a JSON query language \ + over its data), so READ it through the OQL tools — a Candid `method` \ + query via canister_query is rejected here. Order: icp_oql_guide \ + (dialect, once) → get_canister_oql_schema (entities/fields) → \ + canister_query with the `oql` argument (run a JSON query, get a table). \ + Those wrap the `schema`/`execute` methods (no Candid escaping). Per-app \ + data is caller-gated, so the OQL read path REQUIRES the app's \ + derivation_origin — an anonymous OQL read is rejected (for now), not \ + silently empty; pass the derivation_origin from open_app / resolve_app. \ + See icp_oql_guide (or the `{OQL_USAGE_URI}` resource) for the dialect. \ + canister_update_call then handles UPDATE calls only." + )); + } + notes.push(if api_doc_available { + "This canister exposes an API-doc method (api_doc_available=true): call \ + get_canister_api_doc for a prose \"how this app behaves\" guide (units, \ + auth, lifecycle, gotchas)." + .to_string() + } else { + "This canister declares no API-doc method (api_doc_available=false) — don't \ + call get_canister_api_doc; the Candid types above are the interface." + .to_string() + }); + let mut blocks = vec![did]; + blocks.extend(notes); + Ok(ok_structured_blocks(blocks, &output)) + } + Err(e) => Ok(err(format!("metadata is not valid UTF-8: {e}"))), + }, + Err(e) => Ok(err(format!( + "could not read candid:service metadata: {e}" + ))), + } + } + + #[tool( + description = "Load the OQL query-surface guide: the JSON query dialect for canisters that expose OQL (get_canister_candid reports `oql: true`) — entities/fields/edges via `schema`, and the `execute` query object (filters, aggregation, ordering, edge traversal, paging). This is step ONE of the fixed sequence guide→schema→query: read this once, then `get_canister_oql_schema` for the exact entity/field names (they are the schema's own — often PLURAL and unlike the Candid types/methods, e.g. `bookings` not `Booking`/`getBookings`), then `canister_query` with the `oql` argument. Never guess bespoke per-question methods. The schema read and the query REQUIRE the app's `derivation_origin` (from open_app / resolve_app) — anonymous per-app reads are disabled for now and are rejected with guidance. Both wrap the `schema`/`execute` methods, so you write plain JSON — no Candid escaping.", + annotations(title = "Get the OQL query guide", read_only_hint = true, destructive_hint = false, open_world_hint = false), + output_schema = schema_for_output::(), + )] + async fn icp_oql_guide( + &self, + Parameters(_args): Parameters, + ) -> Result { + let output = calls::OqlGuideOutput { content: OQL_PRIMER_MD.to_string() }; + Ok(ok_structured(output.content.clone(), &output)) + } + + #[tool( + description = "Fetch the OQL schema catalogue of a canister that exposes the OQL surface (get_canister_candid reports `oql: true`): its entities, their primary keys, fields, and edges, as JSON. The MIDDLE step of guide→schema→query: read `icp_oql_guide` once, then call THIS before canister_query so you use the exact entity/field names instead of guessing. Entity names are the schema's own — often PLURAL and different from the Candid types/methods (e.g. `bookings`, not `Booking`/`getBookings`). Returns the schema plus a ready-to-run `canister_query` example per entity (each preserving this call's identity). AUTH: `derivation_origin` is REQUIRED — the schema itself is gated by the caller's principal, so a read with no origin is REJECTED (anonymous per-app reads are disabled for now) with guidance to pass it, rather than returning an empty entity list you'd misread as \"the app has no data model\". Pass the app's canonical `derivation_origin` (from open_app / resolve_app) to read the entities visible to the USER; the reply echoes `derived_for_origin` / `acted_as_principal`.", + annotations(title = "Get the OQL schema", read_only_hint = true, destructive_hint = false, open_world_hint = true), + output_schema = schema_for_output::(), + )] + async fn get_canister_oql_schema( + &self, + Parameters(calls::OqlSchemaArgs { canister_id, derivation_origin, account }): Parameters, + ctx: RequestContext, + ) -> Result { + let principal = match Principal::from_text(&canister_id) { + Ok(p) => p, + Err(e) => return Ok(err(format!("invalid canister id: {e}"))), + }; + let arg_bytes = match calls::encode_unit_arg() { + Ok(b) => b, + Err(e) => return Ok(err(e)), + }; + // Anonymous per-app reads are disabled for now (the schema is itself gated by + // the caller's principal): reject with guidance instead of returning an empty + // entity list an agent would misread as "the app has no data model". + let target = match resolve_identity_target(derivation_origin) { + Ok(t) => t, + Err(e) => return Ok(err(e)), + }; + let Some(target) = target else { + return Ok(err(oql_needs_origin_error("Reading the OQL schema"))); + }; + let requested = Some(target.requested.clone()); + let origin = Some(target.origin.clone()); + let (agent, acted_as) = match self + .resolve_agent(&ctx, Some(target.origin.as_str()), account.as_deref(), "reading the schema") + .await + { + Ok(a) => a, + Err(e) => return Ok(err(e)), + }; + let reply = match calls::raw_call(&agent, principal, "schema", arg_bytes, true).await { + Ok(b) => b, + Err(e) => return Ok(err(format!("OQL schema call failed: {e}"))), + }; + let schema = calls::decode_schema_reply(&reply); + // An origin is required (rejected above if absent), so this is never anonymous. + let is_anonymous = false; + // #8: a COMPLETE canister_query per entity, carrying the SAME identity this + // schema was read under (so copying one doesn't drop to anon). + let example_queries = + calls::oql_query_examples(&canister_id, &schema, Some(target.origin.as_str()), account.as_deref()); + // #1: an EMPTY schema (no visible entities) for this authenticated account is + // "nothing visible here", not "the app has no data model". + let empty_note = if calls::oql_schema_is_empty(&schema) { + Some( + "This account sees no OQL entities on this canister — confirm the \ + derivation_origin/account are the ones the user uses in their browser." + .to_string(), + ) + } else { + None + }; + // Keep the primary block as the raw schema JSON (paste-able); surface the + // empty-note, the ready-to-run examples, and the identity note as SEPARATE + // blocks so none of them break the JSON for a copy-paste consumer. + let mut blocks = vec![schema.clone()]; + if let Some(note) = &empty_note { + blocks.push(note.clone()); + } + if !example_queries.is_empty() { + blocks.push(format!( + "Ready-to-run queries (read-only; each preserves this identity):\n{}", + example_queries.join("\n") + )); + } + blocks.push(format!("[{}]", identity_annotation(&target, acted_as.as_deref()))); + let output = calls::OqlSchemaOutput { + canister_id, + schema, + acted_as_principal: acted_as, + derived_for_origin: origin, + requested, + is_anonymous, + note: empty_note, + example_queries, + }; + Ok(ok_structured_blocks(blocks, &output)) + } + + #[tool( + description = "Read a canister's own API documentation — a prose \"how this app behaves\" guide covering units, auth, lifecycle, non-obvious semantics, mutation safety, polling rules, and gotchas — if it exposes a `getApiDoc`/`get_api_doc` method. Call this ONLY when get_canister_candid (or open_app) reports `api_doc_available: true`; most canisters have no such doc, and then the Candid types ARE the interface. Returns a STRUCTURED result in every case (never a bare error): on success `available: true` + the doc markdown; otherwise `available: false` with `expected` (true = interface read fine, no such method — don't retry) and `retry` (true = a transient/unreachable failure — retry) plus a `next` hint, so you can tell \"no doc here\" from \"couldn't reach it\".", + annotations(title = "Get a canister's API documentation", read_only_hint = true, destructive_hint = false, open_world_hint = true), + output_schema = schema_for_output::(), + )] + async fn get_canister_api_doc( + &self, + Parameters(calls::ApiDocArgs { canister_id }): Parameters, + ) -> Result { + let principal = match Principal::from_text(&canister_id) { + Ok(p) => p, + Err(e) => return Ok(err(format!("invalid canister id: {e}"))), + }; + // Read the interface to learn which naming the canister uses + // (getApiDoc vs get_api_doc); the doc is public, so call anonymously. + let did = calls::candid_service(&self.agent, principal).await; + // A structured "not available" (#6) so the agent can tell an EXPECTED + // absence (interface read fine, no api-doc method — don't retry) from an + // UNREACHABLE canister (couldn't read the interface / the call trapped — + // retry) instead of getting one opaque error for both. + let unavailable = |expected: bool, retry: bool, next: &str| { + let output = calls::ApiDocOutput { + canister_id: canister_id.clone(), + available: false, + method: None, + doc: None, + expected, + retry, + next: Some(next.to_string()), + }; + ok_structured(next.to_string(), &output) + }; + let Some(did) = did.as_deref() else { + // Interface unreadable: can't tell whether an api-doc method exists, and + // this is often transient (or access-restricted) — flag it retryable. + return Ok(unavailable( + false, + true, + "Couldn't read this canister's Candid interface, so its API-doc method (if any) is \ + unknown — this may be transient. Retry, or read the interface with \ + get_canister_candid.", + )); + }; + let method = match calls::api_doc_method(did) { + Some(m) => m, + None => { + // Interface read fine; the canister simply declares no api-doc method + // — expected for most canisters, and retrying won't change it. + return Ok(unavailable( + true, + false, + "This canister declares no `getApiDoc`/`get_api_doc` method — most canisters \ + don't. Use get_canister_candid for the interface; its api_doc_available flag \ + mirrors this.", + )); + } + }; + let arg_bytes = match calls::encode_unit_arg() { + Ok(b) => b, + Err(e) => return Ok(err(e)), + }; + let reply = match calls::raw_call(&self.agent, principal, method, arg_bytes, true).await { + Ok(b) => b, + Err(e) => { + // The method exists but the call failed — a transient/network issue, + // so retryable (distinct from the "no such method" case above). + return Ok(unavailable( + false, + true, + &format!("The {method} call failed ({e}); this is likely transient — retry."), + )); + } + }; + let doc = calls::decode_text_reply(&reply); + let output = calls::ApiDocOutput { + canister_id, + available: true, + method: Some(method.to_string()), + doc: Some(doc.clone()), + expected: false, + retry: false, + next: None, + }; + Ok(ok_structured(doc, &output)) + } + + /// The agent to sign calls with for a request, and the principal it signs as: + /// the shared anonymous agent (principal `None`) when `origin` is `None`, else + /// one backed by a short-lived account delegation for that Internet Identity + /// derivation `origin`, derived on demand from this connection's standing + /// credential. `origin` must be a VALIDATED derivation origin: the canonical one + /// from [`resolve_identity_target`], which every caller here now passes + /// (canister_update_call, both of canister_query's paths, and + /// get_canister_oql_schema). (get_app_principal and + /// list_app_accounts don't use this helper — they call + /// `Identities::delegated_identity_for` / `list_accounts` directly with a + /// `resolve_identity_target` origin.) `delegated_identity_for` re-canonicalizes + /// internally (idempotent), so an already-canonical origin is fine. `what` + /// names the action for the no-session error. + async fn resolve_agent( + &self, + ctx: &RequestContext, + origin: Option<&str>, + account: Option<&str>, + what: &str, + ) -> Result<(Agent, Option), String> { + match origin { + None => Ok((self.agent.clone(), None)), + Some(origin) => { + let session_id = authed_session(ctx) + .ok_or_else(|| format!("{what} as an app needs an authenticated session"))? + .session_id; + let delegated = self + .identities + .delegated_identity_for(&session_id, origin, account) + .await?; + let principal = delegated.sender().ok().map(|p| p.to_text()); + // Clone the injected agent and swap in the delegated identity: + // authenticated calls ride the host's boundary-node routing, + // never a second hard-coded endpoint. + let mut agent = self.agent.clone(); + agent.set_identity(delegated); + Ok((agent, principal)) + } + } + } + + /// Probe the app's OWN data canisters once (#3): fetch each candidate's Candid + /// anonymously (the interface is public) and record its OQL / api-doc capability + /// flags on the `DiscoveredCanister`. Only app-owned data candidates are probed + /// — never the frontend or a shared system canister (ledger/II/NNS) — so the + /// caller-gated data-access handle the flags feed stays correctly scoped. Probes + /// run CONCURRENTLY and fail soft: an unreadable interface just leaves the flags + /// null. Bounded by `MAX_CAPABILITY_PROBES` so a large manifest can't fan out + /// unboundedly. + async fn enrich_capabilities(&self, canisters: &mut [discover::DiscoveredCanister]) { + let targets: Vec<(usize, Principal)> = canisters + .iter() + .enumerate() + .filter(|(_, c)| discover::is_app_data_candidate(c)) + .filter_map(|(i, c)| Principal::from_text(&c.canister_id).ok().map(|p| (i, p))) + .take(MAX_CAPABILITY_PROBES) + .collect(); + if targets.is_empty() { + return; + } + let mut set = tokio::task::JoinSet::new(); + for (i, principal) in targets { + let agent = self.agent.clone(); + set.spawn(async move { + let did = calls::candid_service(&agent, principal).await; + // Only conclude flags when the interface was actually read; a failed + // read leaves them null (unknown), not a misleading `false`. + let flags = did + .as_deref() + .map(|d| (calls::has_oql(d), calls::api_doc_method(d).is_some())); + (i, flags) + }); + } + while let Some(res) = set.join_next().await { + if let Ok((i, Some((oql, api_doc)))) = res { + canisters[i].oql = Some(oql); + canisters[i].api_doc_available = Some(api_doc); + } + } + } + + #[tool( + description = "Make an UPDATE call (a state-changing call) on an Internet Computer canister method, with textual Candid in and out. 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.", + annotations(title = "Make a canister update call", read_only_hint = false, destructive_hint = true, idempotent_hint = false, open_world_hint = true), + output_schema = schema_for_output::(), + )] + async fn canister_update_call( + &self, + Parameters(calls::CanisterUpdateCallArgs { + canister_id, + method, + args, + derivation_origin, + account, + candid, + }): Parameters, + ctx: RequestContext, + ) -> Result { + let principal = match Principal::from_text(&canister_id) { + Ok(p) => p, + Err(e) => return Ok(err(format!("invalid canister id: {e}"))), + }; + // The interface to encode/decode against: the canister's own + // candid:service if exposed, else the caller-supplied `candid`. Update calls + // are never redirected (OQL is read-only), so no oql_query_redirect here. + let did = calls::resolve_did(&self.agent, principal, candid.as_deref()).await; + let arg_bytes = match calls::encode_args(did.as_deref(), &method, &args) { + Ok(b) => b, + Err(e) => return Ok(err(e)), + }; + // Resolve which principal to act as: none = anonymous; else the app's + // effective (canonical) II derivation origin, from the caller's explicit + // `derivation_origin` (obtained once via open_app / resolve_app). + let target = match resolve_identity_target(derivation_origin) { + Ok(t) => t, + Err(e) => return Ok(err(e)), + }; + let origin = target.as_ref().map(|t| t.origin.as_str()); + let (agent, acted_as_principal) = match self + .resolve_agent(&ctx, origin, account.as_deref(), "calling") + .await + { + Ok(a) => a, + Err(e) => return Ok(err(e)), + }; + let reply_bytes = match calls::raw_call(&agent, principal, &method, arg_bytes, false).await { + Ok(b) => b, + Err(e) => return Ok(err(format!("call failed: {e}"))), + }; + // Decode against the Candid interface so field names are recovered. + let reply = calls::decode_reply(did.as_deref(), &method, &reply_bytes); + let (derived_for_origin, requested, derivation_origin_source) = match &target { + Some(t) => (Some(t.origin.clone()), Some(t.requested.clone()), Some(t.source.clone())), + None => (None, None, None), + }; + let is_anonymous = target.is_none(); + // Keep the primary text block pure textual Candid (paste-able); surface the + // identity note (so a wrong-principal is visible to text-only clients) as a + // SEPARATE block rather than contaminating the reply. + let mut blocks = vec![reply.clone()]; + if let Some(t) = &target { + let acted = acted_as_principal.as_deref().unwrap_or(""); + blocks.push(format!("[{}]", identity_annotation(t, Some(acted)))); + } + let output = calls::CanisterUpdateCallOutput { + canister_id, method, reply, + acted_as_principal, derived_for_origin, requested, derivation_origin_source, + is_anonymous, + }; + Ok(ok_structured_blocks(blocks, &output)) + } + + #[tool( + description = "If the request is about a specific app or the user's own data in it (a balance, holdings, a profile, \"what can it do\"), start with open_app first to resolve the app and discover its canisters, then compose the answer from what it returns, since there is rarely a dedicated per-feature tool. READ from an Internet Computer canister — provide EITHER a Candid `query` method OR an OQL query (exactly one). `method` is a query function from the canister's Candid interface, invoked with textual-Candid `args`; `oql` is an OQL query as a JSON object string, run against the canister's `execute` method (no Candid escaping — write plain JSON). Use `oql` when get_canister_candid reports `oql: true` (a Candid `method` query is then REJECTED — read via OQL); use `method` for a plain query canister such as a ledger. The `oql` path REQUIRES `derivation_origin` (per-app data is caller-gated; an anonymous OQL read is rejected for now) and returns `columns` + `rows` (a markdown table) with `has_more` for paging; on an empty result it validates the query's `start` against the schema and returns valid_entities + a did_you_mean repair. The `method` path may be anonymous, or pass `derivation_origin` + `account` to read AS your account; it returns the decoded reply in textual Candid. Get `derivation_origin` from open_app / resolve_app (not a raw URL), and the OQL entity/field names from get_canister_oql_schema. For state changes use canister_update_call.", + annotations(title = "Query a canister (Candid method or OQL)", read_only_hint = true, destructive_hint = false, open_world_hint = true), + output_schema = schema_for_output::(), + )] + async fn canister_query( + &self, + Parameters(calls::CanisterQueryArgs { + canister_id, + method, + args, + oql, + derivation_origin, + account, + candid, + }): Parameters, + ctx: RequestContext, + ) -> Result { + let principal = match Principal::from_text(&canister_id) { + Ok(p) => p, + Err(e) => return Ok(err(format!("invalid canister id: {e}"))), + }; + // Normalize: an empty/whitespace-only `method`/`oql` counts as absent, so a + // blank string doesn't masquerade as "both provided". + let method = method.map(|m| m.trim().to_string()).filter(|m| !m.is_empty()); + let oql = oql.filter(|o| !o.trim().is_empty()); + match (method, oql) { + (Some(_), Some(_)) => Ok(err( + "provide EITHER `method` (a Candid query) OR `oql` (an OQL query) — not both." + .into(), + )), + (None, None) => Ok(err( + "canister_query needs a read to run: pass `method` (a Candid query function) or \ + `oql` (an OQL query object). For a canister that exposes an OQL surface \ + (get_canister_candid reports oql: true) use `oql`; otherwise use `method`." + .into(), + )), + (Some(method), None) => { + self.canister_candid_query( + &ctx, principal, canister_id, method, args, derivation_origin, account, candid, + ) + .await + } + (None, Some(oql)) => { + self.canister_oql_query(&ctx, principal, canister_id, oql, derivation_origin, account) + .await + } + } + } + + /// `canister_query`'s Candid-`method` path: a read-only query call, encoded and + /// decoded against the canister's Candid interface. Rejected on an OQL canister + /// (data reads go through the `oql` path); may be anonymous or as an app account. + #[allow(clippy::too_many_arguments)] + async fn canister_candid_query( + &self, + ctx: &RequestContext, + principal: Principal, + canister_id: String, + method: String, + args: String, + derivation_origin: Option, + account: Option, + candid: Option, + ) -> Result { + // The interface to encode/decode against: the canister's own candid:service + // if exposed, else the caller-supplied `candid`. + let did = calls::resolve_did(&self.agent, principal, candid.as_deref()).await; + // Prefer OQL: a canister that exposes an OQL query surface must be READ with + // an OQL query, so reject a raw Candid `method` query here. Fail fast, before + // encoding args. + if let Some(msg) = calls::oql_query_redirect(did.as_deref()) { + return Ok(err(msg)); + } + // Read/write split: a query call to an UPDATE method is rejected by the + // replica at runtime, so when the interface is readable and `method` is + // declared as an update (not a query), fail fast with a clear pointer to + // canister_update_call instead of an opaque failure. Fail OPEN when the + // interface can't be read or the method isn't declared (is_query_method → + // None): the IC then decides, matching the old permissive behavior. (The + // reverse — a query method called as an update — is valid on the IC, so + // canister_update_call stays permissive.) + if let Some(did_text) = did.as_deref() { + if calls::is_query_method(did_text, &method) == Some(false) { + return Ok(err(format!( + "`{method}` is not a `query` method on this canister — its Candid signature is \ + an update method, which the replica refuses to run as a query. Call it with \ + canister_update_call instead (a state-changing update call)." + ))); + } + } + let arg_bytes = match calls::encode_args(did.as_deref(), &method, &args) { + Ok(b) => b, + Err(e) => return Ok(err(e)), + }; + let target = match resolve_identity_target(derivation_origin) { + Ok(t) => t, + Err(e) => return Ok(err(e)), + }; + let origin = target.as_ref().map(|t| t.origin.as_str()); + let (agent, acted_as_principal) = match self + .resolve_agent(ctx, origin, account.as_deref(), "querying") + .await + { + Ok(a) => a, + Err(e) => return Ok(err(e)), + }; + let reply_bytes = match calls::raw_call(&agent, principal, &method, arg_bytes, true).await { + Ok(b) => b, + Err(e) => return Ok(err(format!("query failed: {e}"))), + }; + let reply = calls::decode_reply(did.as_deref(), &method, &reply_bytes); + let (derived_for_origin, requested, derivation_origin_source) = match &target { + Some(t) => (Some(t.origin.clone()), Some(t.requested.clone()), Some(t.source.clone())), + None => (None, None, None), + }; + let is_anonymous = target.is_none(); + // #1: an anonymous query whose reply LOOKS empty is most likely an auth + // artifact (per-app data is caller-gated), not "no data". Computed only from + // local facts (anonymous + empty-looking reply); conservative, so a reply + // with real content never trips it. + let note = (is_anonymous && calls::candid_reply_is_empty(&reply)).then(|| { + calls::anonymous_empty_note( + "this query call", + "the app's canonical `derivation_origin` (from open_app / resolve_app)", + ) + }); + let mut blocks = vec![reply.clone()]; + if let Some(n) = ¬e { + blocks.push(n.clone()); + } + if let Some(t) = &target { + let acted = acted_as_principal.as_deref().unwrap_or(""); + blocks.push(format!("[{}]", identity_annotation(t, Some(acted)))); + } + let output = calls::CanisterQueryOutput { + canister_id, + mode: "candid".to_string(), + method: Some(method), + reply: Some(reply), + columns: Vec::new(), + rows: Vec::new(), + has_more: false, + acted_as_principal, + derived_for_origin, + requested, + derivation_origin_source, + is_anonymous, + note, + valid_entities: None, + did_you_mean: None, + }; + Ok(ok_structured_blocks(blocks, &output)) + } + + /// `canister_query`'s `oql` path (the former run_canister_oql_query tool): wrap + /// the JSON query as `execute`'s single text arg, run it, and decode the tabular + /// reply. Anonymous per-app OQL reads are disabled, so a `derivation_origin` is + /// REQUIRED; an empty/failed result is diagnosed against the schema for the SAME + /// principal (#1/#7). + async fn canister_oql_query( + &self, + ctx: &RequestContext, + principal: Principal, + canister_id: String, + oql: String, + derivation_origin: Option, + account: Option, + ) -> Result { + // Validate the query is a JSON object and wrap it as `execute`'s single text + // arg — the model writes plain JSON, we do the Candid encoding. + let query_json = match calls::normalize_oql_query(&oql) { + Ok(s) => s, + Err(e) => return Ok(err(e)), + }; + let arg_bytes = match calls::encode_text_arg(&query_json) { + Ok(b) => b, + Err(e) => return Ok(err(e)), + }; + // Anonymous per-app reads are disabled for now (this canister gates data by + // the caller's principal), so an OQL query REQUIRES a derivation origin. + let target = match resolve_identity_target(derivation_origin) { + Ok(t) => t, + Err(e) => return Ok(err(e)), + }; + let Some(target) = target else { + return Ok(err(oql_needs_origin_error("Running an OQL query"))); + }; + let requested = Some(target.requested.clone()); + let origin = Some(target.origin.clone()); + let derivation_origin_source = Some(target.source.clone()); + let (agent, acted_as) = match self + .resolve_agent(ctx, Some(target.origin.as_str()), account.as_deref(), "querying") + .await + { + Ok(a) => a, + Err(e) => return Ok(err(e)), + }; + let identity_note = format!("[{}]", identity_annotation(&target, acted_as.as_deref())); + // An origin is required (rejected above if absent), so this is never anonymous. + let is_anonymous = false; + let reply = match calls::raw_call(&agent, principal, "execute", arg_bytes, true).await { + Ok(b) => b, + Err(e) => { + // A trap is the OTHER way an invalid `start` shows up (#7): validate + // it against the schema for THIS principal and fold the repair into + // the error. Failed context: only the unknown-`start` repair is + // appended, never a "came back empty" note. + let d = diagnose_empty_oql(&agent, principal, &query_json, is_anonymous, EmptyContext::Failed).await; + let mut msg = format!("OQL execute failed: {e}"); + if let Some(note) = d.note { + msg.push_str(&format!("\n\n{note}")); + } + return Ok(err(msg)); + } + }; + // Decode the reply against the canister's interface so cell/field names are + // recovered (the wire format hashes them). + let did = calls::resolve_did(&agent, principal, None).await; + match calls::parse_execute_reply(did.as_deref(), &reply) { + calls::OqlResult::Table { columns, rows, has_more } => { + // Validate-on-empty (#1/#7): a 0-row result is diagnosed with facts + // from the SAME principal — unknown-`start` repair, or a benign + // "0 rows for this account" — never by probing others. + let mut diag = if rows.is_empty() { + diagnose_empty_oql(&agent, principal, &query_json, is_anonymous, EmptyContext::EmptyResult).await + } else { + OqlEmptyDiagnosis::none() + }; + if rows.is_empty() && diag.note.is_none() { + diag.note = Some(format!( + "Query matched 0 rows for this account{}.", + calls::oql_query_start(&query_json) + .map(|s| format!(" (start: \"{s}\")")) + .unwrap_or_default() + )); + } + let mut blocks = vec![calls::render_table(&columns, &rows, has_more)]; + if let Some(note) = &diag.note { + blocks.push(note.clone()); + } + blocks.push(identity_note); + let output = calls::CanisterQueryOutput { + canister_id, + mode: "oql".to_string(), + method: None, + reply: None, + columns, + rows, + has_more, + acted_as_principal: acted_as, + derived_for_origin: origin, + requested, + derivation_origin_source, + is_anonymous, + note: diag.note, + valid_entities: diag.valid_entities, + did_you_mean: diag.did_you_mean, + }; + Ok(ok_structured_blocks(blocks, &output)) + } + calls::OqlResult::QueryError(msg) => { + // The canister returned its error arm. An invalid `start` can land + // here too, so enrich with the schema-based repair (#7). + let d = diagnose_empty_oql(&agent, principal, &query_json, is_anonymous, EmptyContext::Failed).await; + let mut text = format!("the canister returned an OQL error: {msg}"); + if let Some(note) = d.note { + text.push_str(&format!("\n\n{note}")); + } + Ok(err(text)) + } + calls::OqlResult::Unrecognized(raw) => { + // Not a recognizable OQL result — hand back the raw decoded reply so + // the model still has the data. Carry a `note` (rather than null) so a + // structured-only consumer doesn't misread empty columns/rows as + // "no data" when the reply actually carried content (in the text block). + let unparsed_note = + "This reply couldn't be parsed as an OQL table; the raw decoded reply is in the \ + text block. This is NOT an empty result — read the raw reply for the data." + .to_string(); + let blocks = vec![ + format!("(Could not parse this as an OQL table; raw reply below.)\n\n{raw}"), + identity_note, + ]; + let output = calls::CanisterQueryOutput { + canister_id, + mode: "oql".to_string(), + method: None, + reply: None, + columns: Vec::new(), + rows: Vec::new(), + has_more: false, + acted_as_principal: acted_as, + derived_for_origin: origin, + requested, + derivation_origin_source, + is_anonymous, + note: Some(unparsed_note), + valid_entities: None, + did_you_mean: None, + }; + Ok(ok_structured_blocks(blocks, &output)) + } + } + } + + #[tool( + description = "Get the Internet Computer principal you act as at an app, without making a canister call. Identify the app by `derivation_origin` — its EXACT canonical Internet Identity derivation origin (NOT necessarily the visible website URL, and never inferred from an alternativeOrigins list). Get it from open_app / resolve_app (which turn an app name or URL into the derivation origin under the guessed-domain gate); this tool does NOT accept a raw website URL. The account delegation is derived on demand from this connection's standing Internet Identity credential. By default this resolves the app's default account; pass `account` (a name from list_app_accounts) for a specific one. The result returns the `principal` plus `derived_for_origin` and `requested` — compare them to catch a canonicalization surprise. If the principal looks wrong, the derivation origin is wrong: re-resolve the app with open_app / resolve_app rather than guessing an origin.", + annotations(title = "Get your principal at an app", read_only_hint = true, destructive_hint = false, open_world_hint = true), + output_schema = schema_for_output::(), + )] + async fn get_app_principal( + &self, + Parameters(identities::GetPrincipalArgs { derivation_origin, account }): Parameters, + ctx: RequestContext, + ) -> Result { + let session_id = match authed_session(&ctx) { + Some(s) => s.session_id, + None => return Ok(err("getting an app principal needs an authenticated session".into())), + }; + // `derivation_origin` is a required String here, so this always yields a + // target (never the anonymous None) unless it fails validation. + let target = match resolve_identity_target(Some(derivation_origin)) { + Ok(Some(t)) => t, + Ok(None) => return Ok(err("`derivation_origin` must not be empty".into())), + Err(e) => return Ok(err(e)), + }; + let delegated = match self + .identities + .delegated_identity_for(&session_id, &target.origin, account.as_deref()) + .await + { + Ok(d) => d, + Err(e) => return Ok(err(e)), + }; + let principal = match delegated.sender() { + Ok(p) => p.to_text(), + Err(e) => return Ok(err(format!("could not derive principal for {}: {e}", target.origin))), + }; + // Surface a read-only session (H2) so the LLM won't attempt (and have the + // IC reject at ingress) canister-management updates. + let read_only = self.identities.is_read_only(&session_id).await == Some(true); + let mut text = format!("{principal}\n\n[{}]", identity_annotation(&target, None)); + if read_only { + text.push_str( + "\n\n(This Internet Identity session is READ-ONLY: reads work, but canister \ + management — create/install/start/stop/delete, and icp_canister_status — needs \ + update access. Ask the user to reconnect with the read-only option turned OFF.)", + ); + } + let output = identities::PrincipalOutput { + derived_for_origin: target.origin, + requested: target.requested, + derivation_origin_source: target.source, + account, + principal, + read_only, + }; + Ok(ok_structured(text, &output)) + } + + #[tool( + description = "List the user's Internet Identity accounts at an app. Identify the app by `derivation_origin` — its EXACT canonical II derivation origin (not necessarily the visible URL). Get it from open_app / resolve_app (which turn an app name or URL into the derivation origin under the guessed-domain gate); this tool does NOT accept a raw website URL. Internet Identity gives the user a distinct principal per derivation origin, and within it they may hold several accounts: a default account everyone gets automatically (the anchor's current, user-controllable default there), plus any named accounts they created. Use this before acting on the user's behalf: if there's only the default account, just proceed (canister_query / canister_update_call / get_app_principal with no `account`); if there are several, pick one with the user by passing its name as `account`. Returns each account's name (the default has none), number, and last-used time, plus `derived_for_origin`/`requested` — if these accounts don't match what the user sees in their browser, the derivation origin is wrong (re-resolve the app rather than guessing). Requires an authenticated session.", + annotations(title = "List your accounts at an app", read_only_hint = true, destructive_hint = false, open_world_hint = true), + output_schema = schema_for_output::(), + )] + async fn list_app_accounts( + &self, + Parameters(identities::ListAccountsArgs { derivation_origin }): Parameters, + ctx: RequestContext, + ) -> Result { + let session_id = match authed_session(&ctx) { + Some(s) => s.session_id, + None => return Ok(err("listing your accounts needs an authenticated session".into())), + }; + // `derivation_origin` is a required String here, so this always yields a + // target (never the anonymous None) unless it fails validation. + let target = match resolve_identity_target(Some(derivation_origin)) { + Ok(Some(t)) => t, + Ok(None) => return Ok(err("`derivation_origin` must not be empty".into())), + Err(e) => return Ok(err(e)), + }; + match self.identities.list_accounts(&session_id, &target.origin).await { + Ok(accounts) => { + let text = format_accounts(&target, &accounts); + let output = identities::AccountsOutput { + derived_for_origin: target.origin, + requested: target.requested, + derivation_origin_source: target.source, + accounts: accounts.iter().map(identities::AccountEntry::from).collect(), + }; + Ok(ok_structured(text, &output)) + } + Err(e) => Ok(err(e)), + } + } + + #[tool( + description = "ROUTING. Run this chain before answering anything that names, or implies, a specific app or the user's own data in one app (a balance, holdings, bookings, positions, profile, or \"what can it do\"): (1) Is the request about a specific APP's functionality or the user's data in it, rather than the IC protocol itself? (2) Is that app on the Internet Computer? If unsure, THIS tool (or icp_find_app_by_name) is how you find out: a known app or an IC-verifiable URL resolves, while an unrecognized app name, or a URL with no Internet Computer evidence, is refused rather than guessed. (3) If so, START HERE: one open_app call resolves the app AND discovers its service. An app feature is almost never a single named tool (there is no \"get_balance\"); you COMPOSE it from what open_app returns, reading the discovered canisters and their `oql`/`api_doc_available` flags to see how to READ (canister_query, including OQL) or ACT (canister_update_call). So never conclude \"there's no tool for that\" from the flat tool list without opening the app first. Open an Internet Computer app in ONE call, given its NAME or its URL — the recommended entry point when a user names an app. It resolves the app's Internet Identity derivation origin (like resolve_app) AND discovers the canisters behind it (like discover_app_canisters) together, so you don't chain those yourself. Pass a NAME (e.g. \"MULTI/DEX\", \"Oisy\", \"NNS\") or a URL (e.g. \"https://oisy.com\"): a name or bare host is matched to the built-in known-app registry FIRST (so even a wrong-TLD guess like \"multidex.com\" repairs to the canonical URL), and an explicit https:// URL is resolved as given. NEVER fabricate a domain from a name — an unknown bare name is refused with instructions to find the real URL (web search / ask the user), and a URL with no Internet Computer evidence is refused, both instead of guessing a wrong identity. Returns `app_url` (the one used), `derivation_origin` (+ its source) to act with, `alternative_origins`, and the discovered `canisters` (with provenance/labels AND per-canister `oql`/`api_doc_available` capability flags, from a one-shot Candid probe of the app's own canisters). A canister flagged `oql` holds the app's data, GATED BY THE CALLER's principal: to read the USER's own data (\"my …\", \"our …\") pass the returned `derivation_origin` to get_canister_oql_schema (for the entity/field names) and to canister_query (with the `oql` argument, to run the query). Those OQL reads REQUIRE `derivation_origin` — not `app_url` — and reject an anonymous read for now. No authenticated session required for open_app itself (no principal is derived here). Narrower tools remain for single steps: icp_find_app_by_name (name→URL only), resolve_app (origin only), discover_app_canisters (canisters only).", + annotations(title = "Open an app (resolve origin + discover canisters)", read_only_hint = true, destructive_hint = false, open_world_hint = true), + output_schema = schema_for_output::(), + )] + async fn open_app( + &self, + Parameters(discover::OpenAppArgs { app }): Parameters, + ) -> Result { + // Clean the query like every other identity input: trim, reject empty, and + // reject control chars — otherwise they'd be echoed into the UnknownName + // error via find_app_by_name's note (the rest of the surface avoids this). + let query = match clean_identity_arg("app", &app) { + Ok(q) => q, + Err(e) => return Ok(err(e)), + }; + // Disambiguate name vs URL, repairing a known app's name/wrong-TLD to its + // canonical URL and refusing a bare unknown name rather than guessing. + let (app_url, app_url_source) = match discover::classify_app_query(&query) { + discover::AppQuery::Known(m) => (m.app_url, "known_app_registry"), + discover::AppQuery::Url(u) => (u, "as_provided"), + discover::AppQuery::UnknownName => { + // Echo find_app_by_name's web-search/ask guidance (it builds the + // "not a known app; don't guess a domain" note from the registry). + return Ok(err(discover::find_app_by_name(&query).note)); + } + }; + let app_url = match clean_app_url(&app_url) { + Ok(u) => u, + Err(e) => return Ok(err(e)), + }; + // Resolve (the gating step) and discover (canisters) run CONCURRENTLY so the + // happy path doesn't pay for discovery sequentially. Discovery is spawned so it + // can be ABORTED the moment resolution fails or the IC-evidence gate refuses: + // that makes a refused/guessed origin return PROMPTLY (no waiting out + // discovery's timeout) and cancels the REST of the crawl. It does not prevent + // discovery from starting — because it runs concurrently with the gate, a few + // requests may already be in flight before abort() — but those are bounded by + // discover's own size/count caps, and the common (accepted) case keeps the + // full concurrency win. Gating before starting discovery would eliminate that + // residual I/O only by serializing the two, which the happy path pays for. + let discovery = tokio::spawn({ + let url = app_url.clone(); + async move { discover::discover(&url).await } + }); + let resolved = match discover::resolve_app_identity(&app_url, true).await { + Ok(r) => r, + Err(e) => { + discovery.abort(); + return Ok(err(app_url_error_with_guidance(&app_url, e))); + } + }; + // The same guessed-domain gate as resolve_app / the identity routes: an + // assumed origin with no IC evidence is refused, not resolved. + if resolved.derivation_origin_source == discover::DerivationSource::AppUrlDefault + && resolved.application_is_ic == Some(false) + { + discovery.abort(); + return Ok(err(unverified_app_url_error(&resolved.application_origin))); + } + let effective = identities::target_origin(&resolved.derivation_origin); + let note = resolution_note(&resolved, &effective); + // Origin resolution succeeded and passed the IC gate, so the derivation + // context stands regardless of discovery. Collect the concurrent discovery + // now, keeping a FAILURE distinct from "found nothing" (a JoinError — i.e. a + // panic — is treated as a discovery failure, never a hard error). + let (canisters, omitted, discovery_error) = match discovery.await { + Ok(Ok(d)) => (d.canisters, d.omitted, None), + Ok(Err(e)) => (Vec::new(), 0, Some(e)), + Err(join_err) => (Vec::new(), 0, Some(format!("discovery task error: {join_err}"))), + }; + // Enrich the app's OWN data canisters with OQL / api-doc capability flags + // (#3), so open_app hands back a ready-to-use handle: which canister holds + // the (caller-gated) data, and the origin to read it as the user. + let mut discovered: Vec = + canisters.iter().map(discover::DiscoveredCanister::from).collect(); + self.enrich_capabilities(&mut discovered).await; + // The ready-to-use data-access handle: the resolved origin, scoped (by + // is_app_data_candidate) to the app's own OQL canisters — never a guessed + // origin, never II/NNS/ledger/frontend. `derivation_origin` only: the + // data-access note names the OQL read tools (get_canister_oql_schema, + // canister_query), which take `derivation_origin`, not a website URL, so + // offering app_url here would send the agent to a param those tools don't accept. + let handle = format!("derivation_origin=\"{effective}\""); + + let mut text = format!( + "app_url: {app_url} ({app_url_source})\nderivation_origin: {effective} ({})\n", + resolved.derivation_origin_source.as_str() + ); + if !resolved.alternative_origins.is_empty() { + text.push_str(&format!("alternative_origins: {}\n", resolved.alternative_origins.join(", "))); + } + if let Some(e) = &discovery_error { + text.push_str(&format!( + "\nCanister discovery FAILED ({e}) — this is NOT \"the app has no canisters\"; the \ + derivation origin above is still valid. Retry discover_app_canisters, or proceed \ + with a canister id you already have.\n" + )); + } else if discovered.is_empty() { + text.push_str("\nNo canisters discovered — the app appears to declare none.\n"); + } else { + text.push_str("\nCanisters:\n"); + for c in &discovered { + text.push_str(&render_canister_line(c)); + } + if omitted > 0 { + text.push_str(&format!( + "(+{omitted} more findings dropped by the output cap; the list is \ + authority-ordered, so the least authoritative entries were cut first)\n" + )); + } + } + if let Some(n) = ¬e { + text.push_str(&format!("\nNOTE: {n}")); + } + if let Some(access) = data_access_note(&discovered, Some(&handle)) { + text.push_str(&format!("\n\n{access}")); + } + text.push_str( + "\n\nNext: inspect a canister with get_canister_candid — its oql / api_doc_available \ + flags say whether to read via OQL and whether get_canister_api_doc has a doc \ + (only call it when api_doc_available). To act as the user, pass the derivation_origin \ + above to canister_query (read) and canister_update_call (write); for an OQL canister, \ + call get_canister_oql_schema for the entity/field names, then canister_query with \ + the `oql` argument — plus an optional account from list_app_accounts. A \"my/our…\" \ + question is an AUTHENTICATED read: pass the origin.", + ); + let output = discover::OpenAppOutput { + app_url, + app_url_source: app_url_source.to_string(), + application_origin: resolved.application_origin, + derivation_origin: effective, + derivation_origin_source: resolved.derivation_origin_source.as_str().to_string(), + alternative_origins: resolved.alternative_origins, + application_is_ic: resolved.application_is_ic, + canisters: discovered, + omitted, + discovery_error, + note, + }; + Ok(ok_structured(text, &output)) + } + + #[tool( + description = "Resolve an application URL to its Internet Identity derivation context, so you don't have to figure out the derivation origin yourself. `app_url` must be a URL you actually HAVE — from the user, from icp_find_app_by_name, or from a web search of the app's official site. NEVER guess or fabricate a domain from an app's name (when you only know a NAME, call icp_find_app_by_name first): a lookalike domain is an unrelated or squatted site, and this tool REFUSES to resolve an origin that shows no evidence of being an Internet Computer app rather than hand back a wrong identity. Returns the `application_origin`, the `derivation_origin` to pass to the identity tools, how it was determined (`derivation_origin_source`: \"declared\" — the app published it in /.well-known/ic-app.json, authoritative; \"known\" — from the connector's built-in registry of well-known custom-derivation-origin apps (e.g. NNS, Oisy, MULTI/DEX), used only when the app declares none; or \"app_url_default\" — the origin IS IC-served but declares nothing, so it was assumed to be its own derivation origin, correct only if the app has no custom one), and the app's `alternative_origins` (informational — the INVERSE relation, never use it to infer the derivation origin). This does NOT return a principal — it resolves the origin only, since you haven't picked an account; to get the principal you act as, pass the returned `derivation_origin` to get_app_principal (choosing an `account`) or list_app_accounts. Use this first when you only know an app's URL; no authenticated session is required.", + annotations(title = "Resolve an app's derivation origin", read_only_hint = true, destructive_hint = false, open_world_hint = true), + output_schema = schema_for_output::(), + )] + async fn resolve_app( + &self, + Parameters(identities::ResolveAppArgs { app_url }): Parameters, + ) -> Result { + let app_url = match clean_app_url(&app_url) { + Ok(u) => u, + Err(e) => return Ok(err(e)), + }; + // This tool surfaces `alternative_origins`, so fetch them (unlike the + // identity hot path in resolve_identity_target). It resolves the derivation + // origin ONLY — no principal is derived here, because the caller hasn't + // chosen an account; that's get_app_principal / list_app_accounts' job. So + // this needs no authenticated session. + let resolved = match discover::resolve_app_identity(&app_url, true).await { + Ok(r) => r, + Err(e) => return Ok(err(app_url_error_with_guidance(&app_url, e))), + }; + // A guessed/lookalike domain fails HARD instead of resolving to a + // plausible-but-wrong identity: no declared origin, not a known app, and + // no sign the origin is even served from the IC. The error carries the + // "did you mean" repair and the legitimate ways to obtain the URL. + if resolved.derivation_origin_source == discover::DerivationSource::AppUrlDefault + && resolved.application_is_ic == Some(false) + { + return Ok(err(unverified_app_url_error(&resolved.application_origin))); + } + let effective = identities::target_origin(&resolved.derivation_origin); + let note = resolution_note(&resolved, &effective); + let mut text = format!( + "application_origin: {}\nderivation_origin: {} ({})\n", + resolved.application_origin, effective, resolved.derivation_origin_source.as_str() + ); + if !resolved.alternative_origins.is_empty() { + text.push_str(&format!("alternative_origins: {}\n", resolved.alternative_origins.join(", "))); + } + if let Some(n) = ¬e { + text.push_str(&format!("\nNOTE: {n}")); + } + let output = identities::ResolveAppOutput { + application_origin: resolved.application_origin, + derivation_origin: effective, + derivation_origin_source: resolved.derivation_origin_source.as_str().to_string(), + alternative_origins: resolved.alternative_origins, + application_is_ic: resolved.application_is_ic, + note, + }; + Ok(ok_structured(text, &output)) + } + + #[tool( + description = "Discover the Internet Computer canisters behind a web domain (e.g. \"oisy.com\"). The domain must be one you actually have (from the user, icp_find_app_by_name, or a web search) — NEVER a domain guessed from an app's name; when you only know a NAME, call icp_find_app_by_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.", + annotations(title = "Discover canisters behind a domain", read_only_hint = true, destructive_hint = false, open_world_hint = true), + output_schema = schema_for_output::(), + )] + async fn discover_app_canisters( + &self, + Parameters(discover::DiscoverCanistersArgs { domain }): Parameters, + ) -> Result { + match discover::discover(&domain).await { + Ok(d) if !d.canisters.is_empty() => { + // Probe the app's own data canisters for OQL / api-doc capabilities + // (#3), mirroring open_app. discover_app_canisters resolves no origin, + // so the data-access note points at resolve_app / open_app for it. + let mut discovered: Vec = + d.canisters.iter().map(discover::DiscoveredCanister::from).collect(); + self.enrich_capabilities(&mut discovered).await; + let mut out = format!("Canisters discovered for {domain}:\n"); + for c in &discovered { + out.push_str(&render_canister_line(c)); + } + if d.omitted > 0 { + out.push_str(&format!( + "(+{} more findings dropped by the output cap; the list is \ + authority-ordered, so the least authoritative entries were cut first)\n", + d.omitted + )); + } + out.push_str( + "\n`ai-connect.html` and `ic-app.json` entries are DECLARED by the app itself \ + (its main backend, and its own canister manifest with roles) — treat them as \ + the app's claim about its composition. The `header` (x-ic-canister-id) entry \ + is the frontend/asset canister. Others come from env.json or the JS bundle \ + and may include multiple environments (prefer the production/IC ids). A \ + «name» (type) is the IC dashboard's label for that id. `[oql]`/`[api-doc]` \ + flags are from a Candid probe of the app's own canisters. Confirm an interface \ + with get_canister_candid before calling.", + ); + if let Some(access) = data_access_note(&discovered, None) { + out.push_str(&format!("\n\n{access}")); + } + let output = discover::DiscoverOutput { + domain, + canisters: discovered, + omitted: d.omitted, + }; + Ok(ok_structured(out, &output)) + } + Ok(d) => { + let mut text = + format!("No IC canisters found for {domain} — is it served from the Internet Computer?"); + if let Some(m) = discover::similar_known_app(&domain) { + text.push_str(&format!( + " DID YOU MEAN {}? Its real URL is {} (derivation origin {}).", + m.name, m.app_url, m.derivation_origin + )); + } + text.push_str( + " If you GUESSED this domain from an app name, don't guess again: call \ + icp_find_app_by_name with the name, or WEB SEARCH the app's official URL, \ + or ask the user for it.", + ); + let output = discover::DiscoverOutput::from((domain, d)); + Ok(ok_structured(text, &output)) + } + // A fetch failure on a guessed domain (DNS-dead lookalikes like + // "multi.dex") carries the same repair as the identity routes, so it + // reads as "wrong URL — here's the real one", not a transient error. + Err(e) => Ok(err(app_url_error_with_guidance(&domain, e))), + } + } + + #[tool( + description = "Find Internet Computer canisters by NAME. Searches the IC dashboard's service registries — the ICRC token ledgers (e.g. ckBTC, ckETH, ckUSDC, SNS tokens) by symbol/name, and the SNS project catalog by name — and returns matching canister ids. Use this when the user names a token, project, or service (e.g. \"ckUSDC\") rather than a canister id; then confirm with get_canister_candid, read with canister_query, and write with canister_update_call. (No public name-search exists over arbitrary canisters; this covers the IC's labelled services.)", + annotations(title = "Find canisters by name", read_only_hint = true, destructive_hint = false, open_world_hint = true), + output_schema = schema_for_output::(), + )] + async fn icp_find_canister_by_name( + &self, + Parameters(discover::FindCanisterArgs { query }): Parameters, + ) -> Result { + match discover::search_by_name(&query).await { + Ok(matches) if !matches.is_empty() => { + let mut out = format!("Canisters matching \"{query}\":\n"); + for m in &matches { + out.push_str(&format!( + "- {} — {} [{}]{}\n", + m.canister_id, + m.name, + m.kind, + m.note.as_deref().map(|n| format!(" — {n}")).unwrap_or_default(), + )); + } + out.push_str( + "\nConfirm an interface with get_canister_candid, then read with canister_query and \ + write with canister_update_call. \ + For an SNS match the id is the project root — icp_lookup_canister_info_by_id it to learn more.", + ); + let output = discover::FindCanisterOutput::from((query, matches)); + Ok(ok_structured(out, &output)) + } + Ok(_) => { + let text = format!( + "No named canisters found matching \"{query}\". This searches known tokens (ICRC \ + ledgers) and SNS projects, so an arbitrary canister won't appear unless it's a \ + labelled service. If you have a website, try discover_app_canisters; if you already \ + have a canister id, try icp_lookup_canister_info_by_id or get_canister_candid." + ); + let output = discover::FindCanisterOutput::from((query, Vec::new())); + Ok(ok_structured(text, &output)) + } + Err(e) => Ok(err(e)), + } + } + + #[tool( + description = "Find a well-known Internet Computer APP by NAME and get its front-end URL and Internet Identity derivation origin — the missing name→app step (discovery otherwise needs a URL). ALWAYS call this FIRST when the user names an app and you don't have its URL — NEVER guess a domain from the name (lookalike domains like .com/.app are unrelated or squatted sites, and the URL-taking tools refuse origins with no IC evidence). Offline and instant. Covers only a small built-in set (NNS, Oisy, MULTI/DEX, ICPSwap). For ANY other app there is NO on-chain name→URL directory, so the result's `note` directs you to WEB SEARCH the app's official URL (or ask the user) and then use resolve_app / discover_app_canisters. Returns `matches` (each with `app_url` + `derivation_origin`) — usually one, or none. This is name→app; for a token/service→canister-id use icp_find_canister_by_name, and for a URL→canisters use discover_app_canisters.", + annotations(title = "Find a well-known app by name", read_only_hint = true, destructive_hint = false, open_world_hint = false), + output_schema = schema_for_output::(), + )] + async fn icp_find_app_by_name( + &self, + Parameters(discover::FindAppArgs { name }): Parameters, + ) -> Result { + let output = discover::find_app_by_name(&name); + let mut text = String::new(); + for m in &output.matches { + text.push_str(&format!( + "{} — {} (derivation_origin: {})\n", + m.name, m.app_url, m.derivation_origin + )); + } + text.push_str(&output.note); + Ok(ok_structured(text, &output)) + } + + #[tool( + description = "Identify what an Internet Computer canister IS, from the IC dashboard: its label/name (e.g. \"ICP Ledger\"), type (e.g. \"ledger\"), controllers, hosting subnet, module hash, language, and latest upgrade proposal. Use this to make sense of a bare canister id — e.g. one returned by discover_app_canisters.", + annotations(title = "Identify a canister", read_only_hint = true, destructive_hint = false, open_world_hint = true), + output_schema = schema_for_output::(), + )] + async fn icp_lookup_canister_info_by_id( + &self, + Parameters(discover::LookupCanisterArgs { canister_id }): Parameters, + ) -> Result { + let client = match discover::http_client() { + Ok(c) => c, + Err(e) => return Ok(err(e)), + }; + match discover::lookup_canister(&client, &canister_id).await { + Ok(info) => { + let text = format_canister_info(&info); + let output = discover::CanisterIdentityOutput::from(info); + Ok(ok_structured(text, &output)) + } + Err(e) => Ok(err(e)), + } + } + + // ---- ICP skills awareness ---------------------------------------------- + + #[tool( + description = "List the official Internet Computer skills — authoritative how-to guides for authoring and shipping IC apps (Motoko language, mops/icp CLIs, cycles management, stable memory & upgrades, security, DeFi, auth, …). Returns each skill's name and a one-line description. Load a skill's full instructions with icp_get_skill(name). Consult these BEFORE writing Motoko/Rust canister code, building, or deploying.", + annotations(title = "List Internet Computer skills", read_only_hint = true, destructive_hint = false, open_world_hint = false), + output_schema = schema_for_output::(), + )] + async fn icp_list_skills( + &self, + Parameters(_args): Parameters, + ) -> Result { + match self.skills.list().await { + Ok(s) => { + let text = skills::SkillsCatalog::format_list(&s); + let output = skills::SkillsOutput::from(s); + Ok(ok_structured(text, &output)) + } + Err(e) => Ok(err(e)), + } + } + + #[tool( + description = "Fetch the full instructions (SKILL.md) of one Internet Computer skill by name (e.g. \"motoko\", \"icp-cli\", \"mops-cli\", \"cycles-management\", \"stable-memory\", \"canister-security\"). Call icp_list_skills first to see the available names. Use this to learn the exact, current way to do an IC task before doing it.", + annotations(title = "Get an Internet Computer skill", read_only_hint = true, destructive_hint = false, open_world_hint = false), + output_schema = schema_for_output::(), + )] + async fn icp_get_skill( + &self, + Parameters(skills::GetSkillArgs { name }): Parameters, + ) -> Result { + match self.skills.get(&name).await { + Ok(md) => { + let output = skills::SkillOutput { name, content: md }; + Ok(ok_structured(output.content.clone(), &output)) + } + Err(e) => Ok(err(e)), + } + } + + // ---- Canister creation & management (as your standing II principal) ----- + + #[tool( + description = "Your cycles-ledger balance — the cycles that icp_create_canister and icp_top_up_canister spend. Acts as your Internet Identity principal (also printed). If it's empty, fund it first (e.g. via the icp CLI / cycles-management skill). Requires an authenticated session.", + annotations(title = "Check your cycles balance", read_only_hint = true, destructive_hint = false, open_world_hint = true), + output_schema = schema_for_output::(), + )] + async fn icp_cycles_balance( + &self, + Parameters(_args): Parameters, + ctx: RequestContext, + ) -> Result { + let sid = match authed_session(&ctx) { + Some(s) => s.session_id, + None => return Ok(err("checking your cycles balance needs an authenticated session".into())), + }; + match management::cycles_balance(&self.identities, &sid).await { + Ok(b) => Ok(ok_structured(b.human(), &b)), + Err(e) => Ok(err(e)), + } + } + + #[tool( + description = "Create and fund a NEW Internet Computer canister (as your Internet Identity). Fund it EITHER with `cycles` (exact, drawn from your cycles-ledger balance) OR with `icp` (a decimal-ICP string like \"0.5\", transferred from your ICP-ledger account and converted to cycles via the CMC). BOTH accounts belong to your management principal — the same principal icp_cycles_balance reports (its default subaccount); check/fund it before calling (cycles-ledger balance via icp_cycles_balance, or hold ICP in that principal's ICP-ledger account). The ICP path is best-effort with no retries: if the ICP transfer lands but the mint fails, the error carries the block index to recover with — do not blindly re-run. `cycles` wins if both are given. Controllers default to your own principal. Returns the new canister id — then build your Wasm (see the motoko/icp-cli skills) and install it with icp_install_code. Requires an authenticated session.", + annotations(title = "Create a canister", read_only_hint = false, destructive_hint = false, idempotent_hint = false, open_world_hint = true), + output_schema = schema_for_output::(), + )] + async fn icp_create_canister( + &self, + Parameters(args): Parameters, + ctx: RequestContext, + ) -> Result { + let sid = match authed_session(&ctx) { + Some(s) => s.session_id, + None => return Ok(err("creating a canister needs an authenticated session".into())), + }; + match management::create_canister(&self.identities, &sid, args).await { + Ok(c) => Ok(ok_structured(c.human(), &c)), + Err(e) => Ok(err(e)), + } + } + + #[tool( + description = "Add cycles to an existing canister (as your Internet Identity). Fund EITHER with `cycles` (exact, drawn from your cycles-ledger balance) OR with `icp` (a decimal-ICP string, transferred from your ICP-ledger account and converted via the CMC straight into the target canister). Both accounts belong to your management principal — the one icp_cycles_balance reports (default subaccount). The ICP path is best-effort with no retries: if the transfer lands but the mint fails, the error carries the block index to recover with — do not blindly re-run. `cycles` wins if both are given. Requires an authenticated session.", + annotations(title = "Top up a canister", read_only_hint = false, destructive_hint = false, idempotent_hint = false, open_world_hint = true), + output_schema = schema_for_output::(), + )] + async fn icp_top_up_canister( + &self, + Parameters(args): Parameters, + ctx: RequestContext, + ) -> Result { + let sid = match authed_session(&ctx) { + Some(s) => s.session_id, + None => return Ok(err("topping up a canister needs an authenticated session".into())), + }; + let canister_id = args.canister_id.clone(); + Ok(ok_canister_action( + canister_id, + management::top_up_canister(&self.identities, &sid, args).await, + )) + } + + #[tool( + description = "Install a compiled Wasm module on a canister you control (as your Internet Identity). Provide the module as `wasm_base64` (or `wasm_hex`); large modules are uploaded via the chunk store automatically. `mode` is \"install\" (default, empty canister), \"reinstall\" (wipe state), or \"upgrade\" (preserve stable memory). `arg` is the init/upgrade argument in textual Candid, e.g. \"()\". Build the Wasm in your own environment first (see the motoko / icp-cli / mops-cli skills). Requires an authenticated session.", + annotations(title = "Install code on a canister", read_only_hint = false, destructive_hint = true, idempotent_hint = false, open_world_hint = true), + output_schema = schema_for_output::(), + )] + async fn icp_install_code( + &self, + Parameters(args): Parameters, + ctx: RequestContext, + ) -> Result { + let sid = match authed_session(&ctx) { + Some(s) => s.session_id, + None => return Ok(err("installing code needs an authenticated session".into())), + }; + let canister_id = args.canister_id.clone(); + Ok(ok_canister_action( + canister_id, + management::install_code(&self.identities, &sid, args).await, + )) + } + + #[tool( + description = "Report a canister's status: run state, cycle balance, module hash, memory size, controllers, and allocations. Controller-only (acts as your Internet Identity). This only READS status (it changes nothing), but on the IC it is an update call, so it needs a full (non-read-only) Internet Identity session. Requires an authenticated session.", + annotations(title = "Get canister status", read_only_hint = true, destructive_hint = false, open_world_hint = true), + output_schema = schema_for_output::(), + )] + async fn icp_canister_status( + &self, + Parameters(args): Parameters, + ctx: RequestContext, + ) -> Result { + let sid = match authed_session(&ctx) { + Some(s) => s.session_id, + None => return Ok(err("reading canister status needs an authenticated session".into())), + }; + let canister_id = args.canister_id.clone(); + Ok(ok_canister_action( + canister_id, + management::canister_status(&self.identities, &sid, args).await, + )) + } + + #[tool( + description = "Update a canister's settings: controllers, compute/memory allocation, freezing threshold, reserved-cycles limit, wasm memory limit, or log visibility (\"controllers\"|\"public\"). Only the fields you pass are changed. WARNING: passing `controllers` REPLACES the whole set — include your own principal to remain a controller. Requires an authenticated session.", + annotations(title = "Update canister settings", read_only_hint = false, destructive_hint = true, idempotent_hint = true, open_world_hint = true), + output_schema = schema_for_output::(), + )] + async fn icp_update_canister_settings( + &self, + Parameters(args): Parameters, + ctx: RequestContext, + ) -> Result { + let sid = match authed_session(&ctx) { + Some(s) => s.session_id, + None => return Ok(err("updating settings needs an authenticated session".into())), + }; + let canister_id = args.canister_id.clone(); + Ok(ok_canister_action( + canister_id, + management::update_canister_settings(&self.identities, &sid, args).await, + )) + } + + #[tool( + description = "Start a stopped canister you control. Requires an authenticated session.", + annotations(title = "Start a canister", read_only_hint = false, destructive_hint = false, idempotent_hint = true, open_world_hint = true), + output_schema = schema_for_output::(), + )] + async fn icp_start_canister( + &self, + Parameters(management::CanisterRefArgs { canister_id }): Parameters, + ctx: RequestContext, + ) -> Result { + let sid = match authed_session(&ctx) { + Some(s) => s.session_id, + None => return Ok(err("starting a canister needs an authenticated session".into())), + }; + let result = management::start_canister(&self.identities, &sid, &canister_id).await; + Ok(ok_canister_action(canister_id, result)) + } + + #[tool( + description = "Stop a running canister you control (required before deleting it). Requires an authenticated session.", + annotations(title = "Stop a canister", read_only_hint = false, destructive_hint = false, idempotent_hint = true, open_world_hint = true), + output_schema = schema_for_output::(), + )] + async fn icp_stop_canister( + &self, + Parameters(management::CanisterRefArgs { canister_id }): Parameters, + ctx: RequestContext, + ) -> Result { + let sid = match authed_session(&ctx) { + Some(s) => s.session_id, + None => return Ok(err("stopping a canister needs an authenticated session".into())), + }; + let result = management::stop_canister(&self.identities, &sid, &canister_id).await; + Ok(ok_canister_action(canister_id, result)) + } + + #[tool( + description = "Remove a canister's code and state, leaving it empty (it keeps its id and cycles). Acts as your Internet Identity. Requires an authenticated session.", + annotations(title = "Uninstall code from a canister", read_only_hint = false, destructive_hint = true, idempotent_hint = true, open_world_hint = true), + output_schema = schema_for_output::(), + )] + async fn icp_uninstall_code( + &self, + Parameters(management::CanisterRefArgs { canister_id }): Parameters, + ctx: RequestContext, + ) -> Result { + let sid = match authed_session(&ctx) { + Some(s) => s.session_id, + None => return Ok(err("uninstalling code needs an authenticated session".into())), + }; + let result = management::uninstall_code(&self.identities, &sid, &canister_id).await; + Ok(ok_canister_action(canister_id, result)) + } + + #[tool( + description = "Delete a canister permanently (irreversible — stop it first; remaining cycles are burned). Acts as your Internet Identity. Requires an authenticated session.", + annotations(title = "Delete a canister", read_only_hint = false, destructive_hint = true, idempotent_hint = false, open_world_hint = true), + output_schema = schema_for_output::(), + )] + async fn icp_delete_canister( + &self, + Parameters(management::CanisterRefArgs { canister_id }): Parameters, + ctx: RequestContext, + ) -> Result { + let sid = match authed_session(&ctx) { + Some(s) => s.session_id, + None => return Ok(err("deleting a canister needs an authenticated session".into())), + }; + let result = management::delete_canister(&self.identities, &sid, &canister_id).await; + Ok(ok_canister_action(canister_id, result)) + } +} + +/// The authenticated MCP session of the calling request, if it carried a valid +/// bearer token (injected by [`auth::require_token`]). +fn authed_session(ctx: &RequestContext) -> Option { + ctx.extensions + .get::() + .and_then(|parts| parts.extensions.get::()) + .cloned() +} + +/// The rejection for an OQL read (`get_canister_oql_schema`, or `canister_query`'s +/// `oql` path) attempted with NO derivation origin. OQL reads per-app data gated by +/// the caller's principal, so an anonymous read is signed as 2vxsx-fae and returns +/// nothing useful — so, for the time being, we reject it outright rather than let it +/// silently come back empty. `what` names the action. Points at open_app / +/// resolve_app so the agent recovers the origin in one step. +fn oql_needs_origin_error(what: &str) -> String { + format!( + "{what} reads per-app data that this canister gates by the CALLER's principal, so it \ + cannot be done anonymously — pass `derivation_origin`, the app's canonical Internet \ + Identity origin, to read as your account. If you don't have it, resolve it from the \ + app's URL or name with open_app / resolve_app, then retry. (Anonymous per-app reads are \ + disabled for now; public metadata such as get_canister_candid still works without an origin.)" + ) +} + +/// The diagnosis attached to an EMPTY / trapped OQL query result (#1 + #7). +struct OqlEmptyDiagnosis { + /// The human-readable explanation of the empty result, if one applies. + note: Option, + /// The entities visible to this caller, when an unknown-`start` diagnosis fired. + valid_entities: Option>, + /// The nearest valid entity to an unknown `start` (`booking` → `bookings`). + did_you_mean: Option, +} + +impl OqlEmptyDiagnosis { + fn none() -> Self { + Self { note: None, valid_entities: None, did_you_mean: None } + } +} + +/// Why `diagnose_empty_oql` was called — a genuinely EMPTY result (0-row table) +/// vs. a FAILED one (trap / OQL error arm). The `start`-entity repair applies to +/// both, but the "came back empty ⇒ likely not authenticated" / "no entities here" +/// notes describe an EMPTY result and would misdescribe a failure that already +/// carries its own error message, so they are emitted for `EmptyResult` only. +#[derive(Clone, Copy, PartialEq)] +enum EmptyContext { + EmptyResult, + Failed, +} + +/// Diagnose why an OQL read came back EMPTY (0 rows) or FAILED (trap / error arm), +/// using ONLY facts gathered from the SAME agent/principal that ran the query — +/// never probing whether some OTHER principal would see data (no leak). This is +/// validate-on-empty, not an unconditional preflight: it runs a single extra +/// `schema` read for this principal and hard-validates the query's `start` entity +/// (only `start` — field and dotted-edge paths are treated advisorily by the caller). +/// +/// - `start` isn't one of the visible entities → the #7 unknown-entity repair +/// (valid entities + a "did you mean?" near match). Emitted in BOTH contexts — +/// an unknown `start` is the common reason a query traps. +/// - The notes below describe an EMPTY RESULT and are emitted for `EmptyResult` +/// only (a `Failed` result keeps its own error message; "came back empty" would +/// misdescribe it): +/// - Anonymous + empty → the #1 auth remediation (empty is almost certainly +/// "not authenticated as your account", not "no data"). +/// - Authenticated + the schema read SUCCEEDED and shows no entities → this +/// account sees no entities here. (A schema read that FAILED is left +/// undiagnosed — the caller falls back to a benign "0 rows" note — rather +/// than mislabeled "no entities".) +/// - `start` valid but 0 rows → authenticated gets no note here (the caller +/// adds the benign "0 rows"); anonymous still gets the auth hint. +async fn diagnose_empty_oql( + agent: &Agent, + principal: Principal, + query_json: &str, + is_anonymous: bool, + ctx: EmptyContext, +) -> OqlEmptyDiagnosis { + // The derivation_origin the caller would ADD to authenticate — a placeholder we + // never fill with a guessed origin (#1). + const ADD_HINT: &str = "the app's `derivation_origin` (its canonical Internet Identity origin)"; + // Re-read the schema for THIS principal (same agent). `None` = the read FAILED + // (unknown, not "empty"); `Some(vec)` = it succeeded (possibly with no entities). + // Keeping the two apart stops a transient schema failure from being mislabeled + // "this account sees no entities". + let entities: Option> = match calls::encode_unit_arg() { + Ok(arg) => match calls::raw_call(agent, principal, "schema", arg, true).await { + Ok(reply) => Some(calls::oql_entity_names(&calls::decode_schema_reply(&reply))), + Err(_) => None, + }, + Err(_) => None, + }; + let start = calls::oql_query_start(query_json); + let mut d = OqlEmptyDiagnosis::none(); + + // #7 unknown-`start` repair — needs a successful, non-empty schema. Applies in + // BOTH contexts (an unknown entity is exactly what makes a query trap). + if let Some(ents) = entities.as_ref().filter(|e| !e.is_empty()) { + if let Some(s) = start.as_deref().filter(|s| !ents.iter().any(|e| e == s)) { + d.did_you_mean = calls::closest_entity(s, ents); + let dym = d + .did_you_mean + .as_deref() + .map(|m| format!(" Did you mean \"{m}\"?")) + .unwrap_or_default(); + d.note = Some(format!( + "`start`: \"{s}\" is not a queryable entity on this canister. Valid entities \ + (visible to this caller): {}.{dym}", + ents.join(", ") + )); + d.valid_entities = Some(ents.clone()); + return d; + } + } + + // The remaining notes describe an EMPTY result — skip them on a failure, whose + // own error message stands, and appending "came back empty" would contradict it. + if ctx == EmptyContext::Failed { + return d; + } + + if is_anonymous { + // Anonymous + empty (schema empty, unreadable, or `start` valid) → auth hint. + d.note = Some(calls::anonymous_empty_note("this query", ADD_HINT)); + } else if matches!(entities.as_deref(), Some([])) { + // Authenticated AND the schema read SUCCEEDED with no entities — a genuine + // "nothing visible here" (not a failed re-read mislabeled as empty). + d.note = Some( + "This account sees no OQL entities on this canister, so there is nothing to query as \ + it — confirm the derivation_origin/account are the ones the user uses in their browser." + .to_string(), + ); + } + // Otherwise (authenticated: `start` valid, OR the schema read failed): no + // actionable diagnosis — leave `note` None so the caller adds the benign + // "0 rows for this account" on an empty table. + d +} + +/// Which app principal an identity-bearing tool should act as, resolved from the +/// caller's `derivation_origin`. +#[derive(Debug)] +struct IdentityTarget { + /// The EFFECTIVE (canonical) Internet Identity derivation origin to feed the + /// delegation layer — echoed to the caller as `derived_for_origin`. + origin: String, + /// Exactly what the caller supplied as `derivation_origin`. + requested: String, + /// How `origin` was determined. Always "explicit" here: the identity-bearing + /// tools take the canonical derivation origin DIRECTLY (they no longer accept an + /// `app_url` to resolve). The "declared"/"known"/"app_url_default" sources come + /// from the resolver tools (`open_app` / `resolve_app`), where a URL is turned + /// into a derivation origin under the guessed-domain gate; feed their result + /// here. Kept for parity with those tools' echoed `derivation_origin_source`. + source: String, +} + +/// Resolve the caller's `derivation_origin` into an [`IdentityTarget`] (or `None` +/// when it's absent — an anonymous call). The origin is canonicalized and used +/// VERBATIM (source `explicit`): these tools trust the caller to supply the exact +/// canonical origin Internet Identity derives against. There is deliberately no +/// `app_url` here — a derivation origin is a stable per-app value, so it's resolved +/// ONCE via `open_app` / `resolve_app` (which run the guessed-domain gate and return +/// it) and then reused across calls, rather than re-resolved on every invocation +/// against this stateless server. +fn resolve_identity_target( + derivation_origin: Option, +) -> Result, String> { + match derivation_origin { + None => Ok(None), + Some(d) => { + let d = clean_identity_arg("derivation_origin", &d)?; + let origin = canonicalize_derivation_origin(&d)?; + Ok(Some(IdentityTarget { + origin, + requested: d, + source: "explicit".to_string(), + })) + } + } +} + +/// Append guess-repair guidance to an `app_url` resolution failure (DNS, TLS, +/// timeout, SSRF refusal): a fetch error on a domain FABRICATED from an app name +/// (e.g. "multi.dex") must redirect the caller to the name→URL tools — the same +/// way the no-IC-evidence refusal does — rather than read as a transient error +/// inviting the next guess. +fn app_url_error_with_guidance(app_url: &str, e: String) -> String { + let mut msg = e; + if let Some(m) = discover::similar_known_app(app_url) { + msg.push_str(&format!( + " DID YOU MEAN {}? Its real URL is {} (derivation origin {}).", + m.name, m.app_url, m.derivation_origin + )); + } + msg.push_str( + " If you arrived at this URL by guessing from an app name, don't guess again — call \ + icp_find_app_by_name with the name, WEB SEARCH the app's official URL, or ask the user.", + ); + msg +} + +/// The refusal text for an `app_url` that resolved to `app_url_default` with NO +/// evidence of being served from the Internet Computer — almost always a domain +/// GUESSED from an app name (e.g. "multidex.com" for MULTI/DEX, whose real URL is +/// multidex.ai). Rather than resolving the guess to a plausible-but-wrong identity, +/// tools return this error, which (a) names the failure, (b) suggests the +/// well-known app the host resembles when there is one ("did you mean …"), and +/// (c) spells out the legitimate ways to obtain the URL — so the caller converges +/// on the real app instead of guessing again. +fn unverified_app_url_error(application_origin: &str) -> String { + let mut msg = format!( + "{application_origin} is reachable but shows NO evidence of being an Internet Computer \ + app — no valid `x-ic-canister-id` gateway header (the IC HTTP gateway sets one on every \ + response) — and its /.well-known/ic-app.json couldn't be fetched or declares no Internet \ + Identity derivation origin. Refusing to treat it as an app. " + ); + if let Some(m) = discover::similar_known_app(application_origin) { + msg.push_str(&format!( + "DID YOU MEAN {}? Its real URL is {} (derivation origin {}) — use that instead. ", + m.name, m.app_url, m.derivation_origin + )); + } + msg.push_str( + "If you arrived at this URL by GUESSING from an app name, stop guessing — a lookalike \ + domain is an unrelated or squatted site, and an identity derived there is wrong: \ + (1) call icp_find_app_by_name with the app's name (well-known apps resolve offline); \ + (2) if it's not known, WEB SEARCH the app's official URL; (3) or ask the user for it. \ + If this origin genuinely IS the app's canonical Internet Identity origin (e.g. a \ + non-IC-hosted app the user pointed you at), pass it explicitly as `derivation_origin` \ + to proceed deliberately.", + ); + msg +} + +/// The human `note` explaining how a resolved derivation origin was determined, +/// shared by `resolve_app` and `open_app` so the two stay in lock-step. `None` for +/// a Declared origin (self-evident); a Known origin explains the built-in registry; +/// an AppUrlDefault origin flags the assumption and, when the host resembles a +/// well-known app, appends a lookalike CAUTION (an IC-hosted squat can pass the +/// evidence gate yet still not be the app it mimics). `effective` is the +/// canonicalized derivation origin (`target_origin`). +fn resolution_note(resolved: &discover::AppIdentity, effective: &str) -> Option { + let lookalike = discover::similar_known_app(&resolved.application_origin).map(|m| { + format!( + " CAUTION: this host RESEMBLES the well-known app {} but is not one of its real \ + origins — {}'s URL is {} (derivation origin {}). If you meant {}, use that URL; \ + if you guessed this URL from the app's name, use icp_find_app_by_name instead of \ + guessing.", + m.name, m.name, m.app_url, m.derivation_origin, m.name + ) + }); + match resolved.derivation_origin_source { + discover::DerivationSource::Declared => None, + discover::DerivationSource::Known => Some(format!( + "This app didn't declare a derivation origin in /.well-known/ic-app.json, but it's \ + a known app that pins a custom one, so this used the built-in value {effective}. \ + The app's own declaration, if it ships one, would override this." + )), + discover::DerivationSource::AppUrlDefault => Some(format!( + "This origin showed evidence of being served from the Internet Computer (its \ + responses carry the gateway's `x-ic-canister-id` header), but its \ + /.well-known/ic-app.json couldn't be fetched or declares no `derivation_origin`, \ + and it isn't in the built-in known-app registry — so this ASSUMED the application \ + origin, canonicalized to {effective} (what II derives against). That is correct \ + for apps without a custom derivation origin; if this app pins a custom one, the \ + assumption yields a WRONG principal — supply the canonical origin explicitly.{}", + lookalike.as_deref().unwrap_or_default() + )), + } +} + +/// Validate a user-supplied identity argument (`derivation_origin` / `app_url`): +/// trim surrounding whitespace, reject an empty/whitespace-only value, and reject +/// ASCII/Unicode control characters. Both fields are echoed back verbatim (as +/// `requested` / `derived_for_origin`) and `derivation_origin` feeds the delegation +/// origin, so a stray control char could corrupt a log line or the origin string — +/// fail closed rather than pass it through. +fn clean_identity_arg(field: &str, raw: &str) -> Result { + let v = raw.trim(); + if v.is_empty() { + return Err(format!("`{field}` must not be empty")); + } + if v.chars().any(char::is_control) { + return Err(format!("`{field}` must not contain control characters")); + } + Ok(v.to_string()) +} + +/// Canonicalize a cleaned `derivation_origin` to the effective origin the delegation +/// path derives against (`target_origin`), rejecting one that carries no host (e.g. +/// "https://" → `https://`) — which would otherwise derive against, and echo back, +/// an empty origin. +fn canonicalize_derivation_origin(cleaned: &str) -> Result { + let invalid = || { + "`derivation_origin` must be an https origin or a bare host, with no user-info \ + (e.g. https://app.example.com or app.example.com)" + .to_string() + }; + // Reject any explicit scheme other than https (a bare host with no scheme is + // fine — target_origin prepends https). Without this, target_origin would + // rewrite a non-https scheme into a different origin than requested — `ftp://x` + // mangled into a bogus `https://…`, or `http://x` silently upgraded to + // `https://x` — contradicting the https-only contract and confusing debugging. + // A bare host with no scheme is fine — target_origin prepends https. An explicit + // scheme must be https, and we lowercase it before handing off: target_origin strips + // only a lowercase `https://`, so an uppercase `HTTPS://` would otherwise survive + // into the host and mangle the origin (e.g. `https://HTTPS:`), turning a valid input + // into a rejection or a bogus origin. Rejecting a non-https scheme here also stops + // target_origin from silently rewriting it (`ftp://x` → bogus `https://…`, `http://x` + // → `https://x`), contradicting the https-only contract. + let normalized = match cleaned.split_once("://") { + Some((scheme, rest)) => { + if !scheme.eq_ignore_ascii_case("https") { + return Err(invalid()); + } + format!("https://{rest}") + } + None => cleaned.to_string(), + }; + let origin = identities::target_origin(&normalized); + // Require the canonical origin to parse as an https URL with a real host and NO + // user-info. `target_origin` keeps any `user@` prefix, but a browser origin never + // has one, so `https://user@host` would derive a different principal than + // `https://host` while `requested == derived_for_origin` hides the mismatch. This + // also rejects a host-less input ("https://") and embedded spaces/invalid chars. + let parsed = url::Url::parse(&origin).ok().filter(|u| { + u.scheme() == "https" + && u.host_str().map_or(false, |h| !h.is_empty()) + && u.username().is_empty() + && u.password().is_none() + }); + match parsed { + // Reserialize to a canonical ASCII origin (lowercased host, no path/user-info), + // exactly like the `app_url` path (`Url::origin().ascii_serialization()`), so a + // mixed-case host echoes identically and hits the same delegation-cache key + // instead of forking `https://Example.com` vs `https://example.com`. + Some(u) if u.origin().is_tuple() => Ok(u.origin().ascii_serialization()), + _ => Err(invalid()), + } +} + +/// Clean a user-supplied `app_url`: the shared identity-arg checks, plus reject an +/// explicit non-https scheme up front. The connector only ever fetches https +/// discovery targets (the SSRF guard refuses anything else), so an `http://` URL +/// would otherwise fail with a late, indirect error — reject it here with a clear +/// message. A bare host (no scheme) is fine; `resolve_app_identity` prepends https. +fn clean_app_url(raw: &str) -> Result { + let u = clean_identity_arg("app_url", raw)?; + if let Some((scheme, _)) = u.split_once("://") { + if !scheme.eq_ignore_ascii_case("https") { + return Err( + "`app_url` must be an https URL or a bare host (the connector only fetches https origins)" + .to_string(), + ); + } + } + // Require a real host and NO user-info, matching `canonicalize_derivation_origin`. + // `resolve_app_identity` prepends https to a bare host and derives the application + // origin via `Url::origin()`, which silently DROPS any `user:pass@` — so + // `https://user@host` would resolve to `https://host` (differing from what the + // caller supplied) and `https://` would yield no host. Reject both up front with a + // clear message instead of a late, indirect downstream failure. + let candidate = if u.contains("://") { u.clone() } else { format!("https://{u}") }; + let valid = url::Url::parse(&candidate).ok().map_or(false, |parsed| { + parsed.scheme() == "https" + && parsed.host_str().map_or(false, |h| !h.is_empty()) + && parsed.username().is_empty() + && parsed.password().is_none() + }); + if !valid { + return Err( + "`app_url` must be an https URL or bare host with a real host and no user-info \ + (e.g. https://app.example.com or app.example.com)" + .to_string(), + ); + } + Ok(u) +} + +/// A one-line identity annotation for the human-readable `text` (which text-only +/// clients see instead of the structured output): the origin II derived against, +/// how it was determined (`source`, always "explicit" for the identity-bearing +/// tools now that they take the canonical origin directly), and — whenever it +/// differs from the derived origin (canonicalization, http→https, a stripped path) +/// — the caller's `requested` value, so a requested≠derived mismatch stays visible +/// in every client. `acted_as` prefixes the signed-as principal when known. +fn identity_annotation(target: &IdentityTarget, acted_as: Option<&str>) -> String { + let mut s = String::new(); + if let Some(p) = acted_as { + s.push_str(&format!("signed as {p} — ")); + } + s.push_str(&format!("derived for {} (source: {})", target.origin, target.source)); + if target.requested != target.origin { + s.push_str(&format!("; requested {}", target.requested)); + } + s +} + +#[tool_handler] +impl ServerHandler for IcTools { + fn get_info(&self) -> ServerInfo { + ServerInfo::new( + ServerCapabilities::builder().enable_tools().enable_resources().build(), + ) + .with_server_info(Implementation::from_build_env()) + .with_instructions( + "Internet Computer tools. Every tool speaks TEXTUAL Candid — the `(...)` value \ + syntax, e.g. `(record { owner = principal \"aaaaa-aa\"; amount = 5 : nat })`, never \ + the binary form. Tool names signal SCOPE: an `icp_` prefix marks IC protocol / \ + meta-level tools (dashboard name/id lookups, the official IC skills, the OQL dialect \ + guide, and canister creation/management); `…_app…` names \ + (`open_app`, `discover_app_canisters`, `get_app_principal`, `list_app_accounts`, `resolve_app`) \ + act on a whole APP, keyed by its Internet Identity derivation origin or app URL; and \ + `…canister…` names (`get_canister_candid`, `get_canister_api_doc`, \ + `get_canister_oql_schema`, `canister_query`, `canister_update_call`) act on ONE \ + specific canister. Before writing Candid args, consult the `candid://textual-syntax` \ + resource (the value syntax these tools use); `candid://reference` has the full type \ + reference.\n\n\ + START WITH open_app WHEN THE USER NAMES OR LINKS AN APP. `open_app(name-or-URL)` is the \ + one-call entry point: it takes an app NAME (e.g. \"MULTI/DEX\", \"Oisy\", \"NNS\") or a \ + URL, resolves the Internet Identity derivation origin AND discovers the canisters \ + together, probes the app's own canisters for their `oql`/`api_doc_available` \ + capabilities, and repairs a wrong-TLD guess to the canonical known-app URL — so you do \ + NOT chain resolve_app + discover_app_canisters yourself, and you do NOT start from \ + discover_app_canisters. (open_app bundles `resolve_app` + `discover_app_canisters`; use \ + those directly only for a single step.) RULE — names are not URLs: NEVER guess or \ + fabricate a domain from an app's name (e.g. .com/.app); pass the NAME to open_app \ + (or `icp_find_app_by_name`) and let the connector resolve it, WEB SEARCH the official \ + URL, or ask the user. Lookalike domains are unrelated or squatted sites, and open_app / \ + every URL-taking tool REFUSES an origin that shows no evidence of being an Internet \ + Computer app instead of resolving it to a wrong identity. When the user names a TOKEN, \ + PROJECT or SERVICE (e.g. \"ckUSDC\"), use `icp_find_canister_by_name` for its canister \ + id; `icp_lookup_canister_info_by_id(id)` tells you what a bare canister id IS (dashboard \ + label, type, controllers, subnet).\n\n\ + \"MY / OUR …\" IS AN AUTHENTICATED READ. A question about the USER's OWN data in an app \ + (\"who am I meeting with…\", \"my bookings\", \"our balance\") reads data the app gates \ + by the CALLER's principal. An OQL read (get_canister_oql_schema, and canister_query \ + with the `oql` argument) REQUIRES the app's \ + `derivation_origin` (from open_app / resolve_app) — anonymous per-app reads are \ + disabled for now, so a call with no origin is REJECTED with guidance to pass it, rather \ + than silently returning empty. Authenticating never hurts a public read either — the \ + canister serves the request regardless of principal — so always pass the origin for app \ + data. (canister_query can still run a Candid `method` query anonymously for genuinely \ + public canisters like ledgers.)\n\n\ + INSPECTING A CANISTER. `get_canister_candid` fetches the interface and reports two \ + capability flags: `oql` and `api_doc_available` (open_app reports the same per \ + canister). If `oql: true`, READ the canister via OQL, in order: `icp_oql_guide` (the \ + JSON dialect, once) → `get_canister_oql_schema` (the entities and fields) → \ + `canister_query` with the `oql` argument \ + (run a JSON query, get a table). These wrap the canister's `schema`/`execute` methods, \ + so you never hand-encode Candid for OQL — and on an OQL canister a Candid `method` query \ + through canister_query is REJECTED (use `oql`; canister_update_call handles UPDATES). \ + Call `get_canister_api_doc` ONLY when `api_doc_available` is true: then it returns a \ + prose \"how this app behaves\" guide (units, auth, lifecycle, mutation safety, polling, \ + gotchas) the Candid types don't convey; when the flag is false the canister has no such \ + doc and the Candid types ARE the interface — don't call it.\n\n\ + `canister_query` (reads) and `canister_update_call` (writes) call a method with \ + textual Candid in/out: omit the identity args to call anonymously, or act AS your \ + account at an app. To act as an app account, identify the app by its \ + `derivation_origin` — the EXACT canonical origin Internet Identity derives its \ + principal from, which is NOT necessarily the visible website URL and must NEVER be \ + inferred from an ii-alternative-origins list. The identity-bearing tools \ + (canister_query, canister_update_call, get_app_principal, list_app_accounts, \ + get_canister_oql_schema) \ + take ONLY `derivation_origin`, NOT a website URL: a derivation origin is a stable \ + per-app value, so RESOLVE IT ONCE with `open_app` (or `resolve_app`) — which turn an \ + app name/URL into it under the guessed-domain gate — and reuse it across calls, rather \ + than re-resolving a URL every time on this stateless server. A short-lived (<=5 min) account \ + delegation is minted ON DEMAND from this connection's standing credential, no extra \ + sign-in. `get_app_principal` returns the principal without a call; `list_app_accounts` lists \ + the user's accounts (a default one plus any named ones), and canister_query / \ + canister_update_call / get_app_principal take an optional `account` (a name from that \ + list) — omit it for the default. Every identity result echoes `derived_for_origin` (the origin actually used) and \ + `requested` (what you passed), so a canonicalization mismatch is visible. If a principal, \ + account, or balance doesn't match what the user sees in their browser, the derivation \ + origin is wrong: re-resolve the app with `open_app`/`resolve_app` (don't guess an origin). The standing \ + credential is obtained when you connect \ + (authenticate via Internet Identity) and lasts for the session duration you choose when \ + connecting (up to 30 days); reconnect when it expires. \ + The session may be READ-ONLY (Internet Identity's consent screen defaults to read-only): \ + reads work, but the canister-management tools below make update calls the network \ + rejects for a read-only session — if one fails that way, ask the user to reconnect with \ + the read-only option turned OFF.\n\n\ + Typical flow (acting FOR THE USER at an app): (0-2) `open_app(name-or-URL)` in ONE \ + call gives the `derivation_origin` AND the app's canisters (with `oql`/`api_doc_available` \ + flags) — pass the NAME the user said (well-known apps NNS/Oisy/MULTI/DEX/ICPSwap resolve \ + offline) or a URL you have, NEVER a domain guessed from the name (there is no on-chain \ + name→URL directory; `icp_find_canister_by_name` finds token/SNS canister ids, not \ + front-ends). If you want just one part, `icp_find_app_by_name` does name→URL, \ + `resolve_app(url)` the origin, `discover_app_canisters(url)` the canisters; (3) \ + `list_app_accounts` — if there is more than one account, ask which to use and remember \ + it; (4) `get_app_principal` ONLY when you need the principal value itself (`canister_query` / \ + `canister_update_call` act as the account without pre-fetching it); (5) inspect the \ + canister with `get_canister_candid` — its `oql` flag says whether to read via OQL, \ + its `api_doc_available` flag whether `get_canister_api_doc` has a doc; (6) READ as the \ + user with `canister_query`, passing the `derivation_origin` (REQUIRED for OQL): use the \ + `oql` argument when `oql: true` (get the entity/field names from get_canister_oql_schema; \ + an anonymous OQL read is rejected for now, and a Candid `method` \ + query is REJECTED on an OQL canister), else a Candid `method` query; (7) ACT with \ + `canister_update_call`, passing `derivation_origin` + `account` to act as the \ + user. Public metadata (get_canister_candid, discover_app_canisters) and public \ + canister_query Candid `method` queries need no origin; OQL reads always require one. The \ + per-canister inspection (5) is independent of the identity steps (1/3/4), so they can \ + run in parallel. Managing your OWN canisters \ + (the `icp_` create/install/status/… tools) acts as your standing MANAGEMENT principal at \ + this server's origin — a DIFFERENT identity than the per-app principals above.\n\n\ + To AUTHOR, BUILD and DEPLOY IC code, first consult the official IC skills: \ + `icp_list_skills` lists them and `icp_get_skill(name)` loads one. Especially `motoko` \ + (language), `mops-cli` (deps/build), `icp-cli` (build & deploy), `cycles-management` \ + (ICP↔cycles & funding), `stable-memory` (upgrades) and `canister-security`. Compiling \ + Motoko/Rust to Wasm happens in YOUR environment (guided by these skills); these tools \ + then put it on chain. To CREATE and MANAGE canisters as your Internet Identity, use: \ + `icp_cycles_balance` (your cycles-ledger balance), `icp_create_canister` (create + fund from \ + that balance — amount in `cycles` or `icp`), `icp_install_code` (install your compiled \ + Wasm — base64 — single-shot or chunked), `icp_canister_status`, `icp_update_canister_settings`, \ + `icp_start_canister`/`icp_stop_canister`/`icp_uninstall_code`/`icp_delete_canister`, and \ + `icp_top_up_canister`. These act as your standing II principal, which must hold cycles in \ + the cycles ledger first (fund it via the icp CLI / cycles-management skill). So to \ + \"build X and deploy a canister with Y ICP worth of cycles\": read the relevant skills, \ + write & build the Wasm locally, `icp_create_canister(icp=Y)`, then `icp_install_code`." + .to_string(), + ) + } + + async fn list_resources( + &self, + _request: Option, + _ctx: RequestContext, + ) -> Result { + let mut resources = vec![ + RawResource::new(CANDID_TEXTUAL_URI, "Candid textual syntax (used by these tools)") + .no_annotation(), + RawResource::new(CANDID_REFERENCE_URI, "Candid type reference (full spec)") + .no_annotation(), + RawResource::new(OQL_USAGE_URI, "OQL query surface usage guide") + .no_annotation(), + ]; + // Surface the IC skills as resources too (best-effort: if the registry is + // unreachable, the candid resources above still list). Each `skill://` + // is read on demand in read_resource. + if let Ok(skills) = self.skills.list().await { + for s in skills { + let title = if s.title.is_empty() { + format!("IC skill: {}", s.name) + } else { + format!("IC skill: {}", s.title) + }; + resources.push( + RawResource::new(format!("{SKILL_URI_PREFIX}{}", s.name), title).no_annotation(), + ); + } + } + Ok(ListResourcesResult { + resources, + next_cursor: None, + meta: None, + }) + } + + async fn read_resource( + &self, + request: ReadResourceRequestParams, + _ctx: RequestContext, + ) -> Result { + // Skills are fetched live by name; the candid references are static. + if let Some(name) = request.uri.strip_prefix(SKILL_URI_PREFIX) { + return match self.skills.get(name).await { + Ok(md) => Ok(ReadResourceResult::new(vec![ResourceContents::text( + md, + request.uri, + )])), + Err(e) => Err(McpError::resource_not_found( + "resource_not_found", + Some(serde_json::json!({ "uri": request.uri, "error": e })), + )), + }; + } + let body = match request.uri.as_str() { + CANDID_TEXTUAL_URI => CANDID_TEXTUAL_MD, + CANDID_REFERENCE_URI => CANDID_REFERENCE_MD, + OQL_USAGE_URI => OQL_PRIMER_MD, + other => { + return Err(McpError::resource_not_found( + "resource_not_found", + Some(serde_json::json!({ "uri": other })), + )) + } + }; + Ok(ReadResourceResult::new(vec![ResourceContents::text( + body, + request.uri, + )])) + } +} + +/// One canister line for the open_app / discover_app_canisters text output: id, +/// discovery label, dashboard «name» (type), sources, and any capability flags +/// (`[oql]` / `[api-doc]`) filled in by [`IcTools::enrich_capabilities`]. Shared by +/// both tools so their listings stay identical. +fn render_canister_line(c: &discover::DiscoveredCanister) -> String { + let identity = match (&c.name, &c.kind) { + (Some(n), Some(k)) => format!(" «{n}» ({k})"), + (Some(n), None) => format!(" «{n}»"), + _ => String::new(), + }; + let mut caps = String::new(); + if c.oql == Some(true) { + caps.push_str(" [oql]"); + } + if c.api_doc_available == Some(true) { + caps.push_str(" [api-doc]"); + } + format!( + "- {}{}{} [{}]{}\n", + c.canister_id, + c.label.as_deref().map(|l| format!(" — {l}")).unwrap_or_default(), + identity, + c.sources.join(", "), + caps, + ) +} + +/// The caller-gated data-access note (#3): when discovery surfaced OQL data +/// canister(s), spell out that their data is gated by the CALLER's principal (an OQL +/// read requires the origin — an anonymous read is rejected for now) and how to read +/// as the user. `handle` is the ready-to-use origin clause when the origin is +/// already resolved (open_app), or `None` when it isn't (discover_app_canisters), in +/// which case the note points at resolve_app / open_app to obtain it. +fn data_access_note(canisters: &[discover::DiscoveredCanister], handle: Option<&str>) -> Option { + if !canisters.iter().any(|c| c.oql == Some(true)) { + return None; + } + let how = match handle { + Some(clause) => format!( + "To read it as the user, pass {clause} to get_canister_oql_schema (for the entity/field \ + names) and canister_query (with the `oql` argument), plus an optional account from list_app_accounts." + ), + None => "Resolve the app's derivation_origin (resolve_app / open_app) and pass it to \ + get_canister_oql_schema (for the entity/field names) and canister_query (with the \ + `oql` argument) to read as the user." + .to_string(), + }; + Some(format!( + "Data access: the canister(s) flagged [oql] hold this app's data, gated by the CALLER's \ + principal — an OQL read REQUIRES the origin (an anonymous read is rejected for now). {how}" + )) +} + +/// Render an IC dashboard canister identity as readable text for icp_lookup_canister_info_by_id. +fn format_canister_info(info: &discover::CanisterInfo) -> String { + let mut s = format!("Canister {}\n", info.canister_id); + s.push_str(&format!( + "- name: {}\n", + info.name.as_deref().unwrap_or("(unlabelled — not a known/named canister)") + )); + if let Some(t) = &info.canister_type { + s.push_str(&format!("- type: {t}\n")); + } + if let Some(sub) = &info.subnet_id { + s.push_str(&format!("- subnet: {sub}\n")); + } + if !info.controllers.is_empty() { + s.push_str(&format!("- controllers: {}\n", info.controllers.join(", "))); + } + if let Some(lang) = &info.language { + s.push_str(&format!("- language: {lang}\n")); + } + if let Some(mh) = &info.module_hash { + s.push_str(&format!("- module hash: {mh}\n")); + } + if let Some(p) = info.latest_upgrade_proposal { + s.push_str(&format!("- latest upgrade: NNS proposal {p}\n")); + } + s.push_str("\nFetch its interface with get_canister_candid, then read with canister_query and write with canister_update_call."); + s +} + +/// Render the user's accounts at an app (from `Identities::list_accounts`) as +/// readable text for the `list_app_accounts` tool. +fn format_accounts(target: &IdentityTarget, accounts: &[identities::AccountInfo]) -> String { + // A one-line derivation-origin header so a wrong origin (or requested≠derived + // mismatch) is visible even to text-only clients. + let header = format!("Accounts {}", identity_annotation(target, None)); + if accounts.is_empty() { + return format!("{header}\n\nNo Internet Identity accounts found there."); + } + let mut out = format!("{header}\n\nYour accounts:\n"); + for a in accounts { + // The default account (anchor's current default) has no name/number. + let label = match &a.name { + Some(name) => format!("\"{name}\""), + None => "(default account — no name)".to_string(), + }; + let number = match a.account_number { + Some(n) => format!("account #{n}"), + None => "default".to_string(), + }; + let last_used = a + .last_used + .map(|ns| format!(", last used {ns} ns since epoch")) + .unwrap_or_default(); + out.push_str(&format!("- {label} [{number}{last_used}]\n")); + } + if accounts.len() == 1 { + out.push_str( + "\nOnly the default account exists here — act on the user's behalf directly: \ + canister_query / canister_update_call / get_app_principal with no `account`.", + ); + } else { + out.push_str( + "\nThere are multiple accounts here. Confirm which one the user means (or act on each), \ + then pass its name as `account` to canister_query / canister_update_call / \ + get_app_principal. Omit `account` for the default one.", + ); + } + out +} + +/// The MCP `outputSchema` for `T`, for use in a `#[tool(output_schema = …)]` +/// attribute. Thin wrapper over rmcp's generator that unwraps the object-root +/// validation: a non-object schema is a programming error (every tool output +/// type is a struct), so it panics at router-construction time rather than +/// forcing an `.expect(…)` at each of the ~19 call sites. +fn schema_for_output() -> std::sync::Arc { + rmcp::handler::server::tool::schema_for_output::().unwrap_or_else(|e| { + panic!( + "output schema for `{}` must be object-rooted: {e}", + std::any::type_name::() + ) + }) +} + +fn ok(text: String) -> CallToolResult { + CallToolResult::success(vec![Content::text(text)]) +} + +/// A success result carrying both the human-readable `text` and a machine- +/// readable `value` that conforms to the tool's declared `outputSchema`. +/// Clients that consume structured tool output get the typed form; the rest +/// still get the text. +/// +/// MCP requires `outputSchema` (and therefore `structuredContent`) to be +/// object-rooted, so we only attach `value` when it serializes to a JSON +/// object; anything else (a bare array/string/number, or a serialization +/// failure) falls back to text-only rather than emitting structured content +/// that couldn't match the declared schema. +fn ok_structured(text: String, value: &T) -> CallToolResult { + ok_structured_blocks(vec![text], value) +} + +/// Like [`ok_structured`], but emits several text content blocks instead of one. +/// The first block is the primary payload (e.g. a canister's raw `.did`); any +/// further blocks are separate notes. Keeping them distinct means a consumer +/// that copies the first block gets clean data (the `.did` stays valid, +/// paste-able Candid), while the model still sees the trailing note(s) — used by +/// `get_canister_candid` to attach the OQL pointer without contaminating the interface +/// text. The structured `value` is attached under the same object-rooted rule as +/// [`ok_structured`]. +fn ok_structured_blocks(texts: Vec, value: &T) -> CallToolResult { + let mut result = CallToolResult::success(texts.into_iter().map(Content::text).collect()); + result.structured_content = match serde_json::to_value(value) { + Ok(v @ serde_json::Value::Object(_)) => Some(v), + _ => None, + }; + result +} + +/// Map a management/lifecycle tool's `Result` (the human +/// confirmation message) to a `CallToolResult`, attaching a +/// `management::CanisterActionOutput { canister_id, message }` as structured content on +/// success so the reply conforms to the tool's declared `outputSchema`. +/// +/// The `canister_id` is normalized to its canonical principal text: the +/// management layer accepts ids with surrounding whitespace (it parses via +/// `Principal::from_text(s.trim())`) and renders the canonical form in its +/// messages, so we canonicalize here too to keep the structured field +/// consistent with the text. On the success path the id always parses (the +/// operation just used it); the trimmed input is only a defensive fallback. +fn ok_canister_action(canister_id: String, r: Result) -> CallToolResult { + match r { + Ok(message) => { + let canister_id = Principal::from_text(canister_id.trim()) + .map(|p| p.to_text()) + .unwrap_or_else(|_| canister_id.trim().to_string()); + let output = management::CanisterActionOutput { canister_id, message }; + ok_structured(output.message.clone(), &output) + } + Err(text) => err(text), + } +} + +fn err(text: String) -> CallToolResult { + CallToolResult::error(vec![Content::text(text)]) +} + + +#[cfg(test)] +mod tests { + use super::calls::decode_bytes_with_did; + use candid::types::value::IDLArgs; + use candid_parser::parse_idl_args; + + // Anonymous per-app reads are disabled for now: the OQL read tools reject a + // call with no origin, and the rejection must name what to pass and how to get + // it (so the agent recovers in one step) — not just say "no". + #[test] + fn oql_needs_origin_error_is_actionable() { + let msg = super::oql_needs_origin_error("Running an OQL query"); + assert!(msg.contains("Running an OQL query"), "echoes the action: {msg}"); + assert!(msg.contains("derivation_origin"), "names the arg to pass: {msg}"); + assert!( + msg.contains("open_app") && msg.contains("resolve_app"), + "names how to get the origin: {msg}" + ); + assert!( + msg.to_lowercase().contains("anonymous"), + "explains anonymous is disabled: {msg}" + ); + } + + // Field names are hashed on the Candid wire; decoding against the method's + // declared return type must recover them (type-less decoding shows hashes). + #[test] + fn typed_decode_recovers_field_names() { + let did = "service : { stats : () -> (record { name : text; url : text }) query }"; + // Encode a record reply (names get hashed in the wire format). + let bytes = parse_idl_args("(record { name = \"ICP\"; url = \"https://internetcomputer.org\" })") + .unwrap() + .to_bytes() + .unwrap(); + + // Type-less decode -> hashed field ids. + let typeless = IDLArgs::from_bytes(&bytes).unwrap().to_string(); + assert!(!typeless.contains("name ="), "type-less should NOT have names: {typeless}"); + + // Typed decode against the .did -> real field names. + let typed = decode_bytes_with_did(did, "stats", &bytes).expect("typed decode"); + assert!(typed.contains("name ="), "typed should have `name`: {typed}"); + assert!(typed.contains("url ="), "typed should have `url`: {typed}"); + } + + // Every tool must carry MCP annotations, else clients fall back to the unsafe + // defaults (readOnly=false, destructive=true) and mislabel reads like + // get_canister_candid as destructive. Assert the read/write classification serializes. + #[test] + fn every_tool_has_correct_read_write_annotations() { + let tools = super::IcTools::tool_router().list_all(); + assert_eq!(tools.len(), 26, "expected 26 tools, got {}", tools.len()); + assert!( + tools.iter().all(|t| t.annotations.is_some()), + "every tool must carry annotations (else clients assume write/destructive)" + ); + let ann = |name: &str| { + tools + .iter() + .find(|t| &*t.name == name) + .unwrap_or_else(|| panic!("tool {name} not found")) + .annotations + .clone() + .unwrap_or_else(|| panic!("tool {name} has no annotations")) + }; + // Pure reads (and status, which reads but is an update call) are read-only, + // AND set destructive_hint=false explicitly so a naive client that doesn't + // gate destructive on read_only can't mislabel them. + for name in [ + "get_canister_candid", "canister_query", "get_canister_oql_schema", "discover_app_canisters", "icp_find_canister_by_name", "icp_find_app_by_name", "icp_lookup_canister_info_by_id", + "icp_list_skills", "icp_get_skill", "icp_oql_guide", + "get_canister_api_doc", "open_app", "resolve_app", "list_app_accounts", "icp_cycles_balance", "get_app_principal", "icp_canister_status", + ] { + let a = ann(name); + assert_eq!(a.read_only_hint, Some(true), "{name} should be read-only"); + assert_eq!(a.destructive_hint, Some(false), "{name} should set destructive=false explicitly"); + } + // Destructive writes: not read-only, destructive. + for name in ["icp_delete_canister", "icp_uninstall_code", "icp_install_code", "icp_update_canister_settings"] { + let a = ann(name); + assert_eq!(a.read_only_hint, Some(false), "{name} should not be read-only"); + assert_eq!(a.destructive_hint, Some(true), "{name} should be destructive"); + } + // Additive/reversible writes: not read-only, not destructive. + for name in ["icp_create_canister", "icp_top_up_canister", "icp_start_canister", "icp_stop_canister"] { + let a = ann(name); + assert_eq!(a.read_only_hint, Some(false), "{name} should not be read-only"); + assert_eq!(a.destructive_hint, Some(false), "{name} should not be destructive"); + } + // The update-call tool is conservatively write + destructive. + let cc = ann("canister_update_call"); + assert_eq!(cc.read_only_hint, Some(false)); + assert_eq!(cc.destructive_hint, Some(true)); + } + + // EVERY tool must declare an outputSchema so a model knows the shape of its + // reply — and MCP requires that schema to be object-rooted. This guards the + // whole surface: a new tool added without an output schema fails here. + #[test] + fn every_tool_declares_an_object_output_schema() { + let tools = super::IcTools::tool_router().list_all(); + for t in &tools { + let schema = t + .output_schema + .as_ref() + .unwrap_or_else(|| panic!("tool {} must declare an output schema", t.name)); + assert_eq!( + schema.get("type"), + Some(&serde_json::json!("object")), + "tool {}'s outputSchema must be object-rooted per the MCP spec", + t.name + ); + } + } + + // Spot-check icp_find_canister_by_name's schema lists the expected properties. + #[test] + fn find_canister_declares_output_schema() { + let tools = super::IcTools::tool_router().list_all(); + let tool = tools + .iter() + .find(|t| &*t.name == "icp_find_canister_by_name") + .expect("icp_find_canister_by_name tool not found"); + let schema = tool + .output_schema + .as_ref() + .expect("icp_find_canister_by_name must declare an output schema"); + let props = schema + .get("properties") + .and_then(|v| v.as_object()) + .expect("outputSchema must have properties"); + assert!(props.contains_key("query"), "outputSchema should describe `query`"); + assert!(props.contains_key("matches"), "outputSchema should describe `matches`"); + } + + // The object-root guard in ok_structured must drop non-object payloads + // (a bare array/string can't match an MCP object outputSchema) while + // still attaching genuine objects. + #[test] + fn ok_structured_only_attaches_objects() { + let obj = super::ok_structured("t".to_string(), &serde_json::json!({"a": 1})); + assert!(obj.structured_content.is_some(), "an object payload must attach"); + + let arr = super::ok_structured("t".to_string(), &serde_json::json!([1, 2, 3])); + assert!(arr.structured_content.is_none(), "a bare array must be dropped"); + + let s = super::ok_structured("t".to_string(), &"hello"); + assert!(s.structured_content.is_none(), "a bare string must be dropped"); + } + + // ok_canister_action normalizes the canister id to its canonical principal + // text, so the structured field matches the id the management layer acted + // on (it trims/parses the input) rather than echoing raw whitespace. + #[test] + fn ok_canister_action_canonicalizes_canister_id() { + let result = super::ok_canister_action( + " ryjl3-tyaaa-aaaaa-aaaba-cai ".to_string(), + Ok("Started ryjl3-tyaaa-aaaaa-aaaba-cai.".to_string()), + ); + let value = result.structured_content.expect("structured content must be attached"); + assert_eq!( + value.get("canister_id"), + Some(&serde_json::json!("ryjl3-tyaaa-aaaaa-aaaba-cai")), + "structured canister_id must be the canonical, trimmed principal text" + ); + } + + // A structured icp_find_canister_by_name reply must round-trip through the declared + // schema shape: text for humans, plus a machine-readable object with a + // `matches` array carrying each match's fields. + #[test] + fn find_canister_output_serializes_to_declared_shape() { + let output = super::discover::FindCanisterOutput { + query: "ckUSDC".to_string(), + matches: vec![super::discover::FoundCanister { + canister_id: "xevnm-gaaaa-aaaar-qafnq-cai".to_string(), + name: "ckUSDC".to_string(), + kind: "token".to_string(), + note: None, + }], + }; + let result = super::ok_structured("human text".to_string(), &output); + let value = result + .structured_content + .expect("structured content must be attached"); + assert_eq!(value.get("query"), Some(&serde_json::json!("ckUSDC"))); + let matches = value.get("matches").and_then(|v| v.as_array()).expect("matches array"); + assert_eq!(matches.len(), 1); + assert_eq!( + matches[0].get("canister_id"), + Some(&serde_json::json!("xevnm-gaaaa-aaaar-qafnq-cai")) + ); + assert_eq!(matches[0].get("kind"), Some(&serde_json::json!("token"))); + } + + // resolve_identity_target's validation rules are part of the tool contract. It + // takes ONLY `derivation_origin` now (the identity-bearing tools no longer + // accept an app_url to resolve — see clean_app_url tests for URL validation). + + // A whitespace-only `derivation_origin` is empty after trimming and must be + // rejected rather than canonicalized into a bogus origin. + #[test] + fn resolve_identity_target_rejects_blank_derivation_origin() { + let err = super::resolve_identity_target(Some(" ".to_string())) + .expect_err("blank derivation_origin must be an error"); + assert!(err.contains("must not be empty"), "unexpected message: {err}"); + } + + // `derivation_origin` is trimmed before canonicalization, so surrounding + // whitespace never leaks into either the echoed `requested` or the effective + // `origin` fed to the delegation path. + #[test] + fn resolve_identity_target_trims_derivation_origin() { + let target = super::resolve_identity_target(Some(" https://example.com ".to_string())) + .expect("valid derivation_origin resolves") + .expect("an explicit derivation_origin yields a target"); + assert_eq!(target.requested, "https://example.com", "requested must be trimmed"); + assert_eq!(target.origin, "https://example.com", "origin must be the canonical trimmed form"); + assert_eq!(target.source, "explicit"); + } + + // No `derivation_origin` is the anonymous path: no target, no error. + #[test] + fn resolve_identity_target_none_is_anonymous() { + let target = super::resolve_identity_target(None).expect("None is anonymous"); + assert!(target.is_none(), "None must yield no target"); + } + + // A control character is rejected up front (it would otherwise be echoed back + // verbatim / corrupt the delegation origin). + #[test] + fn resolve_identity_target_rejects_control_chars() { + let err = super::resolve_identity_target(Some("https://ex\u{7}ample.com".to_string())) + .expect_err("control chars must be rejected"); + assert!(err.contains("control characters"), "unexpected message: {err}"); + } + + // An uppercase scheme must canonicalize the same as lowercase — `target_origin` + // strips only a lowercase `https://`, so without normalization `HTTPS://` would + // survive into the host and mangle the origin instead of yielding `https://host`. + #[test] + fn resolve_identity_target_normalizes_uppercase_scheme() { + let t = super::resolve_identity_target(Some("HTTPS://example.com".to_string())) + .expect("uppercase https scheme must be accepted") + .expect("a derivation_origin yields a target"); + assert_eq!(t.origin, "https://example.com", "unexpected origin: {}", t.origin); + } + + // A mixed-case HOST is canonicalized to lowercase, so the echoed origin and the + // delegation-cache key match the resolver path (which serializes via Url::origin()) + // instead of forking `https://Example.COM` from `https://example.com`. + #[test] + fn resolve_identity_target_lowercases_host() { + let t = super::resolve_identity_target(Some("https://Example.COM".to_string())) + .expect("mixed-case host must be accepted") + .expect("a derivation_origin yields a target"); + assert_eq!(t.origin, "https://example.com", "host must be lowercased: {}", t.origin); + } + + // A `derivation_origin` with no host (e.g. "https://") reduces to an empty + // origin and must be rejected rather than derived against. + #[test] + fn resolve_identity_target_rejects_hostless_derivation_origin() { + let err = super::resolve_identity_target(Some("https://".to_string())) + .expect_err("host-less derivation_origin must be rejected"); + assert!(err.contains("host"), "unexpected message: {err}"); + } + + // A non-http(s) scheme must be rejected, not mangled into a bogus https origin. + #[test] + fn resolve_identity_target_rejects_non_http_scheme() { + let err = super::resolve_identity_target(Some("ftp://example.com".to_string())) + .expect_err("ftp:// must be rejected"); + assert!(err.contains("https origin"), "unexpected message: {err}"); + } + + // `clean_app_url` (used by the resolver tools open_app / resolve_app) fails + // closed on bad URLs up front, so a caller gets a clear error instead of a late + // SSRF-guard/URL-parse failure. (The identity-bearing tools no longer take an + // app_url; the resolvers do.) + #[test] + fn clean_app_url_rejects_bad_urls() { + assert!(super::clean_app_url(" ").expect_err("blank").contains("must not be empty")); + assert!(super::clean_app_url("http://example.com").expect_err("http").contains("https")); + assert!( + super::clean_app_url("https://user:pass@example.com") + .expect_err("user-info") + .contains("user-info") + ); + assert!(super::clean_app_url("https://").expect_err("host-less").contains("real host")); + // A good bare host / https URL passes through. + assert_eq!(super::clean_app_url("oisy.com").unwrap(), "oisy.com"); + assert_eq!(super::clean_app_url("https://oisy.com").unwrap(), "https://oisy.com"); + } + + // The guessed-domain refusal (offline): the message must name the failure, + // redirect to the legitimate name→URL steps, carry the "did you mean" repair + // when the host resembles a well-known app, and keep the deliberate + // `derivation_origin` escape hatch — that combination is what turns a guessing + // loop into a one-step correction. + #[test] + fn unverified_app_url_error_redirects_and_repairs() { + // The exact wild-observed guess: multidex.com for MULTI/DEX (multidex.ai). + let msg = super::unverified_app_url_error("https://multidex.com"); + assert!(msg.contains("NO evidence"), "{msg}"); + assert!(msg.contains("icp_find_app_by_name"), "{msg}"); + assert!(msg.contains("WEB SEARCH"), "{msg}"); + assert!(msg.contains("DID YOU MEAN MULTI/DEX"), "{msg}"); + assert!(msg.contains("https://multidex.ai"), "{msg}"); + assert!(msg.contains("`derivation_origin`"), "escape hatch missing: {msg}"); + // A host resembling no known app still gets the redirect, just no repair. + let plain = super::unverified_app_url_error("https://example.com"); + assert!(plain.contains("icp_find_app_by_name"), "{plain}"); + assert!(!plain.contains("DID YOU MEAN"), "{plain}"); + } + + // A fetch failure on a guessed domain (offline: the DNS-dead "multi.dex" guess) + // carries the same repair, so it reads as "wrong URL — here's the real one" + // rather than a transient error inviting the next guess. + #[test] + fn app_url_fetch_errors_carry_guess_guidance() { + let msg = super::app_url_error_with_guidance( + "multi.dex", + "could not resolve multi.dex: no such host".to_string(), + ); + assert!(msg.contains("could not resolve"), "{msg}"); + assert!(msg.contains("DID YOU MEAN MULTI/DEX"), "{msg}"); + assert!(msg.contains("https://multidex.ai"), "{msg}"); + assert!(msg.contains("icp_find_app_by_name"), "{msg}"); + // Unrelated hosts: guidance without a bogus suggestion. + let plain = super::app_url_error_with_guidance("example.com", "timeout".to_string()); + assert!(!plain.contains("DID YOU MEAN"), "{plain}"); + assert!(plain.contains("icp_find_app_by_name"), "{plain}"); + } + + // The human-readable identity annotation must surface a requested≠derived + // mismatch (and the source) in ALL clients. + #[test] + fn identity_annotation_surfaces_mismatch_and_source() { + // requested == origin: origin + source, but no redundant `requested` echo. + let t = super::IdentityTarget { + origin: "https://nns.ic0.app".to_string(), + requested: "https://nns.ic0.app".to_string(), + source: "explicit".to_string(), + }; + let a = super::identity_annotation(&t, None); + assert!(a.contains("derived for https://nns.ic0.app"), "{a}"); + assert!(a.contains("source: explicit"), "{a}"); + assert!(!a.contains("requested"), "no mismatch must not echo requested: {a}"); + + // requested != origin (e.g. a stripped path): the mismatch is surfaced. + let t2 = super::IdentityTarget { + origin: "https://app.example.com".to_string(), + requested: "https://app.example.com/some/path".to_string(), + source: "explicit".to_string(), + }; + let a2 = super::identity_annotation(&t2, Some("aaaaa-aa")); + assert!(a2.contains("signed as aaaaa-aa"), "{a2}"); + assert!(a2.contains("requested https://app.example.com/some/path"), "{a2}"); + } + + // resolve_identity_target backs every identity-bearing path now — including + // canister_query's OQL mode and get_canister_candid's schema read — so it must + // fail closed on bad derivation origins: control chars, host-less, a non-https + // scheme, an embedded space, and user-info are all rejected; a valid one trims + + // canonicalizes. (The None/blank/uppercase/lowercase-host cases have their own + // tests above.) + #[test] + fn resolve_identity_target_canonicalizes_and_rejects_bad_origins() { + use super::resolve_identity_target as r; + assert!(r(Some("https://a\u{7}b.com".to_string())).is_err(), "control chars rejected"); + assert!(r(Some("https://".to_string())).is_err(), "host-less rejected"); + assert!(r(Some("ftp://example.com".to_string())).is_err(), "non-http(s) scheme rejected"); + assert!(r(Some("https://ex ample.com".to_string())).is_err(), "embedded space rejected"); + assert!( + r(Some("https://user@example.com".to_string())).is_err(), + "user-info rejected (would derive a different principal than the bare origin)" + ); + assert!( + r(Some("http://example.com".to_string())).is_err(), + "explicit http:// rejected (https-only contract; target_origin would silently upgrade it)" + ); + let t = r(Some(" https://example.com ".to_string())) + .expect("valid origin resolves") + .expect("an explicit derivation_origin yields a target"); + assert_eq!(t.origin, "https://example.com", "valid input trims + canonicalizes"); + } +} diff --git a/tests/routers.rs b/tests/routers.rs new file mode 100644 index 0000000..7a48f0e --- /dev/null +++ b/tests/routers.rs @@ -0,0 +1,307 @@ +//! Router-level contract tests: two `McpServer` instances compose into the +//! deployed app shape, and the discovery documents, the 401 challenge, the +//! auth-callback allow-list, and the OAuth endpoints all follow each +//! instance's mount path. + +use axum::{ + body::Body, + http::{Request, StatusCode}, + Router, +}; +use http_body_util::BodyExt; +use tower::ServiceExt; + +const PUBLIC_URL: &str = "https://mcp.example.com"; + +/// Point the OAuth client-registration store at a throwaway temp path BEFORE +/// any `SharedClients::load()`, so these tests neither read nor write a +/// developer's real `oauth-clients.json`. The authorize-path assertions rely on +/// an EMPTY registration set (unknown/unregistered clients are rejected); a +/// stray real registration for `client_id=unknown`/`x` would otherwise flip +/// them. `load()` treats a missing file as empty, so a fresh, deleted path +/// gives a deterministic empty set. Set exactly once (env is process-global). +fn isolate_client_store() { + use std::sync::Once; + static ONCE: Once = Once::new(); + ONCE.call_once(|| { + let mut path = std::env::temp_dir(); + path.push("imcp2-router-tests-oauth-clients.json"); + let _ = std::fs::remove_file(&path); // drop any stale file from a prior run + std::env::set_var("OAUTH_CLIENTS_FILE", &path); + }); +} + +fn server(instance: imcp2::IiInstance, mcp_path: &str) -> imcp2::McpServer { + isolate_client_store(); + let agent = imcp2::Agent::builder() + .with_url(imcp2::IC_URL) + .build() + .expect("build agent"); + imcp2::McpServer::new(imcp2::McpConfig { + agent, + instance, + public_url: PUBLIC_URL.into(), + mcp_path: mcp_path.into(), + clients: imcp2::SharedClients::load(), + }) +} + +/// The composed app, shaped like the deployment binary: beta (the default +/// instance, owning the root discovery documents and sharing the origin-global +/// auth-callback allow-list) at `/mcp`, prod at `/mcp-prod`. +fn app() -> Router { + let beta = server(imcp2::IiInstance::beta().expect("beta"), "/mcp"); + let prod = server(imcp2::IiInstance::prod().expect("prod"), "/mcp-prod"); + Router::new() + .nest_service(beta.mcp_path(), beta.mcp_router()) + .nest_service(prod.mcp_path(), prod.mcp_router()) + .merge(beta.well_known_router()) + .merge(prod.well_known_router()) + .merge(beta.root_well_known_router()) + .merge(imcp2::auth_callbacks_router(&[&beta, &prod])) +} + +async fn get_json(app: Router, path: &str) -> (StatusCode, serde_json::Value) { + let resp = app + .oneshot(Request::get(path).body(Body::empty()).unwrap()) + .await + .unwrap(); + let status = resp.status(); + let bytes = resp.into_body().collect().await.unwrap().to_bytes(); + let json = serde_json::from_slice(&bytes).unwrap_or(serde_json::Value::Null); + (status, json) +} + +#[tokio::test] +async fn protected_resource_metadata_follows_each_mount() { + // Beta: served path-inserted (RFC 9728 §3.1) and, as the default instance, + // at the plain root. + for path in [ + "/.well-known/oauth-protected-resource/mcp", + "/.well-known/oauth-protected-resource", + ] { + let (status, doc) = get_json(app(), path).await; + assert_eq!(status, StatusCode::OK, "GET {path}"); + assert_eq!(doc["resource"], format!("{PUBLIC_URL}/mcp"), "GET {path}"); + assert_eq!(doc["authorization_servers"][0], format!("{PUBLIC_URL}/mcp")); + } + // Prod: path-inserted only, with its own resource/AS. + let (status, doc) = get_json(app(), "/.well-known/oauth-protected-resource/mcp-prod").await; + assert_eq!(status, StatusCode::OK); + assert_eq!(doc["resource"], format!("{PUBLIC_URL}/mcp-prod")); + assert_eq!(doc["authorization_servers"][0], format!("{PUBLIC_URL}/mcp-prod")); +} + +#[tokio::test] +async fn authorization_server_metadata_is_a_path_issuer_per_instance() { + // RFC 8414 path-inserted location, the OIDC-style alternate inside the + // mount, and (default instance) the root courtesy copy — all one document. + for path in [ + "/.well-known/oauth-authorization-server/mcp", + "/mcp/.well-known/oauth-authorization-server", + "/.well-known/oauth-authorization-server", + ] { + let (status, doc) = get_json(app(), path).await; + assert_eq!(status, StatusCode::OK, "GET {path}"); + assert_eq!(doc["issuer"], format!("{PUBLIC_URL}/mcp"), "GET {path}"); + assert_eq!( + doc["authorization_endpoint"], + format!("{PUBLIC_URL}/mcp/oauth/authorize") + ); + assert_eq!(doc["token_endpoint"], format!("{PUBLIC_URL}/mcp/oauth/token")); + assert_eq!( + doc["registration_endpoint"], + format!("{PUBLIC_URL}/mcp/oauth/register") + ); + // Optional fields absent per RFC 8414 must be OMITTED, not null. + assert!(doc.get("scopes_supported").is_none()); + } + for path in [ + "/.well-known/oauth-authorization-server/mcp-prod", + "/mcp-prod/.well-known/oauth-authorization-server", + ] { + let (status, doc) = get_json(app(), path).await; + assert_eq!(status, StatusCode::OK, "GET {path}"); + assert_eq!(doc["issuer"], format!("{PUBLIC_URL}/mcp-prod"), "GET {path}"); + assert_eq!( + doc["authorization_endpoint"], + format!("{PUBLIC_URL}/mcp-prod/oauth/authorize") + ); + } +} + +#[tokio::test] +async fn auth_callbacks_document_declares_every_mount() { + let (status, doc) = get_json(app(), "/.well-known/ii-auth-callbacks").await; + assert_eq!(status, StatusCode::OK); + let declared: Vec = doc["callbacks"] + .as_array() + .expect("callbacks array") + .iter() + .map(|e| e.as_str().unwrap().to_string()) + .collect(); + assert_eq!( + declared, + vec![ + format!("{PUBLIC_URL}/mcp/oauth/connect/callback"), + format!("{PUBLIC_URL}/mcp-prod/oauth/connect/callback"), + ], + "one entry per instance, under each mount" + ); +} + +#[tokio::test] +async fn unauthenticated_mcp_requests_get_the_path_aware_challenge() { + // The MCP service is the mount's fallback, so the bare path, the + // trailing-slash form, and sub-paths are all gated — same breadth + // `nest_service` used to give it. + for path in ["/mcp", "/mcp/", "/mcp/sub"] { + let resp = app() + .oneshot(Request::post(path).body(Body::empty()).unwrap()) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::UNAUTHORIZED, "POST {path}"); + let challenge = resp + .headers() + .get("www-authenticate") + .and_then(|v| v.to_str().ok()) + .unwrap_or_default() + .to_string(); + assert!( + challenge.contains(&format!( + "resource_metadata=\"{PUBLIC_URL}/.well-known/oauth-protected-resource/mcp\"" + )), + "POST {path}: challenge should point at the path-aware metadata: {challenge}" + ); + // RFC 6750: no token presented → the bare challenge, no error code. + assert!( + !challenge.contains("error="), + "POST {path}: bare challenge must omit the error code: {challenge}" + ); + } + + // Prod's challenge points at prod's document. + let resp = app() + .oneshot(Request::post("/mcp-prod").body(Body::empty()).unwrap()) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::UNAUTHORIZED); + let challenge = resp + .headers() + .get("www-authenticate") + .and_then(|v| v.to_str().ok()) + .unwrap_or_default(); + assert!( + challenge.contains("/.well-known/oauth-protected-resource/mcp-prod\""), + "prod challenge: {challenge}" + ); +} + +#[tokio::test] +async fn invalid_token_challenge_carries_rfc6750_error() { + let resp = app() + .oneshot( + Request::post("/mcp") + .header("authorization", "Bearer bogus") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::UNAUTHORIZED); + let challenge = resp + .headers() + .get("www-authenticate") + .and_then(|v| v.to_str().ok()) + .unwrap_or_default(); + assert!( + challenge.contains("error=\"invalid_token\""), + "presented-but-invalid token must carry the RFC 6750 error: {challenge}" + ); +} + +#[tokio::test] +async fn authorize_validates_its_inputs() { + // No response_type. + let resp = app() + .oneshot( + Request::get("/mcp/oauth/authorize?client_id=x&redirect_uri=https://a.test/cb") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::BAD_REQUEST); + + // Unregistered client (loopback included — registration is always required). + let resp = app() + .oneshot( + Request::get( + "/mcp/oauth/authorize?response_type=code&client_id=unknown\ + &redirect_uri=http://127.0.0.1:4321/cb&code_challenge=abc\ + &code_challenge_method=S256", + ) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::BAD_REQUEST); + + // An omitted code_challenge_method defaults to `plain` per RFC 7636, which + // this server does not support — rejected up front rather than handing out + // a code the token endpoint (S256-only) could never redeem. + let resp = app() + .oneshot( + Request::get( + "/mcp/oauth/authorize?response_type=code&client_id=unknown\ + &redirect_uri=http://127.0.0.1:4321/cb&code_challenge=abc", + ) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::BAD_REQUEST); +} + +#[tokio::test] +async fn oauth_endpoints_live_under_each_mount() { + // Token: a bogus grant is rejected with a standards-shaped 400. + for path in ["/mcp/oauth/token", "/mcp-prod/oauth/token"] { + let resp = app() + .oneshot( + Request::post(path) + .header("content-type", "application/x-www-form-urlencoded") + .body(Body::from( + "grant_type=authorization_code&code=bogus&client_id=x&code_verifier=v", + )) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::BAD_REQUEST, "POST {path}"); + let bytes = resp.into_body().collect().await.unwrap().to_bytes(); + let doc: serde_json::Value = serde_json::from_slice(&bytes).unwrap(); + assert_eq!(doc["error"], "invalid_grant", "POST {path}"); + } + + // The pinned callback page is served per instance and posts to that + // instance's redeem path. + for (page, redeem) in [ + ("/mcp/oauth/connect/callback", "/mcp/oauth/connect/redeem"), + ("/mcp-prod/oauth/connect/callback", "/mcp-prod/oauth/connect/redeem"), + ] { + let resp = app() + .oneshot(Request::get(page).body(Body::empty()).unwrap()) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::OK, "GET {page}"); + let bytes = resp.into_body().collect().await.unwrap().to_bytes(); + let html = String::from_utf8_lossy(&bytes); + assert!( + html.contains(redeem), + "GET {page}: the page must post to its own instance's redeem path" + ); + } +}