Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/canister-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -613,7 +613,7 @@ jobs:
# Derived fresh from the signer's seed so there's no hardcoded
# digest to drift out of sync.
dnssec_config="$(node src/frontend/tests/e2e-playwright/utils/renderDnssecTestAnchor.mjs)"
icp canister install internet_identity --wasm internet_identity_backend.wasm.gz --args "(opt record { captcha_config = opt record { max_unsolved_captchas= 50:nat64; captcha_trigger = variant {Static = variant { CaptchaDisabled }}}; related_origins = opt vec { \"https://id.ai\"; \"https://identity.ic0.app\"; \"https://identity.internetcomputer.org\" }; new_flow_origins = opt vec { \"https://id.ai\" }; openid_configs = opt vec { ${{ steps.openid-configs.outputs.OPENID_CONFIGS }} }; sso_discoverable_domains = opt vec { $sso_domains_vec }; enable_dnssec_email_recovery = opt true; dnssec_config = $dnssec_config })"
icp canister install internet_identity --wasm internet_identity_backend.wasm.gz --args "(opt record { captcha_config = opt record { max_unsolved_captchas= 50:nat64; captcha_trigger = variant {Static = variant { CaptchaDisabled }}}; related_origins = opt vec { \"https://id.ai\"; \"https://identity.ic0.app\"; \"https://identity.internetcomputer.org\" }; new_flow_origins = opt vec { \"https://id.ai\" }; mcp_server_origin = opt \"https://mcp.id.ai\"; openid_configs = opt vec { ${{ steps.openid-configs.outputs.OPENID_CONFIGS }} }; sso_discoverable_domains = opt vec { $sso_domains_vec }; enable_dnssec_email_recovery = opt true; dnssec_config = $dnssec_config })"
II_CANISTER_ID=$(icp canister status internet_identity --id-only)
icp canister install internet_identity_frontend --wasm internet_identity_frontend.wasm.gz --args "(record { backend_canister_id = principal \"$II_CANISTER_ID\"; backend_origin = \"https://backend.id.ai\"; related_origins = opt vec { \"https://id.ai\"; \"https://identity.ic0.app\"; \"https://identity.internetcomputer.org\" }; fetch_root_key = opt true; dev_csp = opt true; mcp_server_origin = opt \"https://mcp.id.ai\" })"
icp canister install test_app --wasm demos/test-app/test_app.wasm
Expand Down
71 changes: 71 additions & 0 deletions src/canister_tests/src/api/internet_identity/api_v2.rs
Original file line number Diff line number Diff line change
Expand Up @@ -465,6 +465,77 @@ pub fn update_account(
.map(|(x,)| x)
}

pub fn mcp_set_access(
env: &PocketIc,
canister_id: CanisterId,
sender: Principal,
identity_number: IdentityNumber,
enabled: bool,
) -> Result<Result<(), String>, RejectResponse> {
call_candid_as(
env,
canister_id,
RawEffectivePrincipal::None,
sender,
"mcp_set_access",
(identity_number, enabled),
)
.map(|(x,)| x)
}

pub fn mcp_access_enabled(
env: &PocketIc,
canister_id: CanisterId,
sender: Principal,
identity_number: IdentityNumber,
) -> Result<bool, RejectResponse> {
query_candid_as(
env,
canister_id,
sender,
"mcp_access_enabled",
(identity_number,),
)
.map(|(x,)| x)
}

pub fn mcp_prepare_account_delegation(
env: &PocketIc,
canister_id: CanisterId,
sender: Principal,
target_origin: FrontendHostname,
session_key: SessionKey,
max_ttl: Option<u64>,
) -> Result<Result<PrepareAccountDelegation, AccountDelegationError>, RejectResponse> {
call_candid_as(
env,
canister_id,
RawEffectivePrincipal::None,
sender,
"mcp_prepare_account_delegation",
(target_origin, session_key, max_ttl),
)
.map(|(x,)| x)
}

pub fn mcp_get_account_delegation(
env: &PocketIc,
canister_id: CanisterId,
sender: Principal,
target_origin: FrontendHostname,
session_key: SessionKey,
expiration: u64,
) -> Result<Result<SignedDelegation, AccountDelegationError>, RejectResponse> {
query_candid_as(
env,
canister_id,
sender,
"mcp_get_account_delegation",
(target_origin, session_key, expiration),
)
.map(|(x,)| x)
}

#[derive(Clone)]
pub struct AccountDelegationParams<'a> {
pub env: &'a PocketIc,
Expand Down
36 changes: 32 additions & 4 deletions src/frontend/src/lib/generated/internet_identity_idl.js
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,7 @@ export const idlFactory = ({ IDL }) => {
'backend_origin' : IDL.Opt(IDL.Text),
'captcha_config' : IDL.Opt(CaptchaConfig),
'dummy_auth' : IDL.Opt(IDL.Opt(DummyAuthConfig)),
'mcp_server_origin' : IDL.Opt(IDL.Text),
'register_rate_limit' : IDL.Opt(RateLimitConfig),
});
const UserNumber = IDL.Nat64;
Expand Down Expand Up @@ -619,6 +620,10 @@ export const idlFactory = ({ IDL }) => {
'pubkey' : DeviceKey,
'anchor_number' : UserNumber,
});
const PrepareAccountDelegation = IDL.Record({
'user_key' : UserKey,
'expiration' : Timestamp,
});
const JWT = IDL.Text;
const Salt = IDL.Vec(IDL.Nat8);
const OpenIdCredentialAddError = IDL.Variant({
Expand Down Expand Up @@ -651,10 +656,6 @@ export const idlFactory = ({ IDL }) => {
'expiration' : Timestamp,
'anchor_number' : UserNumber,
});
const PrepareAccountDelegation = IDL.Record({
'user_key' : UserKey,
'expiration' : Timestamp,
});
const PrepareAttributeRequest = IDL.Record({
'origin' : FrontendHostname,
'attribute_keys' : IDL.Vec(IDL.Text),
Expand Down Expand Up @@ -1101,6 +1102,32 @@ export const idlFactory = ({ IDL }) => {
[IDL.Opt(DeviceKeyWithAnchor)],
['query'],
),
'mcp_access_enabled' : IDL.Func([UserNumber], [IDL.Bool], ['query']),
'mcp_get_account_delegation' : IDL.Func(
[FrontendHostname, SessionKey, Timestamp],
[
IDL.Variant({
'Ok' : SignedDelegation,
'Err' : AccountDelegationError,
}),
],
['query'],
),
'mcp_prepare_account_delegation' : IDL.Func(
[FrontendHostname, SessionKey, IDL.Opt(IDL.Nat64)],
[
IDL.Variant({
'Ok' : PrepareAccountDelegation,
'Err' : AccountDelegationError,
}),
],
[],
),
'mcp_set_access' : IDL.Func(
[UserNumber, IDL.Bool],
[IDL.Variant({ 'Ok' : IDL.Null, 'Err' : IDL.Text })],
[],
),
'openid_credential_add' : IDL.Func(
[IdentityNumber, JWT, Salt, IDL.Opt(IDL.Text)],
[
Expand Down Expand Up @@ -1328,6 +1355,7 @@ export const init = ({ IDL }) => {
'backend_origin' : IDL.Opt(IDL.Text),
'captcha_config' : IDL.Opt(CaptchaConfig),
'dummy_auth' : IDL.Opt(IDL.Opt(DummyAuthConfig)),
'mcp_server_origin' : IDL.Opt(IDL.Text),
'register_rate_limit' : IDL.Opt(RateLimitConfig),
});
return [IDL.Opt(InternetIdentityInit)];
Expand Down
33 changes: 33 additions & 0 deletions src/frontend/src/lib/generated/internet_identity_types.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1035,6 +1035,11 @@ export interface InternetIdentityInit {
* Configuration for dummy authentication used in e2e tests.
*/
'dummy_auth' : [] | [[] | [DummyAuthConfig]],
/**
* Origin of the trusted MCP server (no trailing slash). Enables the backend
* /mcp delegation path; `null` leaves it disabled.
*/
'mcp_server_origin' : [] | [string],
/**
* Rate limit for the `register` call.
*/
Expand Down Expand Up @@ -1936,6 +1941,34 @@ export interface _SERVICE {
[Uint8Array | number[]],
[] | [DeviceKeyWithAnchor]
>,
/**
* Whether the anchor currently has MCP access enabled.
*/
'mcp_access_enabled' : ActorMethod<[UserNumber], boolean>,
'mcp_get_account_delegation' : ActorMethod<
[FrontendHostname, SessionKey, Timestamp],
{ 'Ok' : SignedDelegation } |
{ 'Err' : AccountDelegationError }
>,
/**
* Called by the MCP server, authorized by caller() == the anchor's principal
* at mcp_server_origin. Mints a <=5-minute delegation for the anchor's
* default account at target_origin. The anchor is recovered from the caller.
*/
'mcp_prepare_account_delegation' : ActorMethod<
[FrontendHostname, SessionKey, [] | [bigint]],
{ 'Ok' : PrepareAccountDelegation } |
{ 'Err' : AccountDelegationError }
>,
/**
* Enable/disable the backend /mcp delegation path for an anchor. Enabling
* binds the anchor's principal at the configured mcp_server_origin.
*/
'mcp_set_access' : ActorMethod<
[UserNumber, boolean],
{ 'Ok' : null } |
{ 'Err' : string }
>,
/**
* The trailing `opt text` is the SSO discovery domain (null for a direct
* provider). For SSO sign-ins a cold discovery/JWKS cache yields the
Expand Down
1 change: 1 addition & 0 deletions src/frontend/src/lib/utils/iiConnection.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ const DEFAULT_INIT: InternetIdentityInit = {
dummy_auth: [],
backend_canister_id: [],
backend_origin: [],
mcp_server_origin: [],
enable_dnssec_email_recovery: [],
doh_config: [],
dnssec_config: [],
Expand Down
12 changes: 4 additions & 8 deletions src/frontend/src/routes/(new-styling)/mcp/+page.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -206,7 +206,7 @@
await mcpAuthorize({
authenticated,
publicKey: params.publicKey,
app: params.app,
mcpServerOrigin: mcpServer.origin,
ttlMinutes: params.ttlMinutes,
callback: params.callback,
state: params.state,
Expand Down Expand Up @@ -240,23 +240,19 @@
{:else if phase.kind === "wizard" && params.kind === "valid" && mcpServerHost !== undefined}
<div class="flex w-full justify-center max-sm:flex-1 sm:max-w-110">
<AuthPanel>
<McpHero app={params.app} mcpServer={mcpServerHost} />
<McpHero mcpServer={mcpServerHost} />
<AuthWizard {...wizardSignInHandlers}>
<h1 class="text-text-primary my-2 self-start text-2xl font-medium">
{$t`Choose method`}
</h1>
<p class="text-text-secondary mb-6 self-start text-sm">
{$t`to allow MCP access to ${params.app}`}
{$t`to connect ${mcpServerHost}`}
</p>
</AuthWizard>
</AuthPanel>
</div>
{:else if phase.kind === "authorize" && params.kind === "valid" && mcpServerHost !== undefined}
<McpAuthorizeView
app={params.app}
mcpServer={mcpServerHost}
onAuthorize={handleAuthorize}
/>
<McpAuthorizeView mcpServer={mcpServerHost} onAuthorize={handleAuthorize} />
{:else if phase.kind === "mcp-disabled"}
<McpDisabledView />
{:else if phase.kind === "close"}
Expand Down
35 changes: 5 additions & 30 deletions src/frontend/src/routes/(new-styling)/mcp/+page.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,10 @@ const DEFAULT_TTL_MINUTES = 60;
* The `/mcp` request, parsed from the URL fragment the MCP server redirects the
* browser to. `valid` carries the validated request — the session public key to
* delegate to, the callback to post the delegation back to, the single-use
* `state` echoed back to the MCP server, the delegation TTL, and the app whose
* account the delegation acts as. `invalid` means the fragment was missing or
* malformed.
* `state` echoed back to the MCP server, and the delegation TTL. The account the
* delegation acts as is the user's default account at the configured MCP server
* origin (II config), not a request parameter. `invalid` means the fragment was
* missing or malformed.
*
* The callback's origin is checked against the configured MCP server origin in
* the page component (where the canister config is available), not here.
Expand All @@ -25,8 +26,6 @@ export type McpParams =
* delegation to the request it started (CSRF protection). */
state: string;
ttlMinutes: number;
/** Hostname of the app whose account the delegation acts as. */
app: string;
}
| { kind: "invalid" };

Expand Down Expand Up @@ -76,28 +75,6 @@ const parseCallback = (raw: string | null): string | undefined => {
return raw;
};

/**
* Returns the normalised hostname if `raw` is a bare hostname (optionally with
* mixed case), or undefined if it's not. Rejects port, path, query, fragment,
* scheme prefix, and userinfo by requiring the round-trip through `new URL` to
* leave only the hostname behind.
*/
const parseApp = (raw: string | null): string | undefined => {
if (raw === null || raw === "") {
return undefined;
}
let url: URL;
try {
url = new URL(`https://${raw}`);
} catch {
return undefined;
}
if (url.hostname.toLowerCase() !== raw.toLowerCase()) {
return undefined;
}
return url.hostname;
};

const parseState = (raw: string | null): string | undefined => {
if (raw === null || raw === "") {
return undefined;
Expand Down Expand Up @@ -130,20 +107,18 @@ export const load: PageLoad = ({
const publicKey = parseBase64Url(params.get("public_key"));
const callback = parseCallback(params.get("callback"));
const state = parseState(params.get("state"));
const app = parseApp(params.get("app"));
const ttlMinutes = parseTtl(params.get("ttl"));

if (
publicKey === undefined ||
callback === undefined ||
state === undefined ||
app === undefined ||
ttlMinutes === undefined
) {
return { params: { kind: "invalid" }, status };
}
return {
params: { kind: "valid", publicKey, callback, state, ttlMinutes, app },
params: { kind: "valid", publicKey, callback, state, ttlMinutes },
status,
};
};
Loading
Loading