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).
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, andauth_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).
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).
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.
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 asderivation_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(orresolve_app) turns an app name/URL into it and reports how —derivation_origin_source: declared (/.well-known/ic-app.json→derivation_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 recordsderivation_origin_source= explicit. Every identity result echoesderived_for_origin(the origin actually used) alongsiderequested(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-frontopen_app/resolve_appavoids 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_apprefuse anapp_urlwhoseapp_url_defaultassumption would apply while the origin shows no IC evidence — the gateway'sx-ic-canister-idheader (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 asderivation_origin(which is trusted verbatim, ungated). Under the hood the origin goes through the same canonicalizer the delegation path uses (barehttps://<host>, with the*.icp0.io/*.icp.net→*.ic0.appgateway remap below). For backward compatibility the identity tools still accept the legacy parameter namedomainas an alias forderivation_origin.
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.
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.
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 theicpCLI /cycles-managementskill) and check it withicp_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.)
Add the server to Claude Code (replace the URL with wherever it's hosted):
claude mcp add --transport http ic-poc https://YOUR-HOST/mcpThen run /mcp → ic-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).
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)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-timemcp_server_originon 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 runThe 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"/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/authorizevalidates the client + redirect and PKCE, sets a browser-binding cookie (see below), and redirects to II's handshake, carrying this connect's registration public keypub(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 (onemcp_register_v2call), mints a PKCE-bound code, and returns the clientredirect_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; advertisesgrant_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_urisare stored and persisted toOAUTH_CLIENTS_FILE(a bounded, LRU-evicted store — see Bounded state); requestedgrant_typesare honoured (intersected withauthorization_code). A hostedredirect_uriis 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 (withregistration_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 viamcp_register_v2, mints a PKCE-bound code, and returns the clientredirect_uri?code=…&state=… -
POST /mcp/oauth/token— exchanges an authorizationcode(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.
/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.
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:
- initiator — an unguessable
HttpOnly; Secure; SameSite=Laxsidcookie (scoped to the instance's…/oauthpath) set at…/oauth/authorize; - 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;
- plus proven registration — redemption is a signed
mcp_register_v2that must returnOk, 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 withOAUTH_ALLOWED_REDIRECT_PREFIXES(additive; each entry a fullhttps://host/pathURL 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 riskcode=…&code=…parameter pollution, and a fragment is meaningless on a redirect target. Enforced at both…/oauth/registerand…/oauth/authorize. A client turned away is pointed at a contact address to request approval:…/oauth/registersays so in its JSONerror_description, and a browser that reaches…/oauth/authorizegets an on-brand "not allowed" page (not a raw error) naming the same contact.
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 gauges — live_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 connectsid;priv(X)never leaves the backend, andpub(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 readslocation.hashclient-side, POSTs it (with the connect cookie) toPOST …/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
DelegatedIdentityfrompriv(X)+ the delegation and callsmcp_register_v2to bind the long-lived session keySto the anchor (which II recovers fromcaller() == 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 themcp_register_v2reply (feeding therequire_writeguard).
Verified against deployed beta II.
mcp_register_v2and 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, fragmentDelegationChainJSON, the one-argumentmcp_register_v2candid, 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.
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.
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_v2reply carriespermissions: "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_principalreflects 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.
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).
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 principalself_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(orget_app_principal) is invoked with aderivation_origin(resolved once viaopen_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 assession_key. The returned delegation is issued to that per-app key, so the backend signs the canister call withic-agent'sDelegatedIdentityover[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_keyis the DER pubkey of a fresh per-app key, distinct from the connection's session key; the minted delegation is issued to it.target_originis the app's barehttps://<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-originsisn't visible through themcp_*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_numbernames which of the anchor's accounts attarget_originto act as;nullselects the (mutable) default account there.prepareresolves it and returns the concrete account in its reply, which is threaded back intogetso both calls sign for the same account. The server passesnullfor the default account, or a specific number when anaccountname was given — resolved frommcp_get_accounts(see Listing accounts below).max_ttlis in nanoseconds; the server passesnull, 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(defaulthttps://beta.id.ai) is the browser login origin andII_CANISTER_ID(defaultfgte5-ciaaa-aaaad-aaatq-cai, that instance's canister) is the canister these calls target, overhttps://icp-api.io. - Derived delegations are cached per
(session, derivation_origin, account_number)and reused until they near expiry, then re-derived.
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_delegationcanister 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 earliermcp_prepare_account_delegation/mcp_get_account_delegationand removesmcp_set_access/mcp_access_enabled. The live round-trip works once that II build is deployed to the configuredII_URL. Passingaccount_number = nullresolves 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.
- Candid tools over MCP streamable-HTTP;
discover_app_canisters; Candid reference resources. - OAuth 2.1 auth (authorization-code + PKCE): II's
/mcpregistration-delegation handshake binds the connection's session key (a fragment-delivered, canister-signed delegation redeemed viamcp_register_v2), with Consent-Bound Completion binding…/oauth/connect/redeemto both the initiator (sidcookie) 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_principalderivation_origin); no per-app browser flow. - Per-app accounts:
list_app_accounts(derivation_origin)lists the user's accounts at an app (viamcp_get_accounts), andcanister_query/canister_update_call/get_app_principaltake anaccountname to act as a specific (non-default) account. - Deploy the
mcp_register_v2+mcp_get_accounts+mcp_prepare_delegation+mcp_get_delegationcanister 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.
Licensed under the Apache License, Version 2.0.
Copyright © DFINITY Stiftung.
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.