Skip to content

IMCP2

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. The MCP layer is the official Rust SDK (rmcp).

Use as a library

One McpServer serves one Internet Identity instance as two 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 — the shared BN/gateway crate it should eventually be sourced through — releases against ic-agent 0.48; see Cargo.toml.)

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 on staging, serving production Internet Identity at /mcp and beta at /mcp-beta (see below).

Tools

Every tool declares an MCP outputSchema and, on success, attaches structured content — a machine-readable object matching that schema — alongside the human-readable text, so a model knows the expected shape of each reply. The text is always present; the structured object is attached whenever the reply serializes to an object (which the schema guarantees for normal results).

Tool Args Returns
open_app app (name or URL) One-call entry point when a user names/links an app: resolves the Internet Identity derivation_origin and discovers the canisters behind it, together. A name or bare host is matched to the known-app registry first (so a wrong-TLD guess like multidex.com repairs to the canonical URL); an explicit https:// URL is resolved as given. An unknown bare name, or a URL with no IC evidence, is refused (never guessed). Also probes the app's own canisters and reports per-canister oql/api_doc_available capability flags plus a caller-gated data-access note (which canister holds the app data, and the origin to read it as the user). Wraps resolve_app + discover_app_canisters; no auth
discover_app_canisters domain Canister ids behind a web domain — app-declared App Connect metadata first (/ai-connect.html's ic:canister-id meta, /.well-known/ic-app.json manifest), then the frontend via x-ic-canister-id and backend candidates via /env.json + JS-bundle mining — each with provenance, its IC dashboard label/type where known, and (for the app's own canisters) oql/api_doc_available capability flags from a one-shot Candid probe
icp_find_canister_by_name query Canister ids matching a name/symbol, searched in the IC dashboard's service registries — ICRC token ledgers (e.g. ckUSDC) and the SNS project catalog
icp_find_app_by_name name A well-known app's front-end URL + derivation_origin, for a small built-in set (NNS, Oisy, MULTI/DEX, ICPSwap). The first stop whenever only an app name is known — never guess a domain from a name. Any other name returns no match and a note directing a web search for the app's URL (there's no on-chain name→URL directory)
icp_lookup_canister_info_by_id canister_id What a canister IS, per the IC dashboard: label/name, type, controllers, subnet, module hash, latest upgrade proposal
get_canister_candid canister_id The canister's candid:service interface (.did text), plus two capability flags: oql (true when it exposes an OQL query surface — a schema + execute pair — with a pointer to icp_oql_guide) and api_doc_available (true when it declares a getApiDoc/get_api_doc method, gating get_canister_api_doc)
get_canister_api_doc canister_id The canister's own prose API guide ("how this app behaves" — units, auth, lifecycle, mutation safety, polling, gotchas), from its getApiDoc/get_api_doc method. Call only when get_canister_candid/open_app report api_doc_available. Returns a structured result in every case — available + the doc on success, else available:false with expected/retry/next so an expected absence is distinct from an unreachable canister
canister_query canister_id, method? or oql?, args? (textual Candid), derivation_origin?, account? READ a canister — provide EITHER a Candid query method (with args) OR an oql query (a JSON object string, run against execute). A Candid method query may be anonymous or as your account and returns textual Candid; an oql query requires derivation_origin and returns columns + rows (a table) with has_more, validating start against the schema on an empty result. On an OQL canister a Candid method query is rejected — use oql. Echoes derived_for_origin / requested / acted_as_principal
canister_update_call canister_id, method, args (textual Candid), derivation_origin?, account? Make an UPDATE (state-changing) call; reply as textual Candid; anonymous, or as your account at an app (identified by its canonical II derivation_origin, obtained once from open_app/resolve_app). Echoes derived_for_origin / requested / acted_as_principal
get_app_principal derivation_origin, account? The principal you act as at an app, without a call. Identify the app by its derivation_origin (from open_app/resolve_app). Echoes derived_for_origin / requested so an origin mismatch is visible
list_app_accounts derivation_origin The user's Internet Identity accounts at an app — the default account plus any named ones — with name, number, last-used, and the derivation origin they were listed for. Identify the app by its derivation_origin (from open_app/resolve_app)
resolve_app app_url Resolve an app URL to its Internet Identity derivation context: application_origin, the derivation_origin to use (declared in /.well-known/ic-app.json, else a built-in known-app value, else assumed = app origin — flagged via derivation_origin_source: declared/known/app_url_default, with application_is_ic echoing the gateway evidence), and the app's alternative_origins (informational). An origin with no IC evidence that would need the app_url_default assumption is refused (guessed-domain guard, with a "did you mean" repair when the host resembles a well-known app). Does not return a principal (no account chosen) or require auth — pass the derivation_origin to get_app_principal/list_app_accounts
icp_list_skills The official IC skills (Motoko, mops/icp CLIs, cycles, stable memory, security, …), grouped by category
icp_get_skill name The full SKILL.md instructions for one skill (e.g. motoko, icp-cli, cycles-management)
icp_oql_guide The OQL query-surface dialect guide (for canisters where get_canister_candid reports oql: true): the JSON query object, predicate grammar, edges, and paged result shape. The entity/field names come from get_canister_oql_schema and queries run through canister_query (the oql argument)
get_canister_oql_schema canister_id, derivation_origin, account? The canister's OQL schema catalogue (entities, primary keys, fields, edges) as JSON — wraps its schema method — plus a ready-to-run canister_query example per entity. derivation_origin is required: the schema is caller-gated, so an anonymous read is rejected (for now) with guidance, rather than returning an empty list
icp_cycles_balance Your cycles-ledger balance (the funds icp_create_canister/icp_top_up_canister spend), as your standing II principal
icp_create_canister cycles? / icp?, controllers?, subnet? Create + fund a new canister — from your cycles-ledger balance (cycles) or by converting ICP from your ICP-ledger account via the CMC (icp); returns the new canister id
icp_install_code canister_id, wasm_base64 / wasm_hex, mode?, arg? Install/reinstall/upgrade a Wasm module (single-shot, or via the chunk store for large modules)
icp_canister_status canister_id Run state, cycle balance, module hash, memory, controllers, allocations
icp_update_canister_settings canister_id, controllers?, allocations, freezing_threshold?, log_visibility?, … Update a canister's settings
icp_start_canister / icp_stop_canister / icp_uninstall_code / icp_delete_canister canister_id Canister lifecycle
icp_top_up_canister canister_id, cycles? / icp? Add cycles to an existing canister — from your cycles-ledger balance (cycles) or by converting ICP from your ICP-ledger account via the CMC (icp)

discover_app_canisters is the entry point when the user names a website instead of a canister id. Sources, most authoritative first: app-declared metadata (below), the frontend via the x-ic-canister-id header, and backend candidates mined from /env.json + the JS bundle (pick by label, prefer production/IC_ ids, confirm with get_canister_candid).

Typical flow

Acting for the user at an app:

0–2. open_app(name-or-URL) — the one-call entry point. Pass the name the user said (well-known apps NNS/Oisy/MULTI/DEX/ICPSwap resolve offline) or a URL you have; it returns the derivation_origin and the app's canisters in one shot (it runs resolve_app + discover_app_canisters concurrently under the hood). Never guess a domain from a name — a lookalike like <name>.com is an unrelated or squatted site. The tool enforces this: a bare unknown name is refused (find the real URL — web-search or ask the user), and a URL that resolves to app_url_default while showing no IC evidence (no valid x-ic-canister-id gateway header, no ic-app.json derivation origin) is refused too; when the host resembles a known app the error names it and gives the real URL ("did you mean MULTI/DEX → https://multidex.ai"). For a single step, the narrower tools remain: icp_find_app_by_name (name→URL only), resolve_app(url) (origin only), discover_app_canisters(url) (canisters only). 3. list_app_accounts — if there's 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. get_canister_candid to learn the interface — its oql flag says whether OQL is available, its api_doc_available flag whether to call get_canister_api_doc (call it only when that's true). For an OQL canister, get the entity/field names from get_canister_oql_schema (pass the derivation_origin). 6. Read with canister_query: use the oql argument when OQL is available, passing the derivation_origin — an OQL read requires it (an anonymous per-app read is rejected for now, and a Candid method query is rejected on an OQL canister). Otherwise pass a Candid method. 7. Act with canister_update_call, passing derivation_origin + account to act as the user.

Genuinely public reads via a canister_query Candid method query or the public-metadata tools (get_canister_candid, discover_app_canisters) skip steps 3/4 and need no origin; OQL reads always require one. The per-canister inspection (5) is independent of the identity steps (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.

App-declared canister metadata (App Connect)

Apps that adopt Internet Computer App Connect serve a bridge page at /ai-connect.html whose <meta name="ic:canister-id"> declares the app's main backend canister (spec §4.7/§6.1). Discovery reads that meta from the raw served markup (no JavaScript is executed) and reports it as the top-priority finding, labelled main backend (App Connect).

The App Connect spec defers multi-canister enumeration (§6.3: how an app lists all the canisters it comprises, with roles). To fill that gap, this server also reads a proposed convention: a /.well-known/ic-app.json manifest the app serves itself —

{
  "derivation_origin": "https://<frontend-canister>.icp0.io",
  "canisters": [
    { "id": "aaaaa-…-cai", "role": "backend", "description": "orders + inventory API" },
    { "id": "bbbbb-…-cai", "role": "ledger" }
  ]
}

Each entry needs an id (a canister principal); role and description are optional and become the finding's label (role — description). Unknown fields are ignored, so the format can grow. Both sources are the app's own claim about its composition — stronger than anything mined from client code — but an SPA catch-all serving HTML at these paths simply yields no findings (no meta tag; JSON parse fails), and every id is still validated as a principal.

The optional top-level derivation_origin is the app's declaration of the Internet Identity derivation origin its frontends pin (see the identity section above). It is the only authoritative way for open_app / resolve_app to learn a custom derivation origin from an app URL — there is no reverse lookup from an app URL to it — so an app that uses one should declare it here; otherwise the connector assumes the derivation origin equals the application origin and flags that assumption.

When the user names a token, project, or service (e.g. ckUSDC) rather than a website or id, icp_find_canister_by_name resolves it via dashboard.internetcomputer.org's public APIs — the ICRC token registry and the SNS catalog — to the matching canister id(s). icp_lookup_canister_info_by_id goes the other way: given a bare id, it returns the dashboard's label, type, controllers, subnet, and module hash, so a raw principal becomes an identified service. (discover_app_canisters results are annotated with these labels inline.) There is no public name-search over arbitrary canisters, so icp_find_canister_by_name covers the IC's labelled services, which is where the meaningful ones live.

canister_query and canister_update_call run anonymously by default; pass a derivation_origin to call as your account at that app. The server mints a short-lived account delegation on demand using the connection's registered Internet Identity session key (see Domain identities) — there is no per-app sign-in step. get_app_principal returns that account's principal without a call. A user may hold several accounts at an app — a default account everyone gets automatically (the anchor's current, user-controllable default at that origin), plus any they have named — so list_app_accounts lists them (via II's get_accounts), and canister_query/canister_update_call/get_app_principal/list_app_accounts identify the app by its derivation_origin (obtained once from open_app/resolve_app — see the note below), and take an optional account (a name from that list) to act as a specific one; omit it for the default account. All these tools require a bearer token (see Auth).

The derivation origin is explicit — and echoed. Internet Identity derives the per-app principal from a derivation origin, which is usually but not always the visible website URL: an app can pin a custom derivation origin (via derivationOrigin + /.well-known/ii-alternative-origins) that browsers honour. The identity-bearing tools (canister_query, canister_update_call, get_app_principal, list_app_accounts) therefore take the derivation origin explicitly as derivation_origin (the exact canonical origin — never inferred from an alternative-origins list, which is the inverse relation), and only that — they do not accept a raw website URL. A derivation origin is a stable per-app value, so you resolve it once and reuse it: open_app (or resolve_app) turns an app name/URL into it and reports how — derivation_origin_source: declared (/.well-known/ic-app.jsonderivation_origin), else a built-in known-app value for a few apps that pin a custom origin without declaring it (NNS → https://nns.ic0.app, Oisy → https://oisy.com, MULTI/DEX → https://hcv4s-…icp0.io, ICPSwap → https://app.icpswap.com; an app's own declaration always overrides this), else the app origin assumed (app_url_default). Feeding that resolved origin to an identity tool records derivation_origin_source = explicit. Every identity result echoes derived_for_origin (the origin actually used) alongside requested (what you passed), so a canonicalization mismatch is immediately visible. Why not accept a URL directly on every tool? Because the server is stateless — resolving a URL costs a network round-trip each call, and the derivation origin never changes per-app; forcing a single up-front open_app/resolve_app avoids re-resolving on every invocation and gives the agent one way to identify an app. The guessed-domain gate lives at that resolution step: open_app/resolve_app refuse an app_url whose app_url_default assumption would apply while the origin shows no IC evidence — the gateway's x-ic-canister-id header (a valid canister principal, from the origin itself, not a redirect target; checked on the manifest response, else the origin root) — since that's the signature of a domain guessed from an app name (a lookalike/squatted site), and the refusal names the well-known app the host resembles when there is one. A genuinely non-IC-hosted app that uses Internet Identity can still be targeted deliberately by passing its origin as derivation_origin (which is trusted verbatim, ungated). Under the hood the origin goes through the same canonicalizer the delegation path uses (bare https://<host>, with the *.icp0.io/*.icp.net*.ic0.app gateway remap below). For backward compatibility the identity tools still accept the legacy parameter name domain as an alias for derivation_origin.

Skills awareness

icp_list_skills / icp_get_skill expose the official Internet Computer skills — authoritative, current how-to guides for authoring and shipping IC apps (the Motoko language, the mops and icp CLIs, cycles management, stable memory & upgrades, canister security, DeFi, auth, …). The catalogue is fetched live from the registry's manifest (/api/skills.json, cached ~15 min) and each skill's SKILL.md on demand; nothing is bundled, so the agent always sees the current skills. They are also listed as MCP resources (skill://<name>) alongside the candid:// references. Override the registry origin with SKILLS_URL.

OQL query surfaces

Some canisters expose OQL — a self-describing, agent-queryable surface over their data via two Candid query methods: schema : () -> (text) query (a JSON catalogue of entities, fields, and edges) and execute : (text) -> (Result) query (a JSON query language with filters, aggregation, ordering, and edge traversal). get_canister_candid detects the pair and reports oql: true (parsing the interface behind the same CWE-674 guard the encode/decode path uses, so a malformed .did just fails closed to false). Rather than inline the whole dialect into every interface read, get_canister_candid emits only that flag plus a one-line pointer; the full guide is served on demand by icp_oql_guide and as the oql://usage MCP resource.

Two tools drive the surface: get_canister_oql_schema returns the entity/field catalogue (wrapping the schema method), and canister_query takes the query in its oql argument as a plain JSON object string, wraps it as execute's single text argument (so the model never hand-escapes JSON inside a Candid literal), and decodes the reply into columns + rows — rendered as a markdown table, with has_more for paging. Both require a derivation_origin (with an optional account) to query as the user's account — the schema and rows are caller-gated, so an anonymous per-app read is rejected (for now) with guidance to pass the origin rather than silently returning empty (same on-demand delegation as a canister_query Candid method query, which stays permissive so genuinely public canisters can still be read anonymously). Because OQL is the preferred read path when a canister offers it, a Candid method query through canister_query is rejected on an OQL canister (it returns a pointer to the oql argument); canister_update_call then handles that canister's update calls. Detection stays name-based and the decode is fail-closed: a reply that isn't a recognizable OQL result degrades to the raw Candid rather than erroring. The design mirrors the reference IC connector's OQL primer (detect + teach), adding an ergonomic executor suited to this server's structured-output conventions.

Creating & managing canisters

The management tools let the agent act on chain as your standing Internet Identity principal — a stable per-connection identity (the one returned when you authenticate). Because a user ingress message cannot attach cycles, creation and top-ups fund the canister one of two ways, both keyed to that management principal (the one icp_cycles_balance reports, default subaccount):

  • cycles — drawn from your cycles-ledger balance (um5iw-rqaaa-aaaaq-qaaba-cai); fund it first (e.g. via the icp CLI / cycles-management skill) and check it with icp_cycles_balance.
  • icp — a decimal-ICP amount transferred from that principal's ICP-ledger account (ryjl3-tyaaa-aaaaa-aaaba-cai, default subaccount) to the CMC, which mints cycles into the canister (notify_create_canister / notify_top_up). Best-effort, single attempt: if the transfer lands but the mint fails, the error carries the ICP-ledger block index to recover with — the call is not idempotent, so don't blindly re-run it.

cycles takes precedence if both are given. Lifecycle calls (icp_install_code, icp_canister_status, icp_update_canister_settings, start/stop/uninstall/delete) go to the management canister (aaaaa-aa) with the effective canister id set to the target. icp_install_code takes the compiled Wasm as base64/hex and uploads it via the chunk store automatically when it exceeds the single-message limit.

Together these make the end-to-end flow work: "create a Motoko canister that does X and deploy a new canister with Y ICP worth of cycles" → the agent reads the relevant skills, writes and builds the Wasm in its own environment, then icp_create_canister(icp = Y) and icp_install_code. (Compiling Motoko/Rust to Wasm happens in the agent's environment, not in this server.)

Connect from an MCP client

Add the server to Claude Code (replace the URL with wherever it's hosted):

claude mcp add --transport http ic-poc https://YOUR-HOST/mcp

Then run /mcpic-poc → authenticate: the client sends the browser to Internet Identity's /mcp handshake; you sign in once, II registers the connection's session key as a time-boxed grant and returns you to the client, and the tools become available. All clients use the same authorization-code + PKCE flow.

On II's consent screen, leave the read-only option OFF if you want to create or manage canisters — read-only is the default, and it makes every management tool inert (see Read-only sessions below).

Run

cargo run
# serves http://0.0.0.0:8000  (MCP at /mcp against production II, OAuth under it, info page at /)
# honours $PORT (default 8000), $PUBLIC_URL (default http://localhost:8000), and
# $MCP_SERVE_BETA (set it to also serve the beta II instance at /mcp-beta, for staging)

Deploy

The server is a single binary plus the static/ assets. Two requirements when hosting:

  • HTTPS — the id.ai passkey (WebAuthn) only works in a secure context.
  • PUBLIC_URL — set it to the public https URL; it's used in the OAuth discovery documents, the sign-in redirect/callback, and the allowed-Host list. (II derives the MCP server origin from the connect callback, and each user must add this exact origin as their trusted MCP server in II Settings — there is no longer a deploy-time mcp_server_origin on II's side.)

A Dockerfile is included (works on Render / Fly / Cloud Run / Koyeb). For a zero-signup public URL during testing, expose the local server with a tunnel:

cargo run &                                   # local server on :8000
cloudflared tunnel --url http://localhost:8000   # prints https://<name>.trycloudflare.com
# restart the server with PUBLIC_URL set to that URL:
PUBLIC_URL=https://<name>.trycloudflare.com cargo run

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:

# 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 www-authenticate
# -> WWW-Authenticate: Bearer resource_metadata=".../.well-known/oauth-protected-resource/mcp"

Auth (OAuth 2.1, login via Internet Identity)

/mcp is gated by a bearer token, with login via Internet Identity's registration-delegation connect handshake. II's /mcp handshake logs the user 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:

  • /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 /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.

The RFC 8628 device grant was dropped: no listed MCP client uses it, and it adds a device-code phishing surface with none of the PKCE binding the rest of the flow relies on.

Endpoints:

The production instance is mounted at /mcp (the origin's default instance), so its AS issuer is <PUBLIC_URL>/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/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 (a bounded, LRU-evicted store — see Bounded state); 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 /mcp/oauth/authorize — validates the client + redirect, requires PKCE, sets the binding cookie, then redirects to II's handshake (with registration_key)

  • 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 /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 /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 WWW-Authenticate header pointing at the resource metadata, as the MCP spec expects.

Browser-facing error screens

/oauth/authorize and the pinned connect callback are front-channel endpoints the user reaches in a browser, so any error they stumble upon during sign-in or the II handshake renders as a nicely formatted, on-brand screen — an editorial headline, a best-effort diagnostic ("your client may be out of date; remove and re-add the connector", etc.), and always the line "If this error is unexpected, please contact mcp@dfinity.org to report it." — rather than a raw JSON blob. The shared shell lives in src/assets/connect-error.html + src/assets/connect.css (reused from the connect callback page: same parchment grid, serif display, foot-of-page "Hosted by" mark, light/dark theming) and is fully self-contained under a strict, nonce'd, non-scripted, unframeable CSP that reflects no request value. Authorize errors content-negotiate: a browser (Accept: text/html) gets the screen, a programmatic OAuth caller keeps the RFC-style JSON error. Handshake/redeem failures surface on the callback page, which reveals the same contact line once it enters its error state. The dedicated allow-list rejection (below) is a distinct, actionable screen: it names the concrete next step (request access) instead of the report line. The back-channel endpoints (/oauth/token, /oauth/register, the /mcp bearer gate) stay JSON — no browser ever lands on them.

Consent-Bound Completion (split-browser injection defense)

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 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 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 redeems the code with their own PKCE verifier — a token acting as the victim. (An 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 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;
  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;
  3. plus proven registration — redemption is a signed mcp_register_v2 that must return Ok, so it is synchronous and on-chain, never a bare notification.

In the confused-deputy path the delegation lands in the honest pinned page in the victim's browser, whose sid cookie does not match the one the connect (and its registration key X) was bound to, so the redeem aborts. An attacker who initiates then phishes the II link holds sid but never the delegation (it reaches only the consenting victim's browser); the victim holds the delegation but never sid. Neither can redeem. This closes the split-browser injection for all transports incl. loopback (a loopback redirect resolves on the consenter's own machine). SameSite=Lax still rides the top-level cross-site GET II uses to navigate back to the callback page.

Companion control: the hosted-redirect allow-list. The same-browser variant (a victim socially engineered into running the whole flow toward an attacker-registered hosted redirect_uri) is not closed by Consent-Bound Completion alone, since the victim's browser legitimately holds both proofs. It is closed by a hosted-redirect allow-list: dynamic client registration accepts only a loopback redirect, or a hosted redirect whose host is (a subdomain of) an allow-listed registrable domain and whose path falls within that vendor's pinned OAuth-callback prefix, so an attacker cannot register a hosted destination it controls. The path pin matters because several allow-listed origins also serve third-party, script-capable content on the same origin (perplexity.ai/page/…, chatgpt.com/g/…, /share/…); a domain-only rule would let an attacker register such a path and capture the code from on-origin JS. Pinning to the vendor's dedicated callback path keeps every registration on a vendor-controlled, non-user-content endpoint. The list is seeded with the known 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). No redirect (loopback or hosted) may carry a query or fragment: the authorization endpoint appends ?code=…&state=…, so a pre-existing query would risk code=…&code=… parameter pollution, and a fragment is meaningless on a redirect target. 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) naming the same contact.

The registration-delegation connect handshake

The connect flow never lets II bind a session key it was merely shown. Instead it uses a short-lived (~5 min), two-hop delegation chain P_reg → Y → X delivered to a pinned callback page as a URL fragment: II's canister signs P_reg → Y toward an ephemeral key Y held only by II's frontend — so the piece that transits the IC (replicas, boundary nodes, the public state tree) is inert on its own — and the frontend extends it browser-side with a Y-signed hop to the server's registration key X, assembling the redeemable chain only in the consenting browser. The backend redeems it by signing one mcp_register_v2 call as X, binding the long-lived session key S to the anchor. II never binds a bare key it was shown.

/version is the unauthenticated operations probe. It reports the running build (version, commit, built_at, started_at) and two real-time per-instance session gaugeslive_sessions (open grants) and active_sessions (a subset: those also requesting recently):

curl -s https://<host>/version | jq '{live: .live_sessions, active: .active_sessions}'
# => { "live": { "beta": 0, "prod": 3 }, "active": { "beta": 0, "prod": 1 } }

live_sessions counts authenticated sessions whose II grant has not yet expired. It tracks the grant lifecycle: a session is counted from the moment its grant is redeemed until the grant expires, and sitting idle does not remove it — an ongoing MCP session is often quiet for long stretches between tool calls and is still a live session the whole time. The flip side: the server runs MCP statelessly, so a client that disconnects for good produces no server-side event, and its session keeps counting until the grant expires (the session duration is what the user picked on II's consent screen, 10 minutes up to 30 days).

active_sessions is the subset of live sessions seen active within the last ~15 minutes — a ballpark of who is actually using the server right now. "Active" means an authenticated request in that window, and also the connect that redeems the grant (which is itself treated as activity) — so a freshly-connected session counts immediately, before its first tool call; it is not strictly "made an authenticated request". Prefer it for timing a redeploy: a restart wipes the in-memory session and token maps, forcing every connected client to reconnect, so the disruption falls on whoever is active at that moment, not on long-idle grants. Because it is activity-based, a single reading is a point-in-time snapshot; sample it over time (scrape /version on a schedule, or read request rate from the request logs below) to find a genuinely low-traffic window before deploying. active_sessions ≤ live_sessions always.

curl -s https://<host>/version | jq '.active_sessions'
# => { "beta": 0, "prod": 1 }

Session grants are also traceable in the logs (unit imcp2): a session logs session opened (with instance and session_id) when its grant goes live, and session closed when the grant expires and the per-instance reaper (60s cadence) evicts it. These bracket what live_sessions counts, modulo the reaper's cadence: the gauge drops the instant a grant expires, while the paired closed log lands on the reaper's next sweep (up to 60s later), so opened minus closed can momentarily exceed the gauge by the sessions expired since that sweep. The same sweep logs abandoned connects reaped (with a count) for sign-ins that were started but never completed — those never opened a session, so they get no paired closed line. See Bounded state for everything the sweep covers.

Callback allow-list (/.well-known/ii-auth-callbacks). II is moving to validate the connect callback named in the (attacker-craftable) link fragment against a server-declared allow-list (dfinity/internet-identity#4091): before contacting the callback, II fetches <callback origin>/.well-known/ii-auth-callbacks (redirect: "error", no 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 {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 beta II canister's live .did, fgte5-ciaaa-aaaad-aaatq-cai): the connect link carries registration_key = base64url(DER(pub(X))); II navigates back to the allow-listed callback with the chain plus the connect state: #delegation=<DelegationChain JSON>&state=… (agent-js DelegationChain.toJSON(): hex byte fields, hex-string expiration); and redemption calls mcp_register_v2(session_key) -> variant { Ok : record { expiration; permissions }; Err : text } (the server decodes the outer variant; the Ok payload carries the grant expiry and the access level). The access level and lifetime are not sent by the server: the user chose them at consent, and II stored them at prepare_mcp_registration_delegation on an index keyed by P_reg, so mcp_register_v2 recovers both the consent and the anchor from caller() == P_reg. The server therefore sends only pub(S) and can alter neither the anchor, the permissions, nor the TTL; the anchor number never reaches (or is logged by) this server. (The chosen access level does come back on the reply, feeding the read-only guard.)

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 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 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 calls mcp_register_v2 to bind the long-lived session key S to the anchor (which II recovers from caller() == P_reg — the server never names it). Registration is synchronous, so there is no separate liveness probe or polling page. The read-only level comes back on the mcp_register_v2 reply (feeding the require_write guard).

Verified against deployed beta II. mcp_register_v2 and the delegation-minting methods (prepare_mcp_registration_delegation, get_mcp_registration_delegation) are live on the beta II canister (fgte5-ciaaa-aaaad-aaatq-cai); the shapes here (link param, fragment DelegationChain JSON, the one-argument mcp_register_v2 candid, and the callback allow-list) match its published .did; re-verify if it ever moves. The design tracked II's implementation PRs #4091 / #4092 / #4093 through to this merged shape. This also relies on the callback allow-list (an II-side validation) as its security precondition.

Bounded state (memory + disk)

Every map the server keeps is capped, because two of the endpoints that fill them are reachable without authentication: POST /oauth/register (open dynamic client registration, which MCP clients require) and GET /oauth/authorize (which mints a session with two Ed25519 keypairs per call). Left unbounded, a bare request loop against either was an unauthenticated memory-growth primitive, and registration additionally rewrote the whole persisted store on every call — O(N²) disk I/O for N registrations. Each map now has an admission bound (what caps it between sweeps) and a reaper (what returns the memory), and none of them can be filled at the expense of an authenticated user:

State Cap When full
Client registrations (POST /oauth/register) 10 000 least-recently-used registration is evicted; every /oauth/authorize marks its client used, so a flood evicts its own unused entries first, and an evicted client re-registers (DCR is automatic)
Connects in flight (GET /oauth/authorize, one session + one pending-authz entry each) 1 024 oldest pending connect is evicted; sessions holding a live grant are never touched
Sessions overall 20 000 a new connect is refused (503, on-brand "server is busy, try again" screen) rather than evicting an authenticated user's live grant
Authorization codes 4 096 closest-to-expiry code is evicted (inserting one requires a completed II consent)
Access tokens 20 000 closest-to-expiry token is evicted
Per-app delegation cache, per session 64 origins/accounts entry nearest expiry is evicted (it would be re-derived anyway)

The per-instance reaper (60s cadence — spawn_session_reaper, which a deployment must call) drops expired grants, connects abandoned mid-handshake (a session that never redeemed a grant, after 15 minutes), pending authorizations past their 10-minute TTL, unexchanged authorization codes, and expired access tokens. Registrations are the one long-lived map: they must survive a restart, so they are persisted — now on a coalescing background writer (at most one full write per 2s) instead of one full rewrite per registration.

Note the server itself does no rate limiting; these bounds cap what a flood can cost in memory and disk, not the request rate. Put a rate limiter in front (the reverse proxy) if you need that.

Read-only sessions

II's consent screen defaults to read-only (opt-out). A user who just clicks "Allow" gets a session whose per-app delegations are permissions = "queries", and the IC rejects update calls made through them at ingress. That makes the entire canister-management surface inert — create/install/start/stop/ uninstall/delete, and even icp_canister_status, are update calls. To handle this without opaque low-level errors:

  • The mcp_register_v2 reply carries permissions: "queries" | "all", so the server learns the level at connect without minting a probe delegation. A missing or unrecognized value leaves the level unknown (not assumed writable): the update is attempted and the IC's ingress rejection is the fallback signal.
  • Management tools check it up front and, for a known read-only session, return an actionable "reconnect with read-only off" message instead of an ingress error.
  • get_app_principal reflects a read-only session in its output, so the agent won't attempt updates it can't make.

The server generates a fresh per-connection Ed25519 session key S and binds it itself. At connect redemption it signs one mcp_register_v2(pub(S)) call as the registration key X (see the handshake above), which binds S to the user's anchor as a time-boxed grant. priv(S) never leaves the backend. The issued access token is bound to the session key's principal (self_authenticating(session_pubkey)), which is exactly the identity the grant is bound to.

PKCE (S256) is required for the authorization-code flow; auth codes live 120s, and the access token's lifetime tracks the II grant — it expires exactly when the grant does, so the session duration the user picks on II's consent screen (10 minutes up to 30 days) is how long the client's token stays valid. Refresh tokens remain a deliberate non-goal: with the token matched to the grant there is nothing to refresh against — when the grant lapses, so does the token, and the client re-runs the authorization-code flow. (If the grant expiration isn't known at issue time, the token falls back to a 1h TTL; the grant is the hard ceiling at II either way.) Treat any Unauthorized from II as "session over → reconnect": the server surfaces a reconnect message and does not retry.

Set the public base URL (used in the discovery docs, as the MCP origin, and as the management identity's derivation origin) with PUBLIC_URL. The /mcp endpoint is production Internet Identity: II_URL_PROD (browser login, default https://id.ai) plus II_CANISTER_ID_PROD (the canister the mcp_* calls target, default rdmx6-jaaaa-aaaaa-aaadq-cai); both point at the same II.

Staging: beta instance (/mcp-beta)

The staging deployment exposes a second, fully isolated instance connected to beta Internet Identity at /mcp-beta, opt in with MCP_SERVE_BETA (1/true/yes/on). It is off by default, so a production deployment serves /mcp alone. When served it has its own authorization server under /mcp-beta/oauth/* (issuer <PUBLIC_URL>/mcp-beta, an RFC 8414 path issuer; AS metadata at /.well-known/oauth-authorization-server/mcp-beta plus the OIDC-style /mcp-beta/.well-known/… alternate, resource metadata at /.well-known/oauth-protected-resource/mcp-beta). Configure the beta II with II_URL (default https://beta.id.ai) and II_CANISTER_ID (default fgte5-ciaaa-aaaad-aaatq-cai).

Sessions and tokens are per-instance (a /mcp token is not valid on /mcp-beta and vice versa), while dynamic client registrations are shared (they only pin redirect URIs). II trust is by origin, so users enable this server's origin as their trusted MCP server in their beta.id.ai settings (a separate identity from their production anchor).

Domain identities (on demand)

There is no per-app browser sign-in. Instead the model is:

  • One registered session key per connection. When you connect, the backend generates a per-connection Ed25519 session key and II's frontend registers it as a time-boxed grant bound to your anchor. The backend signs II's mcp_* calls directly with that key (its principal self_authenticating(session_pubkey) is what the grant is bound to). Reconnect when the grant expires or is revoked.
  • App delegations minted on demand. When canister_query / canister_update_call (or get_app_principal) is invoked with a derivation_origin (resolved once via open_app/resolve_app), the backend mints a short-lived per-app account delegation on demand: signing as the session key, it calls Internet Identity's account-derivation methods directly — no browser round-trip — with the app's target origin and a fresh per-app key as session_key. The returned delegation is issued to that per-app key, so the backend signs the canister call with ic-agent's DelegatedIdentity over [user_key → per-app key].

The on-demand derivation calls these II canister methods (per dfinity/internet-identity#4086):

mcp_prepare_delegation :
  (target_origin: text, account_number: opt nat64, session_key: blob, max_ttl: opt nat64)
    -> (variant {
         Ok: record { user_key: blob; account_number: opt nat64; expiration: nat64 };
         Err: AccountDelegationError });
mcp_get_delegation :
  (target_origin: text, account_number: opt nat64, session_key: blob, expiration: nat64)
    -> (variant { Ok: SignedDelegation; Err: AccountDelegationError }) query;
  • session_key is the DER pubkey of a fresh per-app key, distinct from the connection's session key; the minted delegation is issued to it.
  • target_origin is the app's bare https://<host> origin. II derives the principal from an anchored regex on that bare origin, so the server first strips any path, query, fragment, trailing slash, or redundant :443 (a stray one would derive a different principal), then applies the gateway remap: *.icp0.io / *.icp.net*.ic0.app. Note this replicates only II's domain-based derivation — a custom derivation origin declared via /.well-known/ii-alternative-origins isn't visible through the mcp_* methods, so an app using one derives a different principal here than in a browser (see the caveat under Tools); fetching that declaration is a future enhancement.
  • account_number names which of the anchor's accounts at target_origin to act as; null selects the (mutable) default account there. prepare resolves it and returns the concrete account in its reply, which is threaded back into get so both calls sign for the same account. The server passes null for the default account, or a specific number when an account name was given — resolved from mcp_get_accounts (see Listing accounts below).
  • max_ttl is in nanoseconds; the server passes null, so II applies its default (≤ 1 hour, and never past the grant).
  • These methods live on the same II instance as the connect-time login: II_URL (default https://beta.id.ai) is the browser login origin and II_CANISTER_ID (default fgte5-ciaaa-aaaad-aaatq-cai, that instance's canister) is the canister these calls target, over https://icp-api.io.
  • Derived delegations are cached per (session, derivation_origin, account_number) and reused until they near expiry, then re-derived.

Listing accounts

A user can hold several accounts at one app: a default account everyone gets automatically (the anchor's current, user-controllable default at that origin), plus any named accounts they created there. Each account is a distinct per-origin principal — the app never sees a global, cross-app identity. list_app_accounts(derivation_origin) returns them by calling II's

mcp_get_accounts : (target_origin: text)
  -> (variant { Ok: vec AccountInfo; Err: AccountDelegationError }) query;
type AccountInfo = record {
  account_number: opt nat64; origin: text; last_used: opt nat64; name: opt text;
};

signed as the session key. Like the delegation methods, II recovers the anchor from the caller (the registered session-key principal), so no anchor number is needed. To act as a non-default account, pass its name to canister_query/canister_update_call/get_app_principal as account; the server resolves the name to its account_number via mcp_get_accounts and threads that into the on-demand delegation. Omitting account uses the default account.

Status: the connect handshake and the mcp_register_v2 / mcp_get_accounts / mcp_prepare_delegation / mcp_get_delegation canister methods are the session-key registration model from dfinity/internet-identity#4086 (the server is built against that candid contract). #4086 renames the on-demand delegation methods from the earlier mcp_prepare_account_delegation / mcp_get_account_delegation and removes mcp_set_access / mcp_access_enabled. The live round-trip works once that II build is deployed to the configured II_URL. Passing account_number = null resolves to the anchor's current (user-controllable) default account at the origin — which may be a named account the user set as their default there, not necessarily the anchor's base account.

Roadmap

  • Candid tools over MCP streamable-HTTP; discover_app_canisters; Candid reference resources.
  • 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 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.)
  • On-demand domain identities: the registered session key mints per-app account delegations directly via II canister methods (canister_query/canister_update_call/get_app_principal derivation_origin); no per-app browser flow.
  • Per-app accounts: list_app_accounts(derivation_origin) lists the user's accounts at an app (via mcp_get_accounts), and canister_query/canister_update_call/get_app_principal take an account name to act as a specific (non-default) account.
  • Deploy the mcp_register_v2 + mcp_get_accounts + mcp_prepare_delegation + mcp_get_delegation canister methods (server is built against the merged II candid contract; the live round-trip lands with the II side).
  • Persist sessions/delegations (currently in-memory, lost on restart).
  • Scoped delegations / per-call confirmation for sensitive methods.

License

Licensed under the Apache License, Version 2.0.

Copyright © DFINITY Stiftung.

Contribution mode

This repository is public but closed to external code contributions: pull requests from outside the DFINITY organization are not merged (and may be closed automatically). Bug reports and suggestions are welcome — please open an issue.

If this repository is later opened to external contributions, contributors will be required to sign the DFINITY CLA, and this section will be updated accordingly.

See CONTRIBUTING.md for how to build and test the project, and SECURITY.md for reporting security vulnerabilities. Participation is governed by our Code of Conduct.

Releases

Packages

Used by

Contributors

Languages