+ {#if enabled}
+
+ AI assistants on this device can ask to sign you in to apps.
+
+ {:else}
+
+ Let AI assistants on this device sign in to apps using your identity.
+
+ {/if}
+
diff --git a/src/frontend/src/routes/(new-styling)/mcp/mcp-switcher.store.ts b/src/frontend/src/routes/(new-styling)/mcp/mcp-switcher.store.ts
new file mode 100644
index 0000000000..eebc9ba3bc
--- /dev/null
+++ b/src/frontend/src/routes/(new-styling)/mcp/mcp-switcher.store.ts
@@ -0,0 +1,12 @@
+import { writable } from "svelte/store";
+
+/**
+ * Whether the identity switcher in the /mcp layout header should be shown.
+ *
+ * The page sets this to `true` only during the sign-in phases (wizard,
+ * authorize) and `false` on the terminal screens (close, error, invalid,
+ * mcp-disabled), where switching identity is meaningless. It lives in a shared
+ * store because the switcher is rendered by the layout but its relevance is
+ * owned by the page's phase.
+ */
+export const showIdentitySwitcher = writable(false);
diff --git a/src/frontend/src/routes/(new-styling)/mcp/utils.ts b/src/frontend/src/routes/(new-styling)/mcp/utils.ts
new file mode 100644
index 0000000000..73086303ed
--- /dev/null
+++ b/src/frontend/src/routes/(new-styling)/mcp/utils.ts
@@ -0,0 +1,129 @@
+import type { Authenticated } from "$lib/stores/authentication.store";
+import { DelegationChain, ECDSAKeyIdentity } from "@icp-sdk/core/identity";
+import { remapToLegacyDomain } from "$lib/utils/iiConnection";
+import {
+ fromBase64URL,
+ retryFor,
+ throwCanisterError,
+ transformSignedDelegation,
+} from "$lib/utils/utils";
+
+interface McpAuthorizeInput {
+ authenticated: Authenticated;
+ /** base64url-encoded DER session pubkey supplied by the MCP server. */
+ publicKey: string;
+ /** Hostname of the app whose account the delegation acts as. */
+ app: string;
+ /** Lifetime in minutes. */
+ ttlMinutes: number;
+ /** Callback URL (on the configured MCP server origin) the delegation chain is
+ * form-POSTed to. */
+ callback: string;
+ /** Opaque value from the request, echoed back so the MCP server can tie the
+ * delivered delegation to the request it started. */
+ state: string;
+}
+
+/**
+ * Builds a two-hop delegation chain rooted at the user's identity and ending at
+ * the MCP server's public key, then delivers it to the MCP server's callback
+ * via a top-level form-POST navigation. The MCP server reads the post and
+ * redirects the browser back to `/mcp` with a `status` so this page keeps
+ * owning the UI.
+ *
+ * The chain is derived for the app's account: `get_default_account` resolves
+ * the user's default account for the app origin, and that account number (which
+ * may be the unreserved default or a specific one the user set) is what the
+ * delegation is bound to — so the MCP server acts as the same account a normal
+ * `/authorize` sign-in to the app would use, not the legacy anchor-seed
+ * principal a blind `null` produces.
+ *
+ * The canister only ever signs a delegation to a freshly-generated,
+ * non-extractable browser key — never to the public_key from the URL fragment
+ * (which is attacker-controllable). The MCP server's public key only enters the
+ * chain via a sub-delegation signed by the ephemeral key.
+ */
+export const mcpAuthorize = async ({
+ authenticated,
+ publicKey,
+ app,
+ ttlMinutes,
+ callback,
+ state,
+}: McpAuthorizeInput): Promise => {
+ const { identityNumber, actor } = authenticated;
+ // Remap an app domain on a gateway (*.icp0.io / *.icp.net) to *.ic0.app so
+ // the principal matches the one /authorize derives for the same app.
+ const effectiveOrigin = remapToLegacyDomain(`https://${app}`);
+ const maxTimeToLiveNanos = BigInt(ttlMinutes) * BigInt(60) * BigInt(1e9);
+
+ const { account_number } = await actor
+ .get_default_account(identityNumber, effectiveOrigin)
+ .then(throwCanisterError);
+
+ const ephemeralIdentity = await ECDSAKeyIdentity.generate({
+ extractable: false,
+ });
+ const ephemeralPublicKey = new Uint8Array(
+ ephemeralIdentity.getPublicKey().toDer(),
+ );
+
+ const { user_key, expiration } = await actor
+ .prepare_account_delegation(
+ identityNumber,
+ effectiveOrigin,
+ account_number,
+ ephemeralPublicKey,
+ [maxTimeToLiveNanos],
+ )
+ .then(throwCanisterError);
+
+ const canisterChain = await retryFor(5, () =>
+ actor
+ .get_account_delegation(
+ identityNumber,
+ effectiveOrigin,
+ account_number,
+ ephemeralPublicKey,
+ expiration,
+ )
+ .then(throwCanisterError)
+ .then(transformSignedDelegation)
+ .then((delegation) =>
+ DelegationChain.fromDelegations([delegation], new Uint8Array(user_key)),
+ ),
+ );
+
+ // Sub-delegate from the ephemeral key to the MCP server's public key. The
+ // expiration matches the canister-signed delegation so the chain expires as a
+ // whole.
+ const mcpPubKey = fromBase64URL(publicKey);
+ const expirationDate = new Date(Number(expiration / BigInt(1_000_000)));
+ const chain = await DelegationChain.create(
+ ephemeralIdentity,
+ { toDer: () => mcpPubKey },
+ expirationDate,
+ { previous: canisterChain },
+ );
+
+ // Submit as a top-level navigation to the configured MCP server origin (the
+ // only origin allowed by the `form-action` CSP). The MCP server redirects
+ // back to /mcp with a status param.
+ const form = document.createElement("form");
+ form.method = "POST";
+ form.action = callback;
+ const delegationInput = document.createElement("input");
+ delegationInput.type = "hidden";
+ delegationInput.name = "delegation";
+ delegationInput.value = JSON.stringify(chain.toJSON());
+ form.appendChild(delegationInput);
+ // Echo the state so the MCP server can match this delivery to the request it
+ // started.
+ const stateInput = document.createElement("input");
+ stateInput.type = "hidden";
+ stateInput.name = "state";
+ stateInput.value = state;
+ form.appendChild(stateInput);
+ document.body.appendChild(form);
+ form.submit();
+};
diff --git a/src/frontend/src/routes/(new-styling)/mcp/views/McpAuthorizeView.svelte b/src/frontend/src/routes/(new-styling)/mcp/views/McpAuthorizeView.svelte
new file mode 100644
index 0000000000..b879ebf40a
--- /dev/null
+++ b/src/frontend/src/routes/(new-styling)/mcp/views/McpAuthorizeView.svelte
@@ -0,0 +1,62 @@
+
+
+