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`](https://github.com/dfinity/agent-rs). The MCP layer is the
[official Rust SDK](https://github.com/modelcontextprotocol/rust-sdk) (`rmcp`).
**This server reaches only canisters an app opted in.** A canister being publicly
callable is not a statement by its operators that they want an agent reading it
or driving it, so every tool that touches one — `get_canister_candid`,
`get_canister_api_doc`, `get_canister_oql_schema`, `canister_query` and
`canister_update_call` — works **only** on a canister the owning app declares in
its
[service-discoverability](https://docs.internetcomputer.org/guides/frontends/service-discoverability/)
manifest at `/.well-known/ic-architecture`. Publishing that manifest is how an
app's operators opt in; an app that publishes none can still be resolved and
discovered, but none of its canisters can be read or called through this server.
See [Reaching a canister is gated on the discoverability manifest](#reaching-a-canister-is-gated-on-the-discoverability-manifest).
**Not for financial operations.** IMCP2 is infrastructure tooling for reading,
building, and operating canisters — it is not a wallet or trading tool, and
financial operations (token transfers, spending approvals, payments, trades)
are unsupported by policy. The standardized ledger surface is refused
mechanically (`canister_update_call` rejects the ICRC-1/ICRC-2 and related
ledger-standard transfer/approval methods on every canister), and the
connector serves no funding or management tools: creating, funding, and topping up
canisters is done by the user with the
[`icp` CLI](https://github.com/dfinity/icp-cli) in their own terminal.
For financial operations, users act themselves outside the connector, in a
trusted interface they control.
## Use as a library
Published on [crates.io](https://crates.io/crates/imcp2), API docs on
[docs.rs](https://docs.rs/imcp2):
```toml
[dependencies]
imcp2 = "0.4"
```
One `McpServer` serves one Internet Identity instance as two
[`axum`](https://github.com/tokio-rs/axum) routers:
- **`McpServer::mcp_router()`** — the MCP streamable-HTTP endpoint (the router
fallback, bearer-token gated) plus the OAuth authorization server under
`/oauth`, nested at the mount path of your choice.
- **`McpServer::well_known_router()`** — the OAuth discovery documents, merged
at the application root (well-known URIs are origin-scoped), **parametric on
the mount path**: the AS issuer is `{public_url}{mcp_path}` (an RFC 8414 path
issuer) and the documents live at the path-inserted well-known locations.
`root_well_known_router()` adds the plain-root fallback documents for the
origin's default instance, and `auth_callbacks_router(&[…])` the
origin-global II auth-callback allow-list covering every instance.
The IC `Agent` is **inherited from the caller**, not built by the library: a
host — an API boundary node or a gateway — passes in its own route-configured
agent, so the whole process links one `ic-agent` and shares one boundary-node
client. (`ic-agent` stays a direct dependency until
[`ic-bn-lib`](https://github.com/dfinity/ic-bn-lib) — the shared BN/gateway
crate it should eventually be sourced through — releases against ic-agent 0.49;
see `Cargo.toml`.)
```rust
use imcp2::{auth_callbacks_router, Agent, IiInstance, McpConfig, McpServer, SharedClients, IC_URL};
#[tokio::main]
async fn main() -> anyhow::Result<()> {
// Standalone: a default mainnet agent. Embedded: hand in the host's own.
let agent = Agent::builder().with_url(IC_URL).build()?;
// Where imcp2 keeps operational state (the client-registration store).
let state_dir = std::path::PathBuf::from("/var/lib/imcp2");
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(&state_dir),
state_dir,
require_resource: true, // strict RFC 8707 (reject a missing `resource`)
cimd_enabled: true, // Client ID Metadata Documents (URL client_ids); false turns them off
});
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)
GET /.well-known/ii-app-metadata (origin-global: name + legal links shown by II)
```
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).
### Metrics (`imcp2::metrics`)
Prometheus instrumentation, usable from the library (the `prometheus` crate at
the version `dfinity/ic` pins). **The registry belongs to the caller**:
`Metrics::new` registers into a `Registry` you supply — exposition stays your
job, and there is no `render` here.
```rust
let registry = prometheus::Registry::new(); // or the host's own
let metrics = imcp2::metrics::Metrics::new(
®istry, version, commit, started_at, &[&server])?;
let app = app
.layer(axum::middleware::from_fn_with_state(
metrics.clone(), imcp2::metrics::write_request_metrics))
.layer(axum::middleware::from_fn(imcp2::metrics::write_request_logs));
// in your exposition path (or on a timer): metrics.refresh().await
```
Published as `imcp2_*`: request counts and a latency histogram (`route` and
`method` labels are bounded — a label an outsider picks is a memory-exhaustion
primitive; see the module docs), `live_sessions` / `active_sessions` per II
instance (labelled `ii_instance`, because Prometheus's own `instance` target
label renames a colliding exposed one to `exported_instance`), `build_info`, and
process start time. `register_process_collector(®istry)` adds CPU/RSS/fd
separately — un-namespaced `process_*` belongs to the application, not the crate.
The bundled binary serves `/metrics` only when `$MCP_SERVE_METRICS` is set: the
native deployment sets it and has Caddy hide the path publicly; a PaaS deployment
from the bundled `Dockerfile` has no proxy in front and leaves it off.
## 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 repairs to the canonical URL); an explicit `https://` URL is resolved as given. An unknown bare name is *refused*, and so is a URL that would need its own origin assumed as the derivation origin while showing no IC evidence (never guessed). Also probes the app's own **declared** canisters and reports per-canister `oql`/`api_doc_available` capability flags — for **up to eight** eligible canisters, with both fields *omitted* (not false) on any beyond that, and on any candidate the app does not declare (which this connector cannot read, so it is not probed) — plus a data-access note (which canister is read through the OQL path, and the origin that path requires). No auth |
| `get_canister_candid` | `canister_id`, `app_url?`, `derivation_origin?` | 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`, `app_url?`, `derivation_origin?` | 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 for every documentation outcome — `available` + the doc on success, else `available:false` with `expected`/`retry`/`next`, so "no compatible method was detected" is distinct from "no answer was obtained". An unusable `canister_id` is rejected before any lookup and is a plain error, not that shape; and `expected:true` is not proof of absence, since an interface the parser cannot read also comes up empty |
| `canister_query` | `canister_id`, `method?` **or** `oql?`, `args?` (textual Candid), `app_url?`, `derivation_origin?`, `account?`, `candid?` | 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`. `candid` is a fallback: the `.did` interface text to encode/decode against when the canister exposes no `candid:service` metadata. Echoes `derived_for_origin` / `requested` so an origin mismatch is visible (never the principal — `get_app_principal` returns it on request) |
| `canister_update_call` | `canister_id`, `method`, `args` (textual Candid), `app_url?`, `derivation_origin?`, `account?`, `candid?` | 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`). **The target must be declared by its app**: the call is made only when the app at `app_url` (from `open_app`; falling back to `derivation_origin` when no `app_url` is given) declares `canister_id` in its `/.well-known/ic-architecture` manifest — see [Reaching a canister is gated on the discoverability manifest](#reaching-a-canister-is-gated-on-the-discoverability-manifest). When `derivation_origin` is also given, the two must belong to the same app — the app at `app_url` is resolved to the derivation origin II derives its users from, and a mismatch is refused rather than signed. The reply echoes `declared_by` / `declared_at` (which origin authorized the write, and at which path). **Financial transactions are refused**: the ICRC-standard transfer/approval methods (ICRC-1/ICRC-2 and the ICRC-4/-7/-37 equivalents) and the NNS/SNS governance method `manage_neuron` (neuron staking and disbursement) are disallowed on every canister, and the ICP and cycles ledgers' own value-moving methods (the legacy `transfer`, `withdraw`, the `create_canister` spends) and the cycles-minting canister's funding-completion methods (`notify_top_up`, `notify_create_canister`, `notify_mint_cycles`, `create_canister`) on those canisters; and **every** update call is refused on the financial-service canisters the guard carries — all to protect the user. The refusal directs the user to perform the operation outside the connector, in a trusted interface they control — or, for canister creation and funding, with the [icp CLI](https://github.com/dfinity/icp-cli) in their own terminal. The policy is stated in the server-level instructions, deliberately not in any tool description. `candid` is the same `.did` fallback as on `canister_query`, used when the interface isn't published on-chain. Echoes `derived_for_origin` / `requested` so an origin mismatch is visible (never the principal — `get_app_principal` returns it on request) |
| `get_app_principal` | `derivation_origin`, `account?` | The principal you act as at an app. Identify the app by its `derivation_origin` (from `open_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, and the derivation origin they were listed for (no principals and no last-used timestamps: the listing exists to pick an account). Identify the app by its `derivation_origin` (from `open_app`) |
| `candid_syntax_guide` | — | The textual-Candid value syntax the `args` of `canister_query` / `canister_update_call` take and their replies come back in: the literal form for each Candid type, and when a value needs an explicit `: type` annotation. The full type system stays available as the `candid://reference` MCP resource |
| `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`, `app_url?`, `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**: this server rejects an anonymous read (for now) with guidance — its own rule, not an inference about the canister — rather than calling `schema` anonymously and returning an empty list |
Every row above that names a `canister_id` takes `app_url`, and for the same
reason: the app at that URL must declare the canister in its
`/.well-known/ic-architecture` manifest, or the call is refused before the
canister is contacted. `open_app` returns the `app_url` alongside the canisters
it discovered, and marks the declared ones `[declared]`. Omitted, `app_url` falls
back to `derivation_origin`; given both, they must belong to the same app. Each
successful reply echoes `declared_by` / `declared_at` — which origin authorized
the call, and at which path. See
[Reaching a canister is gated on the discoverability manifest](#reaching-a-canister-is-gated-on-the-discoverability-manifest).
`open_app` (its `app` argument takes a name **or** a URL) is the one-call entry point
when the user names or links an app: it resolves the Internet Identity
`derivation_origin` **and** discovers the
canisters behind the app together (see [Typical flow](#typical-flow)).
Its canister sources are listed in the table row above (app-declared metadata
first, then the `x-ic-canister-id` frontend header, then backend candidates
mined from `/env.json` + the JS bundle); among the mined candidates, pick by
label, prefer production/`IC_` ids, and 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 resolve offline) or a URL you have (e.g.
`https://opencloud.org`); it returns the `derivation_origin` **and** the app's canisters in one shot
(it resolves the origin and discovers the 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 declared derivation origin) is refused too; when the host
resembles a known app the error names it and gives the real URL (a
"did you mean" repair).
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, passing the `app_url` from step
0–2 — 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` too).
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.
Steps 5–7 all take the `app_url` from step 0–2: it is what authorizes reaching
the canister at all, and only the canisters `open_app` marked `[declared]` are
within reach. A canister id from anywhere else — a mined candidate, a dashboard
lookup, the user's clipboard — needs the URL of the app that declares it before
any of these tools will touch it. Identity is a separate question: a Candid
`method` query can still run anonymously (no `derivation_origin`), while an OQL
read always needs one, so steps 3/4 are skippable but step 0–2 is not. The
per-canister inspection (5) is independent of the identity steps (3/4), so they
can run in parallel. Managing your
**own** canisters is not part of this connector: create and manage them with the
[`icp` CLI](https://github.com/dfinity/icp-cli) in your own terminal.
### App-declared canister metadata
The authoritative statement about which canisters an app comprises is the one
the app publishes itself. Two such statements are read, both from bytes the app
serves:
**The service-discoverability manifest** (`/.well-known/ic-architecture`) —
[Layer 1 of the protocol](https://docs.internetcomputer.org/guides/frontends/service-discoverability/)
— enumerates *every* canister the app comprises, with roles:
```json
{
"version": "1.0.0",
"canisters": [
{ "id": "aaaaa-…-cai", "name": "backend", "role": "the backend",
"description": "orders + inventory API; call getApiDoc() first" },
{ "id": "bbbbb-…-cai", "name": "frontend", "role": "the frontend" }
]
}
```
Each entry needs an `id` (a canister principal); `name`, `role`, and
`description` are optional and become the finding's label (`role — description`,
falling back to `name`). Unknown fields are ignored, so the format can grow.
The gate holds `id` to the protocol's own type rule — an entry must be a
**canister** principal (a 10-byte opaque id) to authorize anything, so a manifest
cannot declare a user principal, the anonymous principal, or the management
canister `aaaaa-aa` (all of which parse as principals) and have a call follow.
This manifest is also the **only** thing that permits reaching a canister — see
[Reaching a canister is gated on the discoverability manifest](#reaching-a-canister-is-gated-on-the-discoverability-manifest).
Before the protocol was published this server proposed the same document at
`/.well-known/ic-app.json`, with an extra top-level `derivation_origin` field.
That path is still read for discovery, at lower authority, so those apps stay
legible — but it does not authorize a call (see below); new apps should publish
`/.well-known/ic-architecture`
and, if they pin a custom derivation origin, `/.well-known/ii-derivation-origin`
(the protocol's Layer 5 — one canonical `https://host` on a single line, which
takes precedence over the legacy field).
Both are the app's own claim about its composition — stronger than anything
mined from client code. Both also **fail closed** on the most common
misconfiguration, an SPA catch-all serving `index.html` at these paths: the JSON
parse fails, and the derivation-origin file's first line is not an origin. Every
id is validated as a principal before it is kept.
### Reaching a canister is gated on the discoverability manifest
A canister being publicly callable is not a statement by its operators that they
want an agent reading it or driving it. So every tool that reaches a canister —
`get_canister_candid`, `get_canister_api_doc`, `get_canister_oql_schema`,
`canister_query` (both paths) and `canister_update_call` — is restricted to
canisters an app **declares** in its `/.well-known/ic-architecture` manifest.
Publishing that manifest is a deliberate act, and per the protocol guide it is
exactly how an app's operators say "these are my canisters; an agent handed my
URL may work them out and use them".
Reads and writes run the *same* check and differ only in what a refusal says: it
names the operation that was actually attempted, so an agent never relays "a
state-changing call was refused" about a read it tried to make.
**Only the standard path authorizes.** The legacy `/.well-known/ic-app.json`
document is still read during discovery, but it does not authorize a call: the apps
serving it adopted a proposal this server made before the protocol existed, under
different terms, and never agreed to the ones publishing the standard manifest now
signifies. Consent that was never given cannot be inherited from a path this server
invented. An origin serving only the older document gets a refusal of its own,
naming the document it *does* publish — reporting it as publishing nothing would
send its operators hunting for a file that is already there — and saying that
serving the same JSON at `/.well-known/ic-architecture` is the whole fix.
**The manifest and the identity are bound.** `app_url` picks which manifest is
read; `derivation_origin` picks whose principal signs — and for an authenticated
read as much as for a write, since a read runs as the user's principal at
whichever app they named. Left unbound those are
separable, and separable is exploitable: publishing a manifest is free and the
gate deliberately does not prove ownership, so an attacker's origin could declare
someone else's canister while the call went out under the principal the user holds
at an app they actually trust. So when a call carries both, the app at `app_url`
is resolved to the derivation origin Internet Identity derives its users from, and
a call naming a different one is refused. The comparison is against what the app
*resolves to* — its declared Layer 5 origin, else a known-app value, else its own
origin — so the many apps whose derivation origin differs from their website (13
of 17 in the built-in registry) still pass, and Internet Identity's own
`ii-alternative-origins` rule is enforced on the way, so an app cannot simply
claim another's identity to satisfy the check. An attacker's manifest can then
only authorize calls made as the attacker's own app identity, which is worth
nothing to them. `open_app` returns a matching pair, so the normal flow never
trips this.
The manifest lives at the app's origin, not on chain, so the tool has to be told
which app owns the target: that is the `app_url` argument (`open_app` returns it),
falling back to `derivation_origin` when the app serves its manifest at that same
origin. The gate then fetches the manifest — under the same SSRF hardening and
size caps as the rest of discovery — and refuses unless the target is listed.
Every refusal names the standard path, links the guide, and distinguishes its
cause (no origin given / origin unreachable / no manifest published / only the
legacy document published / published but this canister is not in it — that last
one lists what the app *does* declare). What it can no longer say is "read it
instead", so what stops an agent from concluding the whole app is off limits is
the sentence naming what still works: `open_app` resolves the app and lists what
it declares, and those canisters are the ones within reach. A successful call
echoes `declared_by` and `declared_at`, so the call's provenance is visible in
the reply.
Four things this gate is not:
- **It is not proof of ownership.** Whoever controls a domain controls what its
manifest says, so a manifest can name a canister its publisher does not own.
What the gate establishes is that *someone* published a document, at an origin
the caller named, claiming that canister as part of their app — and that a
call can be traced back to that claim. Two limits worth stating outright: an
anonymous call skips the identity binding (there is no app identity to
protect), and an authenticated one binds to the *caller's* app while still
reaching any victim method that accepts an arbitrary principal. Neither grants
a capability an attacker lacked — the IC accepts ingress from anywhere, so both
calls can be sent with an ordinary agent — so what the gate withholds is this
connector's willingness to make them **on a user's behalf**, and the binding is
what keeps the user's own app principals out of it. Closing the rest needs an
association the *target* attests to, which the protocol does not define today.
- **It is not a substitute for the canister's own authorization.** The IC still
decides what the calling principal may do; this only decides what this server
is willing to send.
- **On reads, it is stricter than the protocol asks for.** The guide frames
discoverability as an aid to *reading*, and reading the IC is open to anyone
with an ordinary agent, so gating reads withholds nothing from an attacker: it
is a statement about whose canisters this connector will read **on a user's
behalf**. The cost is real and lands on discovery — a candidate mined from an
app's `/env.json` or JS bundle can no longer be confirmed by reading its
interface, so a manifest is now the only route from an app to a usable
canister, and `open_app` leaves the capability flags of an undeclared candidate
blank rather than probing it.
- **It is not a restriction on discovery.** `open_app` resolves an app and lists
its canisters exactly as before, the guides are static, and the identity tools
(`get_app_principal`, `list_app_accounts`) touch no canister. An agent refused
here can still say which app it meant and what that app declares.
Discovery fetches are **SSRF-hardened** (CWE-918). Only `https` URLs with a real
host are fetched, and every outbound fetch runs under a redirect guard (a 3xx may only
go to a **globally-routable** IP or the same host, never a different private
target; capped at 10 hops) with per-body and aggregate size caps, so a hostile or
accidental large body can't exhaust memory. The untrusted **user-supplied** site
fetches (an app origin from `open_app`, or
the `app_url` the gate checks on every canister-reaching tool)
additionally resolve the target host up front and **pin** the connection to that
validated globally-routable address, so a name resolving to a
private/loopback/link-local address is refused and re-resolution can't rebind
mid-flight. Fixed public-host enrichment (the IC dashboard)
uses the redirect guard but is not separately address-pinned. No JavaScript is
executed, and every extracted id is validated as a principal.
Note that the gate makes every canister-reaching tool an **outbound-fetching**
one: touching a canister now contacts the owning app's website (once per call) to
read its manifest, where previously only the discovery/resolution tools did.
There is no manifest cache, so that fetch is paid per call — on the read path
that is a per-query DNS resolve, TLS handshake and two HTTP GETs ahead of the
boundary-node call. A
manifest is only honoured when the origin the connector **probed** is the origin
that **answered** — a redirect can't let one origin borrow another's declaration
and have `declared_by` name the wrong app.
The optional top-level **`derivation_origin`** is this document's own declaration
of the Internet Identity derivation origin an app's frontends pin (see the
identity section above). The protocol moved that declaration to its own file, so
**a new app should publish `/.well-known/ii-derivation-origin`** instead — one
canonical `https://host` on a single line, which takes precedence over this field.
The field is still read for the apps that shipped against it. Either is the only
authoritative way for `open_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 pins one should declare it; otherwise the connector assumes the
derivation origin equals the application origin and flags that assumption.
When the user names a **token, project, or service** rather than a website or
id, web search the canister id or ask the user for it.
(`open_app` results are annotated with the dashboard's labels
inline.)
`canister_query` and `canister_update_call` run anonymously by default; pass a
`derivation_origin` to call as
your account at that app. (Anonymous still means anonymous *identity* — every
call that reaches a canister additionally needs an app origin, via `app_url` or
`derivation_origin`, for the manifest check above; there is no origin-less read
or write.) The server mints a **short-lived account delegation on
demand** using the connection's registered Internet Identity session key (see
[Domain identities](#domain-identities-on-demand)) — 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` — 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` turns an app name/URL into it and
> reports how — `derivation_origin_source`: **declared**
> (`/.well-known/ii-derivation-origin`, else the legacy `/.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 (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` 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`
> *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 as an *identity*, ungated
> — reaching that app's canisters still needs its declaration). 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
The official Internet Computer
[skills](https://skills.internetcomputer.org) — DFINITY-authored how-to
guides for authoring and shipping IC apps (the Motoko language, the `mops` and
`icp` CLIs, cycles management, stable memory & upgrades, canister security,
auth, …) — are served as MCP **resources** (`skill://<name>`) alongside the
`candid://` references. The documents come from a reviewed, versioned bundle
compiled into the binary at build time (`crates/imcp2-core/static/skills/`,
provenance and refresh procedure in the bundle's README); the server
retrieves no instructions over the network, so what an agent reads is
exactly what was reviewed at release. The companion documents a skill links
to are bundled and served the same way, as
`skill://<name>/references/<file>`, so following a link inside a skill
resolves to another served resource rather than sending the agent back to
the network.
### 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. That
guard is a pre-parse structural check that rejects textual Candid over 1 MiB or
nested past 128 levels before the parser runs, so a malicious input can't
stack-overflow and abort the whole process, taking every concurrent session with
it; it covers the textual-Candid inputs (tool `args`, `.did` interfaces, and
install `arg`), and is fail-closed, so a malformed `.did` degrades to `oql: false`.
The same ceiling is enforced past the parser on the untrusted structures
themselves: type-alias chains in a fetched interface are depth-bounded during
resolution, and the type-less reply decode/render path is depth-bounded too, so
a hostile canister's `.did` or reply can't recurse the decoder even when each
individual layer is small.
The OQL query path is JSON rather than Candid, so it shares the same 1 MiB size cap
but is parsed by the JSON parser, not this structural guard. 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 —
this server **rejects** an anonymous per-app read (for now) with guidance to pass
the origin rather than silently returning empty — the connector's rule, not a claim
about how any given canister authorizes callers
(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.
## Connect from an MCP client
Add the server to Claude Code (replace the URL with wherever it's hosted):
```bash
claude mcp add --transport http ic-poc https://YOUR-HOST/mcp
```
Then 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.
> II's consent screen makes you choose an access level: **"Questions only"** or
> **"Actions & questions"**. Choose **Actions & questions** if you want the
> agent to make state-changing calls (`canister_update_call`) — a
> Questions-only session rejects them at ingress (see
> [Read-only sessions](#read-only-sessions) below).
## Run
```bash
cargo run
# serves http://0.0.0.0:8000 (MCP at /mcp against production II, OAuth under it; / redirects to the landing site)
# honours $PORT (default 8000), $PUBLIC_URL (default http://localhost:8000),
# $MCP_SERVE_BETA (set it to also serve the beta II instance at /mcp-beta, for staging),
# and $MCP_SERVE_METRICS (set it to serve the Prometheus exposition at /metrics)
```
The human-facing pages — the landing page and the `/privacy-policy`,
`/support`, and `/terms` documents the connector directories require — are
maintained in [dfinity/internetcomputer-org] (`public/icp-mcp/`) and served at
<https://internetcomputer.org/icp-mcp/>, so the content exists exactly once.
This origin answers their old paths (`/`, `/privacy-policy`, `/support`,
`/terms`) with permanent redirects there, keeping every published link
working. `GET /version` is the operations probe (see [Auth](#auth-oauth-21-login-via-internet-identity)).
[dfinity/internetcomputer-org]: https://github.com/dfinity/internetcomputer-org
For the OpenAI directory's domain-verification check, `GET
/.well-known/openai-apps-challenge` returns `$OPENAI_APPS_CHALLENGE_TOKEN`
verbatim as `text/plain` (trimmed), and 404s while the variable is unset or
blank — so the endpoint is inert except during a submission window.
## Deploy
The deployment binary is **self-contained**: the connect HTML, CSS, and SVG
(`src/assets/` and `crates/imcp2-core/src/assets/`) and the reference
docs (`crates/imcp2-core/static/`) are compiled in with `include_str!`,
so nothing has to ship next to it (both are build-time inputs only). The
only runtime file is the OAuth clients store, which the server always uses: it reads
the file on startup and writes it (best-effort) as client registrations change, so
give it a writable directory. imcp2 creates its operational files in
`IMCP2_STATE_DIR` (the clients store at `<dir>/oauth-clients.json`); it defaults to
the working directory. On `SIGTERM` (what
`systemctl stop`/`restart` sends) or `SIGINT`, the server drains in-flight requests
before exiting, so a redeploy doesn't cut off calls mid-flight. 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.)
- **`OAUTH_REQUIRE_RESOURCE`** — strict RFC 8707 resource indicators, **on by
default**: both OAuth legs require a `resource` naming this server, so a token
can never be minted for (and replayed from) a different MCP server. Set it to a
falsey value (`0`/`false`/`no`/`off`) only if you must serve a client too old to
send `resource`; that reopens the confused-deputy path for such clients, so
prefer updating the client.
- **`OAUTH_CIMD_ENABLED`** — Client ID Metadata Documents, **on by default**. A
falsey value (`0`/`false`/`no`/`off`) switches them off, in which case Claude
and ChatGPT register through DCR instead. A kill switch for the roll-out only,
to be removed once CIMD has run in production for a while.
A `Dockerfile` is included (works on Render / Fly / Cloud Run / Koyeb). The
reference deployment (`deploy/native/`, see its README) instead runs the binary
and Caddy as native systemd units: an `rc-X.Y.Z-N` tag deploys that candidate to
staging (attaching the deployed binary to a GitHub prerelease), and the matching
`vX.Y.Z` tag publishes the crates and the local MCP binaries; production embeds
the published crate. For a
zero-signup public URL during testing, expose the local server with a tunnel:
```bash
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:
```bash
# 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"
```
Once you hold an access token (run the OAuth flow from an MCP client, see
[Connect](#connect-from-an-mcp-client)), you can call a tool directly. The service
runs **stateless** with plain-JSON responses: each POST is handled independently
(no `initialize` handshake and no `Mcp-Session-Id`), and a `tools/call` returns a
single JSON-RPC object, not an SSE stream. The POST handler still requires the dual
`Accept` header.
```bash
TOKEN=mcp-token-… # from the OAuth flow (client -> Internet Identity sign-in)
H=(-H "Accept: application/json, text/event-stream" -H "Content-Type: application/json" -H "Authorization: Bearer $TOKEN")
# anonymous public read from a real mainnet canister (ICP ledger); pass
# derivation_origin to call as your account at an app instead
curl -s "${H[@]}" \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"canister_query","arguments":{"canister_id":"ryjl3-tyaaa-aaaaa-aaaba-cai","method":"icrc1_name","args":"()"}}}' \
http://127.0.0.1:8000/mcp | jq -r '.result.content[0].text'
# => ("Internet Computer")
```
## 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.json` in `IMCP2_STATE_DIR` (a bounded,
LRU-evicted store — see [Bounded state](#bounded-state-memory--disk)); 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.
- **Client ID Metadata Documents** — the MCP authorization spec's preferred
registration, advertised as `client_id_metadata_document_supported: true`. A
client may skip `/register` and use the https URL of its metadata document as
its `client_id`; `/mcp/oauth/authorize` fetches that document under the same
SSRF guard as app discovery (https only, public addresses only, pinned, no
redirects, 8 KiB cap, 5 s including DNS), requires its `client_id` to equal the URL, and checks the requested
redirect against the document's `redirect_uris` exactly as it would a DCR
registration's — hosted-redirect allow-list included, and checked before any
fetch, so a document can neither admit a redirect a DCR client couldn't
register nor make the server fetch a URL for a redirect it would refuse. A
hosted redirect must also be same-origin with the document URL (loopback
excepted), so a self-asserted document cannot point the code at another
party. Only clients that can authenticate as PUBLIC clients are accepted: a
`token_endpoint_auth_method` of `none` (or none given), or `none` among the
document's `token_endpoint_auth_methods_supported` — ChatGPT's case, since it
prefers `private_key_jwt` but lists `none`, which is what it uses here.
Documents must be served as `application/json`, and are cached (bounded; the
origin's remaining freshness — `max-age` less `Age` — honoured up to 24 h,
10 min when it sends none, not at all on `no-store`), so a directory client
connecting thousands of times mints no registrations. Concurrent requests for
one document share a single fetch, and at most sixteen fetches are in flight at
once per process (however many instances the binary mounts), four per host, so
one slow host cannot hold up the rest. The fetch connects directly, never
through a proxy from the environment, so the address pin always binds. Claude and ChatGPT both select CIMD over DCR when it is
advertised — which it is by default: `McpConfig::cimd_enabled` for an embedding
host, and the `imcp2` binary has it on unless `OAUTH_CIMD_ENABLED` is falsey
(a roll-out kill switch, to be removed; the deploy template takes it from the
GitHub Environment's variable of that name and the value is read once at
start-up, so switching means setting it and redeploying — clients then re-read
the metadata within minutes and fall back to DCR). Only a
document on a vetted vendor origin is fetched at all — a host on or under an
allow-listed domain, default port (the trust policy of PR #143); any other URL
`client_id` is refused before any request and pointed at the allow-listing
contact. A document-intrinsic failure (no document there, not JSON, about
another URL) is remembered for a minute so a repeat is cheap; a transient one
is not.
- `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` +
`crates/imcp2-core/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; SameSite=Lax` connect cookie
(`mcp_connect`, scoped to the instance's `…/oauth` path; `Secure` whenever
`PUBLIC_URL` is https, i.e. everywhere but plain-HTTP local development) 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 connect 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 the cookie but never the delegation (it reaches only the
consenting victim's browser); the victim holds the delegation but never the cookie.
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 (percent-encoded slashes and dots in the
> path are rejected, so the prefix check cannot be dodged by encoding), 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`), the served **II
pairings** (`instances`: each mount path with the II origin and canister it
hands off to — the only way an external monitor can learn the pairing, since
neither the mount path nor the server's origin implies it and the `II_URL*`
variables can move it), and two real-time
per-instance **session gauges** — `live_sessions` (open grants) and
`active_sessions` (a subset: those also requesting recently):
```bash
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.
```bash
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](#bounded-state-memory--disk) 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](https://github.com/dfinity/internet-identity/pull/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 check applies to this hosted server only. II exempts a server on loopback,
because browsers block its https document from fetching a `http://127.0.0.1`
URL and the check would fail closed on every local sign-in — so `imcp2-local`
serves no such document.
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 cookie;
`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](https://github.com/dfinity/internet-identity/pull/4091) /
> [#4092](https://github.com/dfinity/internet-identity/pull/4092) /
> [#4093](https://github.com/dfinity/internet-identity/pull/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 requires an explicit access-level choice: **"Questions
only"** or **"Actions & questions"**. A user who picks Questions only gets a
session whose per-app delegations are `permissions = "queries"`,
and the IC **rejects update calls made through them at ingress**. That makes
`canister_update_call` inert. 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`), 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](https://github.com/dfinity/internet-identity/pull/4086)):
```candid
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`. `target_origin` replicates only II's
*domain-based* derivation: a raw `derivation_origin` is canonicalized and used
verbatim, with no recovery of a custom derivation origin from it. When an
`app_url` is passed instead, the server resolves the derivation origin by
precedence **declared** (`/.well-known/ii-derivation-origin`, else the legacy
`/.well-known/ic-app.json` `derivation_origin`) >
built-in **known-app** registry > application origin, so a custom origin an app
declares (or that ships in the registry, e.g. `oisy.com`) **is** honoured, and
the app's `/.well-known/ii-alternative-origins` list is fetched and surfaced by
`open_app` (see the caveat under [Tools](#tools)). The one genuine limitation:
there is no reverse lookup from an app URL to a custom origin the app has not
declared.
- `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](#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
```candid
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](https://github.com/dfinity/internet-identity/pull/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`.
> 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.
## License
Licensed under the [Apache License, Version 2.0](LICENSE).
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](../../issues).
If this repository is later opened to external contributions, contributors will
be required to sign the [DFINITY CLA](https://github.com/dfinity/cla/), and this
section will be updated accordingly.
See [CONTRIBUTING.md](CONTRIBUTING.md) for how to build and test the project, and
[SECURITY.md](SECURITY.md) for reporting security vulnerabilities. Participation
is governed by our [Code of Conduct](CODE_OF_CONDUCT.md).
Connection Info
You Might Also Like
No recommendations available