Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
3 changes: 2 additions & 1 deletion scripts/frontend-arg-helpers.bash
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ build_frontend_install_arg() {
}

# All known fields with their defaults (null for missing/new canisters)
local -a field_names=(backend_canister_id backend_origin related_origins fetch_root_key analytics_config dummy_auth dev_csp featured_dashboard_apps)
local -a field_names=(backend_canister_id backend_origin related_origins fetch_root_key analytics_config dummy_auth dev_csp featured_dashboard_apps mcp_server_origin)
local -A current_values=(
[backend_canister_id]="null"
[backend_origin]="null"
Expand All @@ -82,6 +82,7 @@ build_frontend_install_arg() {
[dummy_auth]="null"
[dev_csp]="null"
[featured_dashboard_apps]="null"
[mcp_server_origin]="null"
)

if [ -z "$raw_config" ]; then
Expand Down
21 changes: 21 additions & 0 deletions src/frontend/src/lib/components/ui/McpLogo.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
<script lang="ts">
import type { ClassValue } from "svelte/elements";

interface Props {
class?: ClassValue;
}

const { class: className }: Props = $props();
</script>

<!-- The Model Context Protocol logo, rendered in currentColor. -->
<svg
viewBox="0 0 24 24"
fill="currentColor"
class={className}
aria-hidden="true"
>
<path
d="M13.85 0a4.16 4.16 0 0 0-2.95 1.217L1.456 10.66a.835.835 0 0 0 0 1.18.835.835 0 0 0 1.18 0l9.442-9.442a2.49 2.49 0 0 1 3.541 0 2.49 2.49 0 0 1 0 3.541L8.59 12.97l-.1.1a.835.835 0 0 0 0 1.18.835.835 0 0 0 1.18 0l.1-.098 7.03-7.034a2.49 2.49 0 0 1 3.542 0l.049.05a2.49 2.49 0 0 1 0 3.54l-8.54 8.54a1.96 1.96 0 0 0 0 2.755l1.753 1.753a.835.835 0 0 0 1.18 0 .835.835 0 0 0 0-1.18l-1.753-1.753a.266.266 0 0 1 0-.394l8.54-8.54a4.185 4.185 0 0 0 0-5.9l-.05-.05a4.16 4.16 0 0 0-2.95-1.218c-.2 0-.401.02-.6.048a4.17 4.17 0 0 0-1.17-3.552A4.16 4.16 0 0 0 13.85 0m0 3.333a.84.84 0 0 0-.59.245L6.275 10.56a4.186 4.186 0 0 0 0 5.902 4.186 4.186 0 0 0 5.902 0L19.16 9.48a.835.835 0 0 0 0-1.18.835.835 0 0 0-1.18 0l-6.985 6.984a2.49 2.49 0 0 1-3.54 0 2.49 2.49 0 0 1 0-3.54l6.983-6.985a.835.835 0 0 0 0-1.18.84.84 0 0 0-.59-.245"
/>
</svg>
1 change: 1 addition & 0 deletions src/frontend/src/lib/constants/store.constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ export const storeLocalStorageKey = {
LastUsedIdentities: "ii-last-used-identities",
Locale: "ii-locale",
CliAccess: "ii-cli-access",
McpAccess: "ii-mcp-access",
} as const;

export type StoreLocalStorageKey =
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ export const idlFactory = ({ IDL }) => {
'backend_origin' : IDL.Text,
'dev_csp' : IDL.Opt(IDL.Bool),
'dummy_auth' : IDL.Opt(IDL.Opt(DummyAuthConfig)),
'mcp_server_origin' : IDL.Opt(IDL.Text),
'feature_flags' : IDL.Opt(IDL.Vec(IDL.Tuple(IDL.Text, IDL.Bool))),
});
const HeaderField = IDL.Tuple(IDL.Text, IDL.Text);
Expand Down Expand Up @@ -56,6 +57,7 @@ export const init = ({ IDL }) => {
'backend_origin' : IDL.Text,
'dev_csp' : IDL.Opt(IDL.Bool),
'dummy_auth' : IDL.Opt(IDL.Opt(DummyAuthConfig)),
'mcp_server_origin' : IDL.Opt(IDL.Text),
'feature_flags' : IDL.Opt(IDL.Vec(IDL.Tuple(IDL.Text, IDL.Bool))),
});
return [InternetIdentityFrontendInit];
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,13 @@ export interface InternetIdentityFrontendInit {
'backend_origin' : string,
'dev_csp' : [] | [boolean],
'dummy_auth' : [] | [[] | [DummyAuthConfig]],
/**
* Origin of the trusted MCP server, e.g. "https://mcp.id.ai" (no trailing
* slash). The /mcp delegation flow delivers the delegation to this origin
* only (it's added to the form-action CSP and /mcp rejects callbacks on any
* other origin). When unset, the /mcp flow is disabled.
*/
'mcp_server_origin' : [] | [string],
/**
* Frontend feature flag overrides keyed by flag name, e.g.
* record { "EMAIL_RECOVERY"; true }. Sets the deployment-level baseline for
Expand Down
5 changes: 5 additions & 0 deletions src/frontend/src/lib/globals.ts
Original file line number Diff line number Diff line change
Expand Up @@ -119,3 +119,8 @@ export const getPrimaryOrigin = () =>
// `undefined` when the operator didn't configure this flag.
export const getConfiguredFeatureFlag = (name: string): boolean | undefined =>
frontendCanisterConfig.feature_flags[0]?.find(([key]) => key === name)?.[1];

// Origin of the trusted MCP server configured via the canister deploy args, or
// `undefined` when unset (in which case the `/mcp` delegation flow is disabled).
export const getMcpServerOrigin = (): string | undefined =>
frontendCanisterConfig.mcp_server_origin[0];
48 changes: 48 additions & 0 deletions src/frontend/src/lib/stores/mcp-access.store.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { derived, get, type Readable } from "svelte/store";
import { storeLocalStorageKey } from "$lib/constants/store.constants";
import { writableStored } from "./writable.store";

type McpAccessState = {
[identityNumber: string]: boolean;
};

type McpAccessStore = Readable<McpAccessState> & {
isEnabled: (identityNumber: bigint) => boolean;
enable: (identityNumber: bigint) => void;
disable: (identityNumber: bigint) => void;
};

export const initMcpAccessStore = (): McpAccessStore => {
const store = writableStored<McpAccessState>({
key: storeLocalStorageKey.McpAccess,
defaultValue: {},
version: 1,
});

return {
subscribe: store.subscribe,
isEnabled: (identityNumber) =>
get(store)[identityNumber.toString()] === true,
enable: (identityNumber) => {
store.update((state) => ({
...state,
[identityNumber.toString()]: true,
}));
},
disable: (identityNumber) => {
store.update((state) => {
const next = { ...state };
delete next[identityNumber.toString()];
return next;
});
},
};
};

export const mcpAccessStore = initMcpAccessStore();

/** Reactive boolean for a specific identity. */
export const isMcpAccessEnabledStore = (
identityNumber: bigint,
): Readable<boolean> =>
derived(mcpAccessStore, (state) => state[identityNumber.toString()] === true);
32 changes: 32 additions & 0 deletions src/frontend/src/lib/utils/analytics/mcpAuthorizeFunnel.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { Funnel } from "./Funnel";

/**
* /mcp authorize flow events (the funnel prefixes each with `mcp-authorize--`):
*
* start-mcp-authorize (INIT)
* mcp-authorize--request-invalid (fragment missing/malformed, or callback
* origin ≠ configured MCP server origin)
* mcp-authorize--request-received (valid request, authorize screen shown)
* mcp-authorize--confirmed (user clicked Allow access)
* mcp-authorize--access-disabled (MCP access not enabled on this device)
* mcp-authorize--success (MCP server redirected back status=success)
* mcp-authorize--error (MCP server redirected back status=error)
* end-mcp-authorize
*
* The flow navigates away to the MCP server and back, so the success and error
* events fire on a fresh page load — Plausible correlates them with the start
* by visitor session, not by JS lifetime.
*/
export const McpAuthorizeEvents = {
RequestInvalid: "request-invalid",
RequestReceived: "request-received",
Confirmed: "confirmed",
AccessDisabled: "access-disabled",
Success: "success",
Error: "error",
} as const;

export const mcpAuthorizeFunnel = new Funnel<typeof McpAuthorizeEvents>(
"mcp-authorize",
true,
);
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import { Trans } from "$lib/components/locale";
import { t } from "$lib/stores/locale.store";
import CliAccessSection from "./components/CliAccessSection.svelte";
import McpAccessSection from "./components/McpAccessSection.svelte";
</script>

<header class="flex flex-col gap-3">
Expand All @@ -16,4 +17,5 @@

<div class="mt-10 flex max-w-3xl flex-col gap-5">
<CliAccessSection identityNumber={$authenticatedStore.identityNumber} />
<McpAccessSection identityNumber={$authenticatedStore.identityNumber} />
</div>
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
<script lang="ts">
import Badge from "$lib/components/ui/Badge.svelte";
import Toggle from "$lib/components/ui/Toggle.svelte";
import McpLogo from "$lib/components/ui/McpLogo.svelte";
import { Trans } from "$lib/components/locale";
import { t } from "$lib/stores/locale.store";
import {
mcpAccessStore,
isMcpAccessEnabledStore,
} from "$lib/stores/mcp-access.store";
import McpConfirmDialog from "./McpConfirmDialog.svelte";

interface Props {
identityNumber: bigint;
}

const { identityNumber }: Props = $props();
const titleId = $props.id();

const enabledStore = $derived(isMcpAccessEnabledStore(identityNumber));
const enabled = $derived($enabledStore);

let showConfirm = $state(false);

const handleToggle = (event: Event) => {
if (!(event.currentTarget instanceof HTMLInputElement)) {
return;
}
if (event.currentTarget.checked) {
showConfirm = true;
} else {
mcpAccessStore.disable(identityNumber);
}
};

const handleConfirm = () => {
mcpAccessStore.enable(identityNumber);
showConfirm = false;
};
</script>

<section
class="border-border-secondary bg-bg-secondary flex flex-row items-start gap-4 rounded-xl border p-5"
>
<span
class="border-border-tertiary text-fg-secondary bg-bg-primary flex size-10 shrink-0 items-center justify-center rounded-lg border"
aria-hidden="true"
>
<McpLogo class="size-5" />
</span>

<div class="flex flex-1 flex-col gap-1">
<div class="flex min-h-[1.5rem] flex-row items-center gap-2">
<h3 id={titleId} class="text-text-primary text-base font-semibold">
{$t`MCP access`}
</h3>
{#if enabled}
<Badge color="success" size="sm" dot>
{$t`Enabled`}
</Badge>
{/if}
</div>
<p class="text-text-tertiary text-sm">
{#if enabled}
<Trans>
AI assistants on this device can ask to sign you in to apps.
</Trans>
{:else}
<Trans>
Let AI assistants on this device sign in to apps using your identity.
</Trans>
{/if}
</p>
</div>

<div class="shrink-0">
<Toggle
checked={enabled}
onchange={handleToggle}
aria-labelledby={titleId}
/>
</div>
</section>

{#if showConfirm}
<McpConfirmDialog
onConfirm={handleConfirm}
onClose={() => (showConfirm = false)}
/>
{/if}
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
<script lang="ts">
import { TriangleAlertIcon } from "@lucide/svelte";
import Dialog from "$lib/components/ui/Dialog.svelte";
import FeaturedIcon from "$lib/components/ui/FeaturedIcon.svelte";
import Checkbox from "$lib/components/ui/Checkbox.svelte";
import { Trans } from "$lib/components/locale";
import { t } from "$lib/stores/locale.store";

interface Props {
onConfirm: () => void;
onClose: () => void;
}

const { onConfirm, onClose }: Props = $props();

let acknowledged = $state(false);
</script>

<Dialog {onClose} width="wider">
<div class="flex flex-col gap-5 p-1">
<FeaturedIcon size="lg" variant="warning" class="self-start">
<TriangleAlertIcon class="size-6" />
</FeaturedIcon>

<h2 class="text-text-primary text-2xl font-medium">
{$t`Are you sure?`}
</h2>

<p class="text-text-tertiary text-base text-pretty">
<Trans>
Enabling this lets AI assistants on this device ask to sign you in to
apps using your identity.
</Trans>
</p>

<ul class="text-text-tertiary flex list-disc flex-col gap-2 ps-5 text-sm">
<li>
<Trans>It can send messages or move funds on your behalf.</Trans>
</li>
<li>
<Trans>Like any AI, it can hallucinate and make mistakes.</Trans>
</li>
</ul>

<Checkbox
bind:checked={acknowledged}
label={$t`I understand the risks.`}
labelClass="text-sm"
/>

<button
class="btn btn-primary btn-lg w-full"
onclick={onConfirm}
disabled={!acknowledged}
>
{$t`Enable MCP access`}
</button>
</div>
</Dialog>
Loading
Loading