Minimal MCP server that bridges an LLM to the Internet Computer.
The LLM only ever speaks textual Candid; this server does all the
encoding/decoding (and, later, signing) against the IC via
ic-agent. The MCP layer is the
official Rust SDK (rmcp).
| Tool | Args | Returns |
|---|---|---|
discover_canisters |
domain |
Canister ids behind a web domain (frontend via x-ic-canister-id; backend via /env.json + JS-bundle mining), each with provenance |
get_candid |
canister_id |
The canister's candid:service interface (.did text) |
call_canister |
canister_id, method, args (textual Candid), is_query, identity |
Reply as textual Candid, called as anonymous or a signed-in domain |
list_identities |
wait_for? |
anonymous + every signed-in domain (principal + validity); waits for a pending sign-in when wait_for is set |
sign_in |
domain |
A short URL the user opens to authorize that domain via Internet Identity |
sign_out |
domain? |
Forget a domain's delegation (or all) |
discover_canisters is the entry point when the user names a website instead
of a canister id: frontend via the x-ic-canister-id header (authoritative),
backend candidates mined from /env.json + the JS bundle (pick by label, prefer
production/IC_ ids, confirm with get_candid).
call_canister runs as identity — anonymous by default, or a domain you've
signed into. sign_in(domain) returns a URL the user opens; after they approve
in II, that domain becomes available as an identity. All these tools require a
bearer token (see Auth).
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: a browser opens the authorize page,
you sign in with Internet Identity (id.ai), and the four tools become
available. (Any MCP client with remote HTTP + OAuth support works.)
cargo run
# serves http://0.0.0.0:8000 (MCP streamable-HTTP at /mcp, info page at /)
# honours $PORT (default 8000) and $PUBLIC_URL (default http://localhost:8000)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'smcp_server_originmust be configured to this exact origin.)
A Dockerfile is included (works on Render / Fly / Cloud Run / Koyeb). For a
zero-signup public URL during testing, expose the local server with a tunnel:
cargo run & # local server on :8000
cloudflared tunnel --url http://localhost:8000 # prints https://<name>.trycloudflare.com
# restart the server with PUBLIC_URL set to that URL:
PUBLIC_URL=https://<name>.trycloudflare.com cargo run# 1. initialize, grab the session id
SID=$(curl -s -D - -o /dev/null \
-H 'Accept: application/json, text/event-stream' -H 'Content-Type: application/json' \
-d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-03-26","capabilities":{},"clientInfo":{"name":"curl","version":"0"}}}' \
http://127.0.0.1:8000/mcp | grep -i '^mcp-session-id' | tr -d '\r' | awk '{print $2}')
H=(-H "Accept: application/json, text/event-stream" -H "Content-Type: application/json" -H "Mcp-Session-Id: $SID")
curl -s "${H[@]}" -d '{"jsonrpc":"2.0","method":"notifications/initialized"}' http://127.0.0.1:8000/mcp >/dev/null
# 2. call a real mainnet canister (ICP ledger)
curl -s "${H[@]}" -d '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"call_canister","arguments":{"canister_id":"ryjl3-tyaaa-aaaaa-aaaba-cai","method":"icrc1_name","args":"()","is_query":true}}}' \
http://127.0.0.1:8000/mcp | grep '^data: {' | sed 's/^data: //' | jq -r '.result.content[0].text'
# => ("Internet Computer")/mcp is gated by a bearer token. The MCP client obtains it with a standard
OAuth 2.1 authorization-code flow — except the authorize page logs the user in
with Internet Identity (id.ai) via @dfinity/auth-client instead of
username/password, and the issued token is bound to the resulting principal.
Endpoints:
GET /.well-known/oauth-authorization-server— AS metadataGET /.well-known/oauth-protected-resource— points clients at the ASPOST /oauth/register— dynamic client registrationGET /oauth/authorize— serves the id.ai login pagePOST /oauth/approve— called after II login; mints a principal-bound codePOST /oauth/token— exchanges the code for an access token
Unauthenticated /mcp requests get 401 with a WWW-Authenticate header
pointing at the resource metadata, as the MCP spec expects.
The principal is verified, not asserted. After id.ai login the browser signs
a server-issued nonce (GET /oauth/nonce) with its delegation identity and
sends the delegation chain. The server (src/delegation.rs) verifies:
- the chain links the session key to the II root (the II canister signature is
checked against the IC mainnet root key via
ic-signature-verification); - the leaf session key's signature over the nonce (Ed25519 or P-256);
- the principal is
self_authenticating(root_pubkey); - no delegation has expired.
Only then is a principal-bound code minted. This matters because the server keys per-principal session data off that identity — a spoofable principal would let one user read another's session. (Fund safety is independent: that's enforced by the IC at signing time, not here.) PKCE (S256) is enforced; codes live 120s, nonces 300s, access tokens 1h.
Set the public base URL (used in discovery docs + the sign-in redirect) with
PUBLIC_URL; point the II /mcp flow at a deployment with II_MCP_URL
(defaults to the staging II canister).
Instead of signing each call in the browser, the MCP server holds a per-session
Ed25519 key and obtains a delegation from Internet Identity that acts as
the user's default account for an app (II PR
dfinity/internet-identity#4026).
The server then signs calls as that identity with ic-agent's DelegatedIdentity.
Flow:
sign_in("oisy.com")→ a short…/signin/<link>URL the user opens.GET /signin/<link>checks the browser'smcp_sessioncookie matches the link's session, sets aSameSite=Noneflow cookie, and redirects to II's/mcp(#public_key&callback&state&app&ttl).- II consent → top-level form-POST of the delegation chain to
/signin/callback, which verifies the flow cookie + single-usestateand stores the delegation under that session + domain. call_canister(identity="oisy.com", …)now signs as the user's oisy account.
Binding: the delegation can only land in the session that requested it
(state), and only via the same browser that authenticated that session
(mcp_session cookie at GET /signin, SameSite=None flow cookie on the
callback) — so a shared sign-in link can't be completed for someone else.
- Candid tools over MCP streamable-HTTP;
discover_canisters; Candid reference resources. - OpenID/OAuth auth (authorize page logs in via
@dfinity/auth-clientagainst id.ai); verified II delegation; PKCE; expiring tokens. - Per-session domain identities via II's
/mcpdelegation flow (sign_in/sign_out/list_identities;call_canisteridentity). - Persist sessions/delegations (currently in-memory, lost on restart).
- Scoped delegations / per-call confirmation for sensitive methods.